mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded documentIds set; validate each id as the caller's own live document and create the membership rows in the same transaction (frozen write-once). Summary DTOs gain documentCount (never ids, never bytes). Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved (never requireAuth), passphrase-gated via the token-scoped unlock cookie, revocation/expiry re-checked before any decrypt, confined to the link's frozen document set (foreign/deleted id → flat 404), fail-closed decrypt, identical serving-class posture as the owner route (inline Class A / attachment Class B, nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate limit. Carve the serve path out of the proxy's blanket DENY posture with the same document CSP as the owner route (default-src 'none'; frame-ancestors 'self'), anchored to exactly two dynamic segments so no lookalike over-matches.
128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
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'",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("proxy.ts share document serve-route framing carve-out", () => {
|
|
const shareToken = "hls_000000000000000000000000000000000000000000000000";
|
|
|
|
it("allows same-origin framing on the /c/<token>/d/<id> serve path", () => {
|
|
const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`));
|
|
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 (Chromium downloads sandboxed PDFs) and no script sources.
|
|
expect(csp).not.toMatch(/sandbox/);
|
|
expect(csp).not.toMatch(/script-src/);
|
|
});
|
|
|
|
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
|
|
const res = proxy(makeRequest(`/c/${shareToken}`));
|
|
// The clinician view is a PAGE — it must not be framable and keeps the
|
|
// page CSP; only the blob subresource under it takes the serve posture.
|
|
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
|
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
|
"frame-ancestors 'none'",
|
|
);
|
|
});
|
|
|
|
it("does not match nested, suffixed, or lookalike share serve paths", () => {
|
|
for (const path of [
|
|
`/c/${shareToken}/d/doc123/extra`, // extra trailing segment
|
|
`/c/${shareToken}/d/doc123x/more/deep`, // deep suffix
|
|
`/c/${shareToken}/dx/doc123`, // wrong segment name
|
|
`/c/${shareToken}/d`, // missing id
|
|
`/c/${shareToken}/d/`, // empty id
|
|
"/c//d/doc123", // empty token (traversal-ish)
|
|
]) {
|
|
const res = proxy(makeRequest(path));
|
|
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
|
|
}
|
|
});
|
|
});
|