Let the app CSP frame and render its own blob previews

Since the record fence, vault previews load through object URLs: the
detail sheet frames a decrypted PDF from a blob: URL and thumbnails
render from blob: images. The app CSP carried no frame-src, so the PDF
iframe fell through to default-src 'self' and was blocked outright,
while img-src 'self' data: silently degraded every thumbnail to its
fallback icon.

Add frame-src 'self' blob: and extend img-src with blob: in both the
dev and prod policies. This admits exactly the app's own documents: a
blob: URL is mintable only by same-origin script, script execution
stays nonce-bound in production, and object-src 'none', frame-ancestors
'none' and default-src 'self' are untouched. The vault serve route
keeps its stricter document CSP. A unit test pins the directives so a
future CSP edit cannot silently regress the previews.

Refs #787
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 12:08:59 +02:00
1 parent 2a85da67f7
commit 4543fa7192
2 files changed
+115 -3

No files matched your search

@@ -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';");
});
});
+15 -3
View File
@@ -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