Files
HealthLog/src/__tests__/proxy-early-exit-headers.test.ts
T
Marc-André Bombeck 7d05f68f23 fix(http): say when proxy headers go unread, and trust X-Real-IP only at one hop
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.

X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.

The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.

The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
2026-09-26 22:45:51 +02:00

144 lines
4.8 KiB
TypeScript

/**
* 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";
import { applyBypassRouteHeaders } from "@/lib/http/proxy-bypass-routes";
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_DEMO = process.env.DEMO_MODE;
beforeEach(() => {
shouldRunWeb.mockReturnValue(true);
vi.stubEnv("NODE_ENV", "production");
delete process.env.DEMO_MODE;
});
afterEach(() => {
vi.unstubAllEnvs();
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("Cross-Origin-Resource-Policy")).toBe("same-origin");
expect(res.headers.get("Cross-Origin-Embedder-Policy")).toBe(
"credentialless",
);
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", () => {
vi.stubEnv("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");
});
});
describe("the routes that bypass the proxy", () => {
it("carry the same cross-origin headers the proxy sets on a full response", () => {
const viaProxy = proxy(makeRequest("/api/health"));
const headers = new Headers();
applyBypassRouteHeaders(headers);
for (const name of [
"Cross-Origin-Opener-Policy",
"Cross-Origin-Resource-Policy",
"Cross-Origin-Embedder-Policy",
"X-Content-Type-Options",
"X-Frame-Options",
"X-Permitted-Cross-Domain-Policies",
"Strict-Transport-Security",
]) {
expect(headers.get(name), name).toBe(viaProxy.headers.get(name));
}
});
});