diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index 84520733b..9ab2d097c 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -212,7 +212,7 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", ["image/webp", "scan.webp"], ["image/gif", "anim.gif"], ])( - "Class A %s → inline, true type, nosniff, CSP sandbox", + "Class A %s → inline, true type, nosniff", async (mime, name) => { seed(mime, name); const res = await callGet(makeReq("doc-m"), ctx("doc-m")); @@ -220,7 +220,12 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", expect(res.headers.get("Content-Type")).toBe(mime); expect(res.headers.get("Content-Disposition")).toContain("inline"); expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); - expect(res.headers.get("Content-Security-Policy")).toBe("sandbox"); + // The response CSP is owned by the proxy carve-out (`default-src + // 'none'; frame-ancestors 'self'`, deliberately no sandbox — + // Chromium force-downloads sandboxed PDFs). A route-level value + // would be overwritten there, so the route must not set one; see + // src/__tests__/proxy-document-serve-framing.test.ts for the pin. + expect(res.headers.get("Content-Security-Policy")).toBeNull(); expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }, ); @@ -250,7 +255,7 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", expect(disposition.startsWith("attachment;")).toBe(true); expect(disposition).toContain(name); expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); - // No render context → no sandbox header needed on a pure download. + // CSP is proxy-owned on every serve response; the route sets none. expect(res.headers.get("Content-Security-Policy")).toBeNull(); expect(res.headers.get("Cache-Control")).toBe("private, no-store"); }, diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts index 2acf5e2b4..f4681888f 100644 --- a/src/app/api/documents/inbound/[id]/original/route.ts +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -12,12 +12,19 @@ * * - inline (Class A: PDF/JPEG/PNG/WebP/GIF): true Content-Type, * `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`, - * `Cache-Control: private, no-store`, plus `Content-Security-Policy: - * sandbox` as defence-in-depth for the PDF viewer context. + * `Cache-Control: private, no-store`. * - attachment (Class B: Office/text/TIFF/HEIC/XML/JSON): ALWAYS * `Content-Disposition: attachment` with `Content-Type: * application/octet-stream` + `nosniff` — never inline, regardless of * the stored type, so a misclassified file cannot execute in-origin. + * + * The response CSP is owned by the proxy, not this route: `src/proxy.ts` + * carves out exactly this path with `default-src 'none'; frame-ancestors + * 'self'` (middleware headers win over route headers, so anything set here + * would be dead weight). Deliberately no `sandbox` directive anywhere — + * Chromium force-downloads PDFs framed in sandboxed documents instead of + * rendering them, which would break the inline preview. + * `src/__tests__/proxy-document-serve-framing.test.ts` pins that posture. */ import { NextResponse } from "next/server"; @@ -197,12 +204,11 @@ export const GET = apiHandler( "X-Content-Type-Options": "nosniff", // Private PHI — never cache in a shared / disk cache. "Cache-Control": "private, no-store", + // No Content-Security-Policy here: the proxy's serve-route carve-out + // owns it (`default-src 'none'; frame-ancestors 'self'`, no sandbox — + // Chromium force-downloads sandboxed PDFs). A route-level CSP would be + // silently overwritten by the middleware header pass. }; - if (servingClass === "inline") { - // Defence-in-depth for the in-origin render context (notably the PDF - // viewer): no scripts, no forms, no top-navigation from the response. - headers["Content-Security-Policy"] = "sandbox"; - } return new NextResponse(bytes as unknown as BodyInit, { status: 200,