docs: correct the CSP entry in TODO.md

Scripted EPUBs are a regression from the foliate migration, not a pre-existing
gap: epub.js sandboxed without allow-scripts by default, foliate sets it
unconditionally. Records grimmory's fix — CSP on per-entry responses rather
than app-wide — which also sidesteps the mode-watcher blocker, plus the
whole-file buffering it would remove.
This commit is contained in:
2026-08-12 14:21:10 -04:00
parent 7e04826fa5
commit 5305d3bb5e
+63 -18
View File
@@ -51,28 +51,73 @@ Worth adding at the same time:
## Frontend
### No CSP, so scripted EPUBs run against the app origin
### Scripted EPUBs run against the app origin
EPUB files may contain JavaScript. foliate-js renders each section in an iframe from a
**same-origin** `blob:` URL and cannot sandbox it — `allow-scripts` is required, and
blob URLs inherit the embedder's origin — so script inside a book can reach `/api/*`
with the session cookie attached. foliate's own README says not to use it without a
Content Security Policy blocking scripts.
**This is a regression from the foliate-js migration, not a pre-existing gap.**
The obvious policy is `kit.csp` in `svelte.config.js` with `script-src: ['self']`, and
deliberately no `default-src` (it would also cover `style-src`/`img-src`/`font-src` and
kill both the book's own blob: assets and the inline `<style id="chitai-theme">` that
`hooks.server.ts` injects via `transformPageChunk`).
The old epub.js reader never passed `allowScriptedContent`. epub.js defaults it to
`false`, which sets `iframe.sandbox = "allow-same-origin"` — no `allow-scripts` — so
script inside a book never ran. The vendored foliate-js sets, unconditionally:
**What blocks it:** `mode-watcher` renders its own inline `setInitialMode` script, which
sets the dark class before first paint. SvelteKit only nonces the bootstrap script it
injects itself, so that one is blocked and every page load flashes the light theme.
Fixing it means pinning a SHA-256 of a third-party inline script whose contents change
with the package version and the props passed — it would break silently on upgrade, and
the symptom would be a theme flash rather than an error.
```js
// paginator.js — and the same in fixed-layout.js
// `allow-scripts` is needed for events because of WebKit bug
this.#iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
```
Worth revisiting if `mode-watcher` gains a nonce prop, or if the theme class moves to a
cookie so the server can set it without an inline script.
`allow-same-origin` together with `allow-scripts` is the combination that makes the
sandbox attribute do nothing. Sections are served as same-origin `blob:` URLs, so script
in a book can reach `/api/*` with the session cookie attached. foliate's README says as
much and tells you to use a CSP instead; we have not added one.
This is not theoretical. Audiobookshelf shipped the same combination and got
**CVE-2024-35236** — scripted EPUB plus an unrestricted upload gave remote code
execution; fixed in 2.10.0 by making scripted content a per-library opt-in, off by
default. Kavita (CVE-2024-39307) and Jellyfin (fixed 10.9.8) are variations on it.
Write-up: <https://gebir.ge/blog/every-trick-in-the-book/>.
**An app-wide CSP is the wrong shape.** `kit.csp` with `script-src: ['self']` also blocks
`mode-watcher`'s inline `setInitialMode`, which sets the dark class before first paint —
SvelteKit only nonces the bootstrap script it injects itself, so every page load would
flash the light theme. Pinning a hash of a third-party inline script breaks silently on
upgrade.
**Grimmory solves it properly**, and it runs foliate-js too. Rather than handing foliate
the whole file, it serves each EPUB entry from its own endpoint and puts the strict
policy on that response:
```java
// EpubReaderController.java
response.setHeader("Content-Security-Policy", "script-src 'none'");
```
The app shell keeps its own, more permissive policy. That works because each section is
then a real same-origin document with its own header, rather than a `blob:` — and a
`blob:` inherits the CSP of the document that created it, which is exactly why a header
on `/api/books/download/…` would achieve nothing today.
Two ways forward:
1. **Cheap.** Patch the vendored `sandbox` attribute to drop `allow-scripts`, restoring
what epub.js gave us. Cost is the WebKit bug the upstream comment cites: events inside
the iframe get swallowed, which would likely break touch/swipe paging and possibly the
in-iframe keyboard handling in `foliate-view.svelte`. Needs testing before trusting.
2. **Right.** Follow grimmory: a backend endpoint serving individual EPUB entries with
`script-src 'none'`, and drive foliate through its `{ loadText, loadBlob, getSize }`
loader hooks instead of a whole-file blob.
Option 2 also fixes the memory cost below, which is why it is worth more than it looks.
### Book files are buffered whole, twice
`frontend/src/routes/api/[...path]/+server.ts` reads every response with
`await response.arrayBuffer()` and forwards no `Range` header, so opening a book pulls
the entire file into the node process and then again into the browser. A large art PDF
or a comic archive is tens of megabytes each time, per reader.
Serving EPUB entries individually (option 2 above) removes it for EPUB. PDFs would still
want range support in the proxy, which is what the vendored pdf.js viewer expects and
currently never gets.
### Remove the epub.js locations-cache purge