Files
HealthLog/src/__tests__/proxy-csp-hsts-hardening.test.ts
T
Marc-André Bombeck 76012bf8e2 chore: scrub instance details and personal data from committed artefacts
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
2026-06-10 11:00:26 +02:00

136 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* F-5 (mobile security audit, 2026-05-16): two security headers
* needed to harden against first-visit MITM and to scope the
* Withings backend host to its surface only.
*
* - HSTS now carries `; preload` so the served host is
* eligible for the Chromium HSTS preload list.
* - The `wbsapi.withings.net` `connect-src` entry only ships on
* the Withings surfaces (`/settings/integrations/withings/*`
* and `/api/withings/*`) instead of globally — same gating
* pattern the AI hosts already use.
*
* Both checks lock the proxy in place so a future refactor cannot
* silently regress either one.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
// `NODE_ENV` is a special read-only property on node's `process.env`
// proxy. `vi.stubEnv` knows how to patch it without tripping the
// "only accepts a configurable, writable, and enumerable data
// descriptor" guard that a direct `defineProperty` runs into.
function setNodeEnv(value: "development" | "production") {
vi.stubEnv("NODE_ENV", value);
}
beforeEach(() => {
// Default to production so the production-only headers run; individual
// tests can flip back to development if they care about the dev shape.
setNodeEnv("production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function makeRequest(
pathname: string,
cookies: Record<string, string> = {},
): NextRequest {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts HSTS preload (F-5, 2026-05-16)", () => {
it("attaches `preload` to the Strict-Transport-Security header in production", () => {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.headers.get("strict-transport-security")).toBe(
"max-age=31536000; includeSubDomains; preload",
);
});
it("does not ship HSTS in development", () => {
setNodeEnv("development");
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.headers.get("strict-transport-security")).toBeNull();
});
});
describe("proxy.ts Withings CSP gating (F-5, 2026-05-16)", () => {
it("does NOT allow wbsapi.withings.net on a non-Withings page", () => {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).not.toMatch(/wbsapi\.withings\.net/);
});
it("allows wbsapi.withings.net under /settings/integrations/withings/*", () => {
const res = proxy(
makeRequest("/settings/integrations/withings", {
healthlog_session: "sess-1",
}),
);
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toMatch(/connect-src[^;]*https:\/\/wbsapi\.withings\.net/);
});
it("allows wbsapi.withings.net under /api/withings/*", () => {
const res = proxy(
makeRequest("/api/withings/callback", {
healthlog_session: "sess-1",
}),
);
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toMatch(/connect-src[^;]*https:\/\/wbsapi\.withings\.net/);
});
it("never ships the Withings host on the auth surface (DOM-XSS exfil channel)", () => {
const res = proxy(makeRequest("/auth/login"));
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).not.toMatch(/wbsapi\.withings\.net/);
});
});
describe("proxy.ts WHOOP CSP gating (v1.11.0)", () => {
it("does NOT allow api.prod.whoop.com on a non-WHOOP page", () => {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).not.toMatch(/api\.prod\.whoop\.com/);
});
it("allows api.prod.whoop.com under /settings/integrations/whoop/*", () => {
const res = proxy(
makeRequest("/settings/integrations/whoop", {
healthlog_session: "sess-1",
}),
);
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toMatch(/connect-src[^;]*https:\/\/api\.prod\.whoop\.com/);
});
it("allows api.prod.whoop.com under /api/whoop/*", () => {
const res = proxy(
makeRequest("/api/whoop/callback", { healthlog_session: "sess-1" }),
);
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toMatch(/connect-src[^;]*https:\/\/api\.prod\.whoop\.com/);
});
it("never ships the WHOOP host on the auth surface", () => {
const res = proxy(makeRequest("/auth/login"));
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).not.toMatch(/api\.prod\.whoop\.com/);
});
});