Files
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

75 lines
4.3 KiB
TypeScript

import { expect, test } from "@playwright/test";
import http from "node:http";
import { spawnSync } from "node:child_process";
import path from "node:path";
const origin = "https://jobs.example.test";
const url = "http://127.0.0.1:5302";
const host = { Host: "jobs.example.test" };
test("unsafe production CORS overrides fail before the API starts listening", () => {
for (const value of ["http://localhost:3000", "*"]) {
const result = spawnSync(process.env.DOTNET_HOST_PATH || "dotnet", [
path.resolve("../JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll"),
"--App:PublicBaseUrl=https://jobs.example.test", `--Cors:Origins:0=${value}`,
], { env: { ...process.env, ASPNETCORE_ENVIRONMENT: "Production" }, encoding: "utf8", timeout: 10_000 });
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Cors:Origins");
expect(result.stdout).not.toContain("Now listening");
}
});
test("production CORS defaults to the canonical origin, never localhost or arbitrary origins", async ({ request }) => {
for (const value of [origin, "http://localhost:3000", "https://attacker.example.test"]) {
const response = await request.get(`${url}/api/auth/config`, { headers: { ...host, Origin: value } });
expect(response.status()).toBe(200);
expect(response.headers()["access-control-allow-origin"]).toBe(value === origin ? origin : undefined);
expect(response.headers()["access-control-allow-credentials"]).toBe(value === origin ? "true" : undefined);
}
});
test("forwarded host cannot replace Host and internal liveness does not expose other routes", async ({ request }) => {
const unknown = await request.get(`${url}/api/auth/config`, { headers: { Host: "attacker.example.test", "X-Forwarded-Host": "jobs.example.test" } });
expect(unknown.status()).toBe(400);
const canonical = await request.get(`${url}/api/auth/config`, { headers: { ...host, "X-Forwarded-Host": "attacker.example.test", Forwarded: "host=attacker.example.test;proto=https" } });
expect(canonical.status()).toBe(200);
expect((await request.get(`${url}/health`)).status()).toBe(200);
expect((await request.get(`${url}/api/auth/config`)).status()).toBe(400);
});
test("forged forwarding scheme cannot downgrade canonical HTTPS cookie security", async ({ request }) => {
for (const scheme of ["http", "https", "invalid"]) {
const response = await request.get(`${url}/api/auth/csrf`, { headers: { ...host, "X-Forwarded-Proto": scheme, "X-Forwarded-For": "198.51.100.60" } });
expect(response.status()).toBe(204);
expect(response.headers()["set-cookie"]).toMatch(/; secure/i);
}
});
test("untrusted direct source cannot reset its IP allowance using IP, scheme or host forwarding", async () => {
const attempt = (source: string, claim: string) => new Promise<number>((resolve, reject) => {
const call = http.request(`${url}/api/auth/login`, {
method: "POST", localAddress: source,
headers: { ...host, "Content-Type": "application/json", "X-Forwarded-For": claim,
"X-Forwarded-Proto": "https", "X-Forwarded-Host": "attacker.example.test", Forwarded: `for=${claim};proto=https` },
}, response => { response.resume(); response.on("end", () => resolve(response.statusCode!)); });
call.on("error", reject);
call.setTimeout(5000, () => call.destroy(new Error("Local ingress probe timed out")));
call.end(JSON.stringify({ email: "", password: "" }));
});
for (let i = 0; i < 10; i++) expect(await attempt("127.0.0.2", `198.51.100.${i + 1}`)).toBe(400);
expect(await attempt("127.0.0.2", "203.0.113.1")).toBe(429);
expect(await attempt("127.0.0.3", "203.0.113.1")).toBe(400);
});
test("trusted proxy gives two real clients independent IP allowances and ignores leftmost extra hops", async ({ request }) => {
const attempt = (ip: string) => request.post(`${url}/api/auth/login`, {
headers: { ...host, "X-Forwarded-For": ip, "X-Forwarded-Proto": "https" }, data: { email: "", password: "" },
});
for (let i = 0; i < 10; i++) expect((await attempt("198.51.100.50")).status()).toBe(400);
expect((await attempt("198.51.100.50")).status()).toBe(429);
expect((await attempt("198.51.100.51")).status()).toBe(400);
// Nginx rejects chains; this separately verifies the API's one-hop defence if that contract breaks.
expect((await attempt("203.0.113.99, 198.51.100.50")).status()).toBe(429);
});