PaperDesk runs a small HTTP server on your machine and ships a browser extension that holds broad host permissions. Both deserve an explanation rather than a footnote, so this document states what the software does, what it deliberately does not do, and where the trust boundaries actually sit.
The macOS app makes no outbound network requests. There is no telemetry, no crash
reporting, no update check, no metadata lookup against Crossref or any other service.
The only socket it opens is a listener bound to 127.0.0.1, and the only reason it
exists is that Chrome's built-in translation refuses to run on file:// URLs — a
reading copy has to be served over HTTP to be translatable at all.
Your PDFs, the HTML built from them, your tags and your notes stay in
~/Documents/PaperDesk/ as ordinary files. Translation itself is performed by Chrome,
against Google's servers, exactly as it would be on any other web page — that is
Chrome's data flow, not PaperDesk's, and it applies only to pages you actually
translate.
LocalHTTPServer binds with requiredLocalEndpoint set to 127.0.0.1, so it is not
reachable from your network even if the interface is up. The port is derived from a
hash of the library path and sits in the IANA dynamic range, which keeps bookmarks
working across restarts.
Loopback is not by itself a trust boundary: any web page you visit can send requests to
127.0.0.1, and so can any process running as you. The server is written accordingly.
| Route | Who may call it | How that is enforced |
|---|---|---|
GET /paperdesk/ping |
anyone local | Returns only {"app":"paperdesk","port":N} — no library data |
GET /items/{uuid}/reading.html |
anyone local | Path allowlist, then a filesystem-boundary check |
GET /items/{uuid}/figures/fig-N.png |
anyone local | Same, with a strict fig-\d{1,3}\.png name pattern |
POST /paperdesk/ingest |
browser extensions only | Origin must be chrome-extension:// or moz-extension://, and a custom X-PaperDesk-Client header must be present |
POST /paperdesk/progress |
this server's own pages | Origin must be this server |
GET /paperdesk/progress/{uuid} |
this server's own pages | Absent Origin (same-origin GET) or this server's Origin |
The two conditions on ingest close it to ordinary web pages: a page's origin is
http(s)://… and cannot be forged, and the custom header forces a CORS preflight that
this server answers without Access-Control-Allow-Origin for any non-extension origin.
A page therefore cannot reach the endpoint even though it can reach the port. This is
covered by tests in Tests/PaperDeskCoreTests/IngestEndpointTests.swift.
Static reads are constrained by an allowlist (isAllowedReadingPath) before any path
is built, and then again by isDescendant after symlink resolution. Only
reading.html and rendered figures under items/{uuid}/ are reachable; nothing else in
the library — not the original PDF, not library.json, not your notes — is served.
The origin check is a browser-level control, not authentication. Any process
already running as your user can set an Origin header and the custom header with
curl and push a PDF into your library. There is no shared secret, and adding one
would not help: a local process could read it from disk.
PaperDesk treats "code already executing as you" as outside its threat model, the same way a text editor does. What it defends against is the realistic case — a web page in your browser reaching a port it can see. If your threat model includes hostile local processes, do not run PaperDesk.
The extension requests <all_urls> host permission plus scripting. That is a lot,
and it is worth being concrete about why.
To fetch a paywalled PDF the request must carry your session. A fetch issued from the
extension's own service worker is cross-site to the publisher: SameSite=Lax cookies
are withheld, there is no Referer, and publishers behind bot protection answer 403
even while the PDF renders fine in your tab. So the extension injects a fetch into
the page you are on, which makes it the request you would have made yourself.
Injecting into arbitrary publisher domains is what <all_urls> and scripting buy,
and there is no narrower permission that works — the set of academic publishers is not
enumerable in advance.
What the extension does not do:
- It does not read page content.
content-collect.jscollects link URLs and the visible text of link-like elements, for the sole purpose of finding the PDF download link. - It does not run on page load. Nothing is injected until you click the toolbar button or the context-menu item.
- It sends nothing anywhere except
127.0.0.1. There is no analytics endpoint and no remote configuration. - It has no
<script src>from any CDN, and no remote code execution path.
Candidate URLs are filtered to http(s) only, as an allowlist rather than a blocklist —
the candidate list is assembled from a page's own DOM, so a hostile page decides what
is in it, and file:, javascript: and anything not yet thought of must fail closed.
A hostile page can put a high-scoring "Download PDF" link on itself pointing at some other authenticated URL. If you then click PaperDesk on that page, the extension may fetch that URL with your cookies and hand the bytes to the local app.
The result lands in your own library on your own machine — the attacking page never
sees the response, and nothing is transmitted to a third party. It requires a
deliberate click and the response must begin with %PDF-. It is a nuisance rather than
a disclosure, which is why the fallback that enables it has not been removed: taking it
out would break legitimate cross-origin PDF hosting, which is how a large share of
publishers actually serve files.
PDFs are untrusted input, and academic PDFs arrive from wherever the reader found them.
- Text extracted from a PDF is HTML-escaped (
&,<,>,",') before it reaches the reading page, including titles, captions, bylines and citation tooltips. - Filenames supplied over the ingest endpoint are stripped of path separators and
..segments and forced to a.pdfleaf, then length-capped (IngestService.safeFilename, tested against../../etc/passwd). - Request headers are capped at 64 KB and bodies at 256 MB, so a malformed local client cannot exhaust memory.
- Parsing runs through PDFKit; PaperDesk does not implement its own PDF parser.
The reading page carries a small inline script that reports scroll position back to the app. It is generated by PaperDesk, not by the PDF, and the PDF cannot inject into it.
Open a GitHub issue for anything low-risk. For something you would rather not post publicly, use GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability).
This is a personal project maintained in spare time, and it carries no response-time guarantee.