From e9b8995486ef2b295b416ae19c54d6bbe43f6017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 13:12:47 +0200 Subject: [PATCH] fix(documents): drop the dead route-level CSP on the serve path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy's serve-route carve-out owns the response CSP (default-src 'none'; frame-ancestors 'self'; no sandbox — Chromium force-downloads sandboxed PDFs), and middleware headers overwrite route headers, so the route-level sandbox value never reached a client. Remove it and point both the route and its header-matrix test at the proxy pin. Refs: document-vault plan W1-T10 posture reconciliation --- .../[id]/original/__tests__/route.test.ts | 11 +++++++--- .../documents/inbound/[id]/original/route.ts | 20 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) 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,