Skip to content

fix: sandbox the Content and Metadata the library delivers - #136

Merged
johnf merged 2 commits into
mainfrom
spec/133-sandbox-delivered-bytes
Oct 28, 2026
Merged

johnf merged 2 commits into
mainfrom
spec/133-sandbox-delivered-bytes

Conversation

@johnf

@johnf johnf commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #133, closes #134, closes #135

Summary

Every Delivery the library sends itself now carries protective headers, so a deposited HTML or SVG file can't run scripts on the API's origin. See ADR 0005.

 sendDelivery(stream | path | json)          # Content and Metadata, GET and HEAD
   setDeliveryHeaders                        # was setFileHeaders (internal)
     Content-Type, Content-Length, ETag, Last-Modified
+    X-Content-Type-Options: nosniff
+    unless Content-Type is a single audio/* or video/* type (RFC 9110 grammar):
+      Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
 sendDelivery(redirect) / ?noRedirect=true    # unchanged: can't protect the target
  • Media exemption: Chrome won't play audio or video opened directly under sandbox, or under default-src 'none'. A media document can't run script, and nosniff stops it being sniffed into HTML. The type must parse as one media type. Browsers split the header as a list and take the last type, so audio/mpeg, text/html keeps the CSP.
  • Override: there's no plugin option. A deployment registers an onSend hook in the same scope as arocapi (shown in the storage configuration docs).
  • Docs: the Storage page, the S3Storage docs and the storage backend reference tell redirecting backends to protect their own target.
  • Rejected alternative: sending Access-Control-Allow-Origin: * would also make sandboxed media play. It would also let any origin's scripts read Content the API serves, cutting across the access checks.

Evidence

Route tests through inject, GET and HEAD, stream and path:

text/html, image/svg+xml, application/pdf, octet-stream   → CSP + nosniff
audio/mpeg, text/html · video/mp4,text/html · ' audio/mpeg' → CSP + nosniff
audio/wav · AUDIO/Mpeg;rate=44100 · video/mp4; codecs="avc1.42E01E, mp4a.40.2" → nosniff only
Metadata json / stream                                     → CSP + nosniff
redirect · ?noRedirect=true · 404 · 500 (incl. a throw while setting Last-Modified) → neither
onSend hook in arocapi's scope                             → replaces the CSP
  • Before: the new header tests fail (no CSP or nosniff on any delivery). The crafted list types failed against the first isMedia (a prefix match) until the type was parsed strictly.
  • After: pnpm lint is clean, pnpm test passes 1015 tests at 100% coverage, and pnpm --filter website build passes with all internal links valid.

The browser behaviour the policy relies on was checked by hand on 2026-10-28, not in CI:

  • PDFs, images and text render under this CSP in Chrome 156 and Firefox 157, and in Safari from 18.4 per WebKit bug 284594.
  • Media opened directly fails in Chrome under sandbox.

Merge Danger

Door: two-way

Removing or changing the headers is a patch. Every deployment on a release with this fix is protected, and reverting reopens the hole.

Blast Radius: deposited-HTML

  • Deposited HTML and SVG opened from the API still render but run no scripts and load nothing from outside, including data: images and fonts. Inline styles still apply.
  • Safari 17.4.1–18.3 won't show inline PDFs.
  • <audio>, <video> and <img> elements on portal pages are unaffected.
  • Released as a fix: patch. The first push to main publishes a beta prerelease.

Review findings not acted on:

  • No CSP at all on media. default-src 'none'; media-src 'self' is untested in browsers, and a media document can't run script.
  • The CSP literal is repeated in the tests and docs on purpose. The tests pin the value, and the docs tell redirect targets what to send.
  • data: images and fonts stay blocked, matching GitHub raw. They can be allowed later in a patch if deposits need them.

johnf added 2 commits October 28, 2026 11:31
…#135)

ADR 0005 records that the library sandboxes the bytes it delivers. The Storage page now says
the library can't add headers to a redirect target, and what a redirecting backend or a CDN in
front of S3 should do instead.
Deposited HTML and SVG served by the API no longer run scripts or load outside resources.

Every stream, path and json delivery now carries `X-Content-Type-Options: nosniff` and
`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`. A single
audio or video Content-Type skips the CSP, as Chrome won't play media opened directly under it.
Redirects are unchanged. A deployment can replace the headers in an onSend hook registered in
arocapi's scope, as the storage configuration docs show.
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 100% (🎯 100%) 1138 / 1138
🟢 Statements 100% (🎯 100%) 1199 / 1199
🟢 Functions 100% (🎯 100%) 272 / 272
🟢 Branches 100% (🎯 100%) 782 / 782
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/storage/deliver.ts 100% 100% 100% 100%
src/utils/headers.ts 100% 100% 100% 100%
Generated in workflow #139 for commit 27ff49e by the Vitest Coverage Report Action

@johnf
johnf requested a review from moisbo October 28, 2026 00:45
@johnf
johnf merged commit 2c872df into main Oct 28, 2026
3 checks passed
@johnf
johnf deleted the spec/133-sandbox-delivered-bytes branch October 28, 2026 00:45
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 5.0.0-beta.8 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant