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

+5
View File
@@ -23,6 +23,11 @@
### Fixed
- **PDF previews show in Safari.** On macOS and iOS the preview of an
uploaded PDF stayed an empty box. The page's security policy told the
browser that nothing may frame it, and Safari applied that to the
preview the page itself opens. The policy now allows framing by the app
itself and still refuses every other site. (#1311)
- **Periods from Garmin and Oura open their cycles.** Period days imported
from a watch or a ring were stored as days only, so the cycle tracker saw
no cycle start, no lengths and no forecast. They now open and fill their
@@ -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,