mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(proxy): allow same-origin framing of the document serve route
The blanket X-Frame-Options: DENY + frame-ancestors 'none' refused the detail preview's same-origin <iframe>, and the middleware page CSP clobbered whatever the serve route set — the inline PDF preview rendered as a permanent skeleton. Carve out exactly the /original path: SAMEORIGIN framing, frame-ancestors 'self', and a document CSP of default-src 'none'. Deliberately NO sandbox directive and no sandbox attribute on the preview iframe — Chromium force-downloads PDFs in sandboxed documents instead of rendering them (verified against a real Chromium channel; headless test builds ship no PDF viewer at all). The load-bearing boundaries for user-uploaded bytes are unchanged: magic-byte classification at upload, true Content-Type + nosniff at serve, HTML/SVG denied entirely, attachment-only posture for every non-passive format. Tests pin the carve-out and that every neighbouring path keeps the DENY posture. Plan-Task: W2-T3 (preview integration)
This commit is contained in:
1 parent
2b7a6544d6
commit
f9ca7fef79
3 files changed
+127
-5
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* The document vault's decrypt-and-serve route is framed same-origin by the
|
||||
* detail preview (`<iframe>` for inline-class PDFs). The proxy's blanket
|
||||
* `X-Frame-Options: DENY` + `frame-ancestors 'none'` would refuse that
|
||||
* embed, and the blanket page CSP overrides whatever the route sets
|
||||
* (middleware headers win) — so the proxy carves out EXACTLY the
|
||||
* `/original` path with a same-origin framing posture and a document CSP
|
||||
* (`default-src 'none'`; deliberately no `sandbox` — Chromium
|
||||
* force-downloads sandboxed PDFs instead of rendering them).
|
||||
*
|
||||
* These tests pin both sides of that carve-out: the serve path gets the
|
||||
* narrow posture, and every neighbouring documents API path keeps the full
|
||||
* DENY posture.
|
||||
*/
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts document serve-route framing carve-out", () => {
|
||||
it("allows same-origin framing on the /original serve path", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound/doc123/original"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toContain("frame-ancestors 'self'");
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
// No `sandbox` directive: Chromium downloads sandboxed PDFs instead of
|
||||
// rendering them, which would break the inline preview outright.
|
||||
expect(csp).not.toMatch(/sandbox/);
|
||||
// No script sources of any kind for a served document.
|
||||
expect(csp).not.toMatch(/script-src/);
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on the documents list route", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on the document detail route", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound/doc123"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("does not match nested or suffixed lookalike paths", () => {
|
||||
for (const path of [
|
||||
"/api/documents/inbound/doc123/original/extra",
|
||||
"/api/documents/inbound/doc123/originalx",
|
||||
"/api/documents/inbound//original",
|
||||
]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on app pages, including /documents itself", () => {
|
||||
const res = proxy(makeRequest("/documents"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -87,10 +87,15 @@ function InlinePreview({
|
||||
/>
|
||||
) : null}
|
||||
{isPdf ? (
|
||||
// No `sandbox` attribute by design: Chromium force-downloads PDFs
|
||||
// in sandboxed frames instead of rendering them. The serve
|
||||
// response itself carries `default-src 'none'` +
|
||||
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
|
||||
// upload policy denies every format whose inline render could
|
||||
// execute script.
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
sandbox=""
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={cn(
|
||||
"border-border h-[55vh] w-full rounded-lg border",
|
||||
|
||||
+33
-4
@@ -297,9 +297,24 @@ export function proxy(request: NextRequest) {
|
||||
// Request ID for log correlation
|
||||
response.headers.set("x-request-id", requestId);
|
||||
|
||||
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
|
||||
// the document detail preview (an <iframe> for inline-class PDFs). The
|
||||
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
|
||||
// refuse that embed, and the blanket page CSP would clobber the route's
|
||||
// own `Content-Security-Policy: sandbox` (middleware headers win over
|
||||
// route headers). Scope: exactly the `/original` path — list/detail API
|
||||
// routes keep the full DENY posture. The replacement CSP keeps the
|
||||
// sandbox (the served bytes are user-uploaded and must never script in
|
||||
// the app origin) and narrows framing to 'self' instead of dropping it.
|
||||
const isDocumentServeRoute =
|
||||
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
||||
|
||||
// Security headers
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set(
|
||||
"X-Frame-Options",
|
||||
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
|
||||
);
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
@@ -386,9 +401,23 @@ export function proxy(request: NextRequest) {
|
||||
// page-load. Avatars now live on the User row and serve from
|
||||
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
|
||||
// them.
|
||||
const csp = 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}; 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;`;
|
||||
const csp = isDocumentServeRoute
|
||||
? // Vault serve route (see the X-Frame-Options note above): the
|
||||
// response is a user-uploaded document, not an app page.
|
||||
// `default-src 'none'` — a served document may load nothing as if
|
||||
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
||||
// same-origin preview iframe. Deliberately NO `sandbox` directive:
|
||||
// Chromium refuses to render PDFs in sandboxed documents (it
|
||||
// force-downloads them), which would kill the inline preview
|
||||
// outright. The load-bearing boundaries for user-uploaded bytes
|
||||
// stay: magic-byte classification at upload, true Content-Type +
|
||||
// `nosniff` at serve (a PDF can never be reinterpreted as HTML),
|
||||
// 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}; 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
|
||||
|
||||
Reference in new issue
Block a user