Owner inline downloads serve HTML and SVG as live pages on the app origin #107

Closed
opened 2026-09-25 17:30:39 +00:00 by kayg · 1 comment
Owner

Context

Found during the share audit (branch job/share-audit, 2026-09-25); outside that job's files, so not fixed there.

GET /api/v1/files/download?path=…&inline=true (and the x-calternal-inline: true header) in crates/plugins/files/src/lib.rs (download) serves a user's file inline on the app's own origin with the type from mime_guess. An .html, .xhtml or .svg file therefore renders as a live page on the app origin when someone opens that URL. There is no Content-Security-Policy, no X-Content-Type-Options: nosniff and no sandbox on the response (no security headers exist anywhere in the server).

Attack: a user with edit access to a shared folder (an internal share) uploads report.html or chart.svg with script. When the owner opens the inline URL (a link in a note, a copied Quick Look URL), the script runs as the owner in the app origin: it can call the API with the owner's session cookie (read all files, create public links, change settings).

Fix

Do what the public /preview endpoint now does (crates/plugins/files/src/public.rs, preview_bytes):

  • Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline' on every inline file response (sandbox gives the document an opaque origin and blocks script).
  • X-Content-Type-Options: nosniff on every file response.
  • Serve text/html, application/xhtml+xml and application/javascript inline as text/plain; charset=utf-8.
  • Use the RFC 6266 / RFC 8187 Content-Disposition builder (content_disposition in public.rs) so non-ASCII and control characters in names cannot break the header.
  • Check the same for /api/v1/files/versions/download, thumbnails, the video plugin's inline route, and the DAV plugin's GET.

Tests: a Rust test per endpoint, and a tests/adversarial/ probe that uploads an HTML and an SVG file with script and checks the headers of every read endpoint.

## Context Found during the share audit (branch `job/share-audit`, 2026-09-25); outside that job's files, so not fixed there. `GET /api/v1/files/download?path=…&inline=true` (and the `x-calternal-inline: true` header) in `crates/plugins/files/src/lib.rs` (`download`) serves a user's file **inline on the app's own origin** with the type from `mime_guess`. An `.html`, `.xhtml` or `.svg` file therefore renders as a live page on the app origin when someone opens that URL. There is no `Content-Security-Policy`, no `X-Content-Type-Options: nosniff` and no `sandbox` on the response (no security headers exist anywhere in the server). Attack: a user with **edit** access to a shared folder (an internal share) uploads `report.html` or `chart.svg` with script. When the owner opens the inline URL (a link in a note, a copied Quick Look URL), the script runs as the owner in the app origin: it can call the API with the owner's session cookie (read all files, create public links, change settings). ## Fix Do what the public `/preview` endpoint now does (`crates/plugins/files/src/public.rs`, `preview_bytes`): - `Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'` on every inline file response (sandbox gives the document an opaque origin and blocks script). - `X-Content-Type-Options: nosniff` on every file response. - Serve `text/html`, `application/xhtml+xml` and `application/javascript` inline as `text/plain; charset=utf-8`. - Use the RFC 6266 / RFC 8187 `Content-Disposition` builder (`content_disposition` in `public.rs`) so non-ASCII and control characters in names cannot break the header. - Check the same for `/api/v1/files/versions/download`, thumbnails, the video plugin's inline route, and the DAV plugin's GET. Tests: a Rust test per endpoint, and a `tests/adversarial/` probe that uploads an HTML and an SVG file with script and checks the headers of every read endpoint.
Author
Owner

#107 fixed on job/inline-xss (rebased on dev 8400bf2, not merged)

Proof that the tests failed first

Commit 63cb443 adds the tests before the fix. They failed on every route because there was no guard:

assertion `left == right` failed: report.html: nosniff
  left: ""
 right: "nosniff"
test result: FAILED. 0 passed; 6 failed   (files: inline query+header, svg, attachment, versions, thumbnail, public download)
assertion `left == right` failed: source   (video source and playlists)

One shared helper: calternal_plugin_files::user_bytes

  • nosniff and Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline' go on every user-bytes response, attachments too.
  • Inline active types go out as text/plain; charset=utf-8: HTML, XHTML, all XML and +xml (XSLT), and JavaScript/ECMAScript. SVG keeps image/svg+xml only when Sec-Fetch-Dest: image (our <img> tags: Quick Look, photos, note images). For a navigation, fetch or an unknown client it is text. Inline responses send Vary: Sec-Fetch-Dest.
  • entity_tag: a neutralized answer gets its own ETag ("<hash>-text"). Both 304 paths use it. The browser probe found this bug: Chromium revalidated the cached text answer for an <img>, got 304 and showed a broken image.
  • An RFC 6266 / RFC 8187 content_disposition builder, and strip_unsafe_name_chars. The copies from share-audit in public.rs moved here, so there is one rule. head.rs uses the re-export.

Endpoints

Files download (inline by query and by header, ranges, attachments), versions download (now named, still an attachment), thumbnails, ZIP, public download, preview and thumbnail, video source, HLS master, variants and segments. Photos originals and Notes attachments use files download, so they are covered. DAV, AI and the API send only JSON, XML or iCalendar that the server makes. The middleware covers them.

Baseline headers (crates/calternal-server/src/security.rs, one outermost middleware)

  • On every response: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, and Permissions-Policy: camera=(), display-capture=(), geolocation=(), microphone=(self), midi=(), payment=(), usb=(), serial=(), hid=(), browsing-topics=().
  • On /api/*, /dav/* and /.well-known/*: default-src 'none'; …; frame-ancestors 'none'; base-uri 'none'. It has no form-action, because the CLI approval form redirects to loopback. A handler's own policy always wins.
  • On the SPA shell: the enforced base-uri 'self'; object-src 'none'; frame-ancestors 'none', plus the full script policy as Report-Only. The server hashes the shell's inline scripts (theme init and SvelteKit bootstrap) with SHA-256 from the embedded index.html. Chromium walked /files, /today, /notes, /calendar, /photos and /settings with 0 reports. Follow-up: #118 (enforce it).

Adversarial: tests/adversarial/hostile_bytes.mjs (runs in run.sh after round 1 and before round 2)

Member B, as an editor of the owner's shared folder, plants HTML, HTM, XHTML, SVG (with <script> and onload), JS, XML with XSLT, an XSL file, a GIF+HTML polyglot and a name with quotes and non-ASCII characters. The probe checks the headers on every read endpoint listed above, and on DAV, the API and the shell. Then Chromium, signed in as the owner, opens each inline URL. The planted script sets window.__pwned and calls a local beacon. The flag stays unset and the beacon gets 0 hits. The same SVG still renders in an <img> in the app. The probe then revokes its share and link.

Gates (final tree)

  • cargo fmt --check: exit 0
  • cargo clippy --workspace --all-targets -- -D warnings: exit 0 (Finished dev profile … in 54.69s)
  • cargo test --workspace --no-fail-fast: exit 0, 1096 passed, 0 failed, 9 ignored
  • bash packages/api-client/check-generated.sh: exit 0 (one regenerated doc string)
  • apps/web bun run check: COMPLETED 1393 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS. bun run test: Test Files 40 passed (40), Tests 272 passed (272). bun run build: ✓ built in 41.87s
  • e2e: FILES E2E PASSED, SHARE E2E PASSED, search e2e: ok. Two search runs at load average 33–36 failed on layout-timing assertions, each on a different one. The run at load average 19 passed.
  • bash tests/adversarial/run.sh: HOSTILE BYTES FINDINGS 0, round 1 has 1 SLOW only (load), restart probe: 0 findings, round 2 has 1: search access (a trashed file still shows in search). #107 does not change search. This finding is intermittent (0 on the run before), filed as #120.

Also filed and closed #119: a files test that failed on dev and was fixed on dev by 73084fc.

## #107 fixed on `job/inline-xss` (rebased on dev 8400bf2, not merged) ### Proof that the tests failed first Commit 63cb443 adds the tests before the fix. They failed on every route because there was no guard: ``` assertion `left == right` failed: report.html: nosniff left: "" right: "nosniff" test result: FAILED. 0 passed; 6 failed (files: inline query+header, svg, attachment, versions, thumbnail, public download) assertion `left == right` failed: source (video source and playlists) ``` ### One shared helper: `calternal_plugin_files::user_bytes` - `nosniff` and `Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'` go on **every** user-bytes response, attachments too. - Inline active types go out as `text/plain; charset=utf-8`: HTML, XHTML, all XML and `+xml` (XSLT), and JavaScript/ECMAScript. SVG keeps `image/svg+xml` only when `Sec-Fetch-Dest: image` (our `<img>` tags: Quick Look, photos, note images). For a navigation, `fetch` or an unknown client it is text. Inline responses send `Vary: Sec-Fetch-Dest`. - `entity_tag`: a neutralized answer gets its own ETag (`"<hash>-text"`). Both 304 paths use it. The browser probe found this bug: Chromium revalidated the cached text answer for an `<img>`, got 304 and showed a broken image. - An RFC 6266 / RFC 8187 `content_disposition` builder, and `strip_unsafe_name_chars`. The copies from share-audit in `public.rs` moved here, so there is one rule. `head.rs` uses the re-export. ### Endpoints Files download (inline by query and by header, ranges, attachments), versions download (now named, still an attachment), thumbnails, ZIP, public download, preview and thumbnail, video source, HLS master, variants and segments. Photos originals and Notes attachments use files download, so they are covered. DAV, AI and the API send only JSON, XML or iCalendar that the server makes. The middleware covers them. ### Baseline headers (`crates/calternal-server/src/security.rs`, one outermost middleware) - On every response: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: DENY`, and `Permissions-Policy: camera=(), display-capture=(), geolocation=(), microphone=(self), midi=(), payment=(), usb=(), serial=(), hid=(), browsing-topics=()`. - On `/api/*`, `/dav/*` and `/.well-known/*`: `default-src 'none'; …; frame-ancestors 'none'; base-uri 'none'`. It has no `form-action`, because the CLI approval form redirects to loopback. A handler's own policy always wins. - On the SPA shell: the enforced `base-uri 'self'; object-src 'none'; frame-ancestors 'none'`, plus the full script policy as **Report-Only**. The server hashes the shell's inline scripts (theme init and SvelteKit bootstrap) with SHA-256 from the embedded `index.html`. Chromium walked /files, /today, /notes, /calendar, /photos and /settings with 0 reports. **Follow-up: #118** (enforce it). ### Adversarial: `tests/adversarial/hostile_bytes.mjs` (runs in `run.sh` after round 1 and before round 2) Member B, as an editor of the owner's shared folder, plants HTML, HTM, XHTML, SVG (with `<script>` and `onload`), JS, XML with XSLT, an XSL file, a GIF+HTML polyglot and a name with quotes and non-ASCII characters. The probe checks the headers on every read endpoint listed above, and on DAV, the API and the shell. Then Chromium, signed in as the owner, opens each inline URL. The planted script sets `window.__pwned` and calls a local beacon. The flag stays unset and the beacon gets **0 hits**. The same SVG still renders in an `<img>` in the app. The probe then revokes its share and link. ### Gates (final tree) - `cargo fmt --check`: exit 0 - `cargo clippy --workspace --all-targets -- -D warnings`: exit 0 (`Finished dev profile … in 54.69s`) - `cargo test --workspace --no-fail-fast`: exit 0, 1096 passed, 0 failed, 9 ignored - `bash packages/api-client/check-generated.sh`: exit 0 (one regenerated doc string) - apps/web `bun run check`: `COMPLETED 1393 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS`. `bun run test`: `Test Files 40 passed (40)`, `Tests 272 passed (272)`. `bun run build`: `✓ built in 41.87s` - e2e: `FILES E2E PASSED`, `SHARE E2E PASSED`, `search e2e: ok`. Two search runs at load average 33–36 failed on layout-timing assertions, each on a different one. The run at load average 19 passed. - `bash tests/adversarial/run.sh`: `HOSTILE BYTES FINDINGS 0`, round 1 has 1 SLOW only (load), `restart probe: 0 findings`, round 2 has 1: `search access` (a trashed file still shows in search). #107 does not change search. This finding is intermittent (0 on the run before), filed as **#120**. Also filed and closed #119: a files test that failed on dev and was fixed on dev by 73084fc.
kayg closed this issue 2026-09-25 22:31:59 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#107
No description provided.