fix(security): let the app frame its own blob previews

A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 00:52:02 +01:00
1 parent 6800ff2620
commit e9886ee554
6 files changed
+34 -31

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94069,
"total": 94064,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -86,6 +86,6 @@
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
"proxy.ts": 870
"proxy.ts": 865
}
}
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111909,
"total": 111902,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -180,7 +180,7 @@
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
@@ -347,6 +347,6 @@
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 498
"proxy.ts :: proxy": 493
}
}
+5 -2
View File
@@ -11,7 +11,7 @@ import { NextRequest } from "next/server";
* - `frame-src` exists and admits precisely 'self' and blob: — nothing else.
* - `img-src` admits blob: alongside 'self' and data:.
* - The boundaries that make blob: safe stay untouched: `object-src 'none'`,
* `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src.
* `frame-ancestors 'self'`, `default-src 'self'`, nonce-bound script-src.
* - The vault SERVE route keeps its own stricter document CSP unchanged.
*
* Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only
@@ -73,7 +73,10 @@ describe("proxy.ts blob preview CSP (#787)", () => {
const res = proxy(makeRequest("/documents"));
const csp = res.headers.get("content-security-policy") ?? "";
expect(directive(csp, "object-src")).toEqual(["'none'"]);
expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
// 'self', never 'none': the blob: preview inherits this policy, and
// WebKit applies the inherited frame-ancestors to the preview's own
// parent, so 'none' blanks every PDF preview in Safari.
expect(directive(csp, "frame-ancestors")).toEqual(["'self'"]);
expect(directive(csp, "default-src")).toEqual(["'self'"]);
expect(csp).toMatch(/script-src 'self' 'nonce-/);
});
@@ -4,8 +4,7 @@ import { NextRequest } from "next/server";
/**
* The document vault's decrypt-and-serve route is framed same-origin by the
* detail preview (`<iframe>` for inline-class PDFs). The proxy's blanket
* `X-Frame-Options: DENY` + `frame-ancestors 'none'` would refuse that
* embed, and the blanket page CSP overrides whatever the route sets
* `X-Frame-Options: DENY` would refuse that embed, and the blanket page CSP overrides whatever the route sets
* (middleware headers win) — so the proxy carves out EXACTLY the
* `/original` path with a same-origin framing posture and a document CSP
* (`default-src 'none'`; deliberately no `sandbox` — Chromium
@@ -58,7 +57,7 @@ describe("proxy.ts document serve-route framing carve-out", () => {
const res = proxy(makeRequest("/api/documents/inbound"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
@@ -82,7 +81,7 @@ describe("proxy.ts document serve-route framing carve-out", () => {
const res = proxy(makeRequest("/documents"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
});
@@ -103,11 +102,12 @@ describe("proxy.ts share document serve-route framing carve-out", () => {
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
const res = proxy(makeRequest(`/c/${shareToken}`));
// The clinician view is a PAGE — it must not be framable and keeps the
// page CSP; only the blob subresource under it takes the serve posture.
// The clinician view is a PAGE — it keeps the page CSP, framable by its
// own origin only (see the blob: note in proxy.ts); only the served
// document under it takes the serve posture.
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
+12 -17
View File
@@ -637,7 +637,7 @@ export function proxy(request: NextRequest) {
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
// the document detail preview (an <iframe> for inline-class PDFs). The
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
// blanket `X-Frame-Options: DENY` below would
// refuse that embed, and the blanket page CSP would clobber the route's
// own `Content-Security-Policy: sandbox` (middleware headers win over
// route headers). Scope: exactly the `/original` path — list/detail API
@@ -652,8 +652,8 @@ export function proxy(request: NextRequest) {
// it decrypts and serves a user-uploaded document that the clinician view
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
// NOT the `/c/` page CSP, which pins `frame-ancestors 'none'` and would
// refuse the preview embed. Anchored to EXACTLY two dynamic segments so a
// NOT the `/c/` page posture, whose `X-Frame-Options: DENY` would refuse
// the preview embed. Anchored to EXACTLY two dynamic segments so a
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
// lookalike never inherits the carve-out.
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
@@ -719,7 +719,7 @@ export function proxy(request: NextRequest) {
// closing the Spectre-class side-channel surface a stray
// `window.opener` reference would otherwise carry. CORP narrows the
// page's resources to same-origin loaders, complementing the
// `frame-ancestors 'none'` CSP rule for non-document subresources.
// same-origin-only `frame-ancestors` CSP rule for non-document subresources.
// The legacy `X-Permitted-Cross-Domain-Policies` line shuts the
// Flash/PDF crossdomain.xml channel that some scanners still flag.
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
@@ -810,21 +810,16 @@ export function proxy(request: NextRequest) {
// HTML/SVG denied entirely, and attachment-only posture for every
// non-passive format.
`default-src 'none'; frame-ancestors 'self';`
: // v1.37.15 — `frame-src 'self' blob:` + `blob:` in `img-src` (both dev
// and prod). Since the record fence (v1.37.0) every vault preview loads
// through `useFencedObjectUrl`, i.e. a `blob:` object URL: without a
// `frame-src` the PDF preview iframe fell through to `default-src
// 'self'` and was blocked loudly, and `img-src 'self' data:` degraded
// every blob thumbnail to its fallback icon silently. Admitting `blob:`
// is same-origin-tight: a blob: URL can only be minted by script running
// on this origin (`URL.createObjectURL`), script execution stays
// nonce-bound (prod), and `object-src 'none'` / `frame-ancestors
// 'none'` / `default-src 'self'` are unchanged — no foreign origin
// gains anything. Reverting the previews to direct `/api` src instead
// is barred by the record-fence guard.
: // v1.37.15 — `frame-src 'self' blob:` + `blob:` in `img-src`: every
// vault preview loads through `useFencedObjectUrl` as a `blob:` URL,
// which only script on this origin can mint; script stays nonce-bound
// and `object-src 'none'` / `default-src 'self'` hold. A blob: document
// inherits this policy and WebKit applies its `frame-ancestors` to the
// blob frame's own parent, so 'none' blanked every PDF preview in
// Safari (macOS and iOS); 'self' still refuses every foreign origin.
isDev
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'${wasmScriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'${wasmScriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
// The MCP OAuth authorization endpoint sets its own CSP. Its consent form
// posts to this origin and the server then redirects to the connecting
// client's `redirect_uri`; Chromium applies `form-action` to that redirect,