mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
6800ff2620
commit
e9886ee554
6 files changed
+34
-31
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user