mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(proxy,auth,insights): close three drift gaps the audits left open
The proxy's seven early exits — worker-only 503, two legacy 301s, the demo-mode 403, and the login / onboarding / MFA redirects — returned before the security header block, so they carried no HSTS, no framing refusal and no nosniff. Factor the transport subset into a helper and apply it on every exit. The per-route CSP stays where it is; it is nonce-bound and only meaningful for a response with a body. The WebAuthn relying-party candidate list always carried http://localhost:3000, including in production. The signed rpIdHash still pinned the real domain, so this was never a reachable bypass, but a configured production origin has no reason to carry it. Gate the fallback on development, keeping it when nothing is configured so getRpId() still resolves. ORPHAN_ENUMS was a hand-maintained literal that drifted every time ALLOWED_CHART_TOKENS grew — eighteen allowlisted metrics had no strip entry, so a model writing 'Your PHQ9_SCORE is elevated' leaked the raw enum into rendered prose. Derive it from the allowlist and pin the coupling with a test.
This commit is contained in:
1 parent
f5889affb7
commit
8ead442d44
6 files changed
+365
-93
No files matched your search
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Security headers on the proxy's EARLY EXITS.
|
||||
*
|
||||
* The full header block sits at the bottom of `proxy()`, after the pass-through
|
||||
* `NextResponse.next()`. Seven exits return before it — the worker-only 503,
|
||||
* two legacy 301s, the demo-mode 403, and the login / onboarding / MFA
|
||||
* redirects — and used to carry no HSTS, no framing refusal and no nosniff at
|
||||
* all.
|
||||
*
|
||||
* The exposure was one hop (a redirect has no rendered body, and the follow-up
|
||||
* navigation re-enters the proxy and does get the full set), but HSTS is worth
|
||||
* the most on exactly that first hop over a hostile network. These assertions
|
||||
* exist so a future exit added above the header block cannot silently rejoin
|
||||
* the unprotected set.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const shouldRunWeb = vi.fn(() => true);
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => shouldRunWeb(),
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
init: { cookies?: Record<string, string>; method?: string } = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(init.cookies ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
method: init.method ?? "GET",
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
|
||||
const ORIGINAL_DEMO = process.env.DEMO_MODE;
|
||||
|
||||
beforeEach(() => {
|
||||
shouldRunWeb.mockReturnValue(true);
|
||||
process.env.NODE_ENV = "production";
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
|
||||
if (ORIGINAL_DEMO === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = ORIGINAL_DEMO;
|
||||
});
|
||||
|
||||
/** The transport subset every exit must carry. */
|
||||
function expectBaselineHeaders(res: Response) {
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe(
|
||||
"strict-origin-when-cross-origin",
|
||||
);
|
||||
expect(res.headers.get("Permissions-Policy")).toContain("camera=()");
|
||||
expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin");
|
||||
expect(res.headers.get("X-Permitted-Cross-Domain-Policies")).toBe("none");
|
||||
expect(res.headers.get("Strict-Transport-Security")).toContain(
|
||||
"max-age=31536000",
|
||||
);
|
||||
}
|
||||
|
||||
describe("proxy early exits carry the baseline security headers", () => {
|
||||
it("worker-only 503", () => {
|
||||
shouldRunWeb.mockReturnValue(false);
|
||||
const res = proxy(makeRequest("/dashboard"));
|
||||
expect(res.status).toBe(503);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("legacy 301 redirect", () => {
|
||||
const res = proxy(makeRequest("/stimmung"));
|
||||
expect(res.status).toBe(301);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("unauthenticated page redirect to /auth/login", () => {
|
||||
const res = proxy(makeRequest("/dashboard"));
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("onboarding redirect", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/", {
|
||||
cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" },
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toMatch(/\/onboarding$/);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("demo-mode 403 on a blocked mutation", () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
const res = proxy(
|
||||
makeRequest("/api/measurements", {
|
||||
method: "POST",
|
||||
cookies: { healthlog_session: "sess-1" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expectBaselineHeaders(res);
|
||||
});
|
||||
|
||||
it("omits HSTS in development, like the main header block", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
const res = proxy(makeRequest("/stimmung"));
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.get("Strict-Transport-Security")).toBeNull();
|
||||
// The non-transport-dependent headers still attach.
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user