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((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); });