fix(documents): drop the dead route-level CSP on the serve path

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
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:12:47 +02:00
1 parent f9ca7fef79
commit e9b8995486
2 files changed
+21 -10

No files matched your search

@@ -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");
},
@@ -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,