0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
75 lines
4.3 KiB
TypeScript
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);
|
|
});
|