Skip to content

Security: xiangjinwei2003/paper2html

SECURITY.md

Security

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.

What leaves your machine: nothing

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.

The local server

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.

Known limitation, stated plainly

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 browser extension

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.js collects 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.

Known limitation, stated plainly

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.

Untrusted input

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 .pdf leaf, 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.

Reporting a vulnerability

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.

There aren't any published security advisories