diff --git a/src/__tests__/proxy-csp-blob-preview.test.ts b/src/__tests__/proxy-csp-blob-preview.test.ts new file mode 100644 index 000000000..06811925f --- /dev/null +++ b/src/__tests__/proxy-csp-blob-preview.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Refs #787 — the record fence made every vault preview load through + * `useFencedObjectUrl`, i.e. `blob:` URLs. The app CSP had no `frame-src`, so + * `default-src 'self'` blocked the PDF preview iframe loudly, and + * `img-src 'self' data:` degraded every blob thumbnail to an icon silently. + * + * These tests pin the fix exactly: + * - `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. + * - The vault SERVE route keeps its own stricter document CSP unchanged. + * + * Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only + * be minted by same-origin script, and script execution stays nonce-bound, so + * admitting blob: admits exactly the app's own decrypted documents. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function setNodeEnv(value: "development" | "production") { + vi.stubEnv("NODE_ENV", value); +} + +beforeEach(() => { + setNodeEnv("production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function makeRequest(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }); +} + +/** Parse one directive's source list out of a CSP header string. */ +function directive(csp: string, name: string): string[] | null { + const match = csp + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name} `) || part === name); + if (!match) return null; + return match.split(/\s+/).slice(1); +} + +describe("proxy.ts blob preview CSP (#787)", () => { + it("production frame-src admits exactly 'self' and blob:", () => { + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "frame-src")).toEqual(["'self'", "blob:"]); + }); + + it("production img-src admits blob: alongside 'self' and data:", () => { + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + const sources = directive(csp, "img-src"); + expect(sources).toContain("blob:"); + expect(sources).toContain("'self'"); + expect(sources).toContain("data:"); + }); + + it("production keeps the boundaries that make blob: safe", () => { + 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'"]); + expect(directive(csp, "default-src")).toEqual(["'self'"]); + expect(csp).toMatch(/script-src 'self' 'nonce-/); + }); + + it("development frame-src admits exactly 'self' and blob:", () => { + setNodeEnv("development"); + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "frame-src")).toEqual(["'self'", "blob:"]); + }); + + it("development img-src admits blob:", () => { + setNodeEnv("development"); + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "img-src")).toContain("blob:"); + }); + + it("leaves the vault serve route's document CSP untouched (no frame-src, no blob:)", () => { + const res = proxy(makeRequest("/api/documents/inbound/doc-1/original")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(csp).toBe("default-src 'none'; frame-ancestors 'self';"); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 1a2b435f5..dd95fdf28 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -528,9 +528,21 @@ export function proxy(request: NextRequest) { // HTML/SVG denied entirely, and attachment-only posture for every // non-passive format. `default-src 'none'; frame-ancestors 'self';` - : isDev - ? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';` - : `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; 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;`; + : // 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. + 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}'; 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;`; response.headers.set("Content-Security-Policy", csp); // Production-only headers. HSTS carries `preload` so the domain stays