/** * Sicherheits-Grundtests der Baseline (Express 5, node:test). * * Diese Datei NICHT pro App anpassen. Alles App-Spezifische steht in security.config.ts. * Regel: Wird ein Test hier rot, wird der App-Code repariert, nie der Test gelockert. * * Läuft mit `node --import tsx --test tests/security/baseline.test.ts`. */ import assert from "node:assert/strict"; import type { AddressInfo } from "node:net"; import { after, before, describe, it } from "node:test"; import { collectRoutes, fillPath, installRouteRecorder, type Route } from "./route-inventory.ts"; installRouteRecorder(); // Erst NACH installRouteRecorder() importieren, sonst fehlen die Mount-Pfade. const { default: config } = await import("./security.config.ts"); type Headers = Record; const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]); let app: Awaited>; let server: import("node:http").Server; let baseUrl = ""; let owner: Headers = {}; let other: Headers = {}; const key = (r: Route) => `${r.method} ${r.path}`; const protectedRoutes = () => collectRoutes(app).filter((r) => !config.publicRoutes.includes(key(r))); const fill = (template: string, obj: Record) => template.replace(/\{(\w+)\}/g, (_, name: string) => String(obj[name])); async function call(method: string, path: string, headers: Headers = {}, body?: unknown) { const init: RequestInit = { method, headers: { ...headers }, redirect: "manual" }; if (typeof body === "string") { init.body = body; (init.headers as Headers)["content-type"] = "application/json"; } else if (body !== undefined || BODY_METHODS.has(method)) { init.body = JSON.stringify(body ?? {}); (init.headers as Headers)["content-type"] = "application/json"; } return fetch(baseUrl + path, init); } before(async () => { app = await config.createApp(); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; owner = await config.ownerHeaders(baseUrl); other = await config.otherHeaders(baseUrl); }); after(() => new Promise((resolve) => server.close(() => resolve()))); describe("Authentifizierung", () => { it("Leerlauf-Sperre: genug geschützte Routen erkannt", () => { const found = protectedRoutes(); assert.ok( found.length >= config.minProtectedRoutes, `Nur ${found.length} geschützte Routen erkannt, erwartet >= ${config.minProtectedRoutes}: ${found.map(key).join(", ")}`, ); assert.ok(!found.some((r) => r.path.includes("?")), "Mount-Pfad nicht auflösbar (Array/RegExp in app.use)"); }); it("Public-Liste enthält keine Leichen", () => { const existing = new Set(collectRoutes(app).map(key)); const stale = config.publicRoutes.filter((r) => !existing.has(r)); assert.deepEqual(stale, [], `Public-Routen existieren nicht mehr: ${stale.join(", ")}`); }); it("jede Route verlangt Login", async () => { const failures: string[] = []; for (const route of protectedRoutes()) { const res = await call(route.method, fillPath(route.path)); if (![401, 403].includes(res.status)) failures.push(`${key(route)} -> ${res.status}`); } assert.deepEqual(failures, [], `Ohne Login erreichbar:\n${failures.join("\n")}`); }); it("gefälschte Identität wird abgelehnt", async () => { const failures: string[] = []; for (const route of protectedRoutes()) { const res = await call(route.method, fillPath(route.path), config.spoofedHeaders); if (![401, 403].includes(res.status)) failures.push(`${key(route)} -> ${res.status}`); } assert.deepEqual(failures, [], `Gefälschte Identität akzeptiert:\n${failures.join("\n")}`); }); }); // idorGap: bewusst offene, dokumentierte Lücke. Die Tests laufen trotzdem und zeigen den // Ist-Zustand, werden aber als TODO gemeldet statt den Lauf rot zu machen. const idorGap = (config as { idorGap?: string }).idorGap; const idorOptions = idorGap ? { todo: `OFFENE LÜCKE: ${idorGap}` } : {}; describe("Autorisierung (IDOR)", () => { it("fremde Objekte sind nicht erreichbar, eigene schon", idorOptions, async () => { assert.ok(config.idorCases.length > 0, "idorCases konfigurieren"); const failures: string[] = []; for (const c of config.idorCases) { const obj = await config.createOwnedObject(baseUrl, owner); const path = fill(c.path, obj); const foreign = await call(c.method, path, other, c.body); if (![403, 404].includes(foreign.status)) failures.push(`${c.method} ${path} als Fremder -> ${foreign.status}`); // Gegenprobe: sonst wäre ein Endpunkt, der immer 404 liefert, "sicher". const own = await call(c.method, path, owner, c.body); if (own.status < 200 || own.status >= 300) failures.push(`${c.method} ${path} als Besitzer -> ${own.status} (Gegenprobe)`); } assert.deepEqual(failures, [], failures.join("\n")); }); it("Listen enthalten keine fremden Objekte", idorOptions, async () => { assert.ok(config.listCases.length > 0, "listCases konfigurieren"); const obj = await config.createOwnedObject(baseUrl, owner); for (const { path, key: field } of config.listCases) { const ids = async (h: Headers) => ((await (await call("GET", path, h)).json()) as Record[]).map((i) => i[field]); assert.ok((await ids(owner)).includes(obj[field]), `${path}: Besitzer sieht eigenes Objekt nicht (Gegenprobe)`); assert.ok(!(await ids(other)).includes(obj[field]), `${path}: fremdes Objekt in der Liste`); } }); }); describe("Eingaben", () => { it("unbekannte/geschützte Felder werden abgelehnt (Mass Assignment)", async () => { assert.ok(config.massAssignmentCases.length > 0, "massAssignmentCases konfigurieren"); const obj = await config.createOwnedObject(baseUrl, owner); const failures: string[] = []; for (const c of config.massAssignmentCases) { const res = await call(c.method, fill(c.path, obj), owner, c.body); if (![400, 422].includes(res.status)) failures.push(`${c.method} ${c.path} ${JSON.stringify(c.body)} -> ${res.status}`); } assert.deepEqual(failures, [], failures.join("\n")); }); it("ungültige Eingaben führen nie zu 5xx", async () => { assert.ok(config.invalidInputCases.length > 0, "invalidInputCases konfigurieren"); const obj = await config.createOwnedObject(baseUrl, owner); const failures: string[] = []; for (const c of config.invalidInputCases) { const res = await call(c.method, fill(c.path, obj), owner, c.body); if (res.status < 400 || res.status >= 500) failures.push(`${c.method} ${c.path} ${JSON.stringify(c.body)} -> ${res.status}`); } assert.deepEqual(failures, [], failures.join("\n")); }); }); describe("Konfiguration", () => { it("CORS lässt fremde Origins nicht zu", async () => { const route = protectedRoutes()[0]; for (const origin of [config.forbiddenOrigin, "null"]) { const res = await fetch(baseUrl + fillPath(route.path), { method: "OPTIONS", headers: { origin, "access-control-request-method": route.method }, }); const allowed = res.headers.get("access-control-allow-origin"); assert.ok(allowed !== origin && allowed !== "*", `CORS erlaubt ${origin}: ${allowed}`); } }); it("CORS erlaubt die konfigurierte Origin (Gegenprobe)", async (t) => { if (!config.allowedOrigin) return t.skip("keine erlaubte Origin konfiguriert"); const route = protectedRoutes()[0]; const res = await fetch(baseUrl + fillPath(route.path), { method: "OPTIONS", headers: { origin: config.allowedOrigin, "access-control-request-method": route.method }, }); assert.equal(res.headers.get("access-control-allow-origin"), config.allowedOrigin); }); it("Fehlerantworten verraten keine Interna", async () => { const leakMarker = "geheimer-interner-zustand-4711"; app.get("/__baseline_boom__", () => { throw new Error(leakMarker); }); // Route an den Anfang schieben, damit die Fehlerbehandlung der App dahinter greift. const stack = (app as unknown as { router: { stack: unknown[] } }).router.stack; stack.unshift(stack.pop()); try { const res = await fetch(`${baseUrl}/__baseline_boom__`); const text = await res.text(); assert.equal(res.status, 500); for (const marker of [leakMarker, "Error:", " at ", "node_modules"]) { assert.ok(!text.includes(marker), `Fehlerantwort verrät ${JSON.stringify(marker)}`); } } finally { stack.shift(); } }); it("Security-Header gesetzt", async (t) => { if (!config.expectSecurityHeaders) return t.skip("expectSecurityHeaders=false – UNGEPRÜFT"); const res = await fetch(baseUrl + (config.publicRoutes[0]?.split(" ")[1] ?? "/")); assert.equal(res.headers.get("x-content-type-options"), "nosniff"); assert.equal(res.headers.get("x-powered-by"), null, "X-Powered-By verrät Express"); }); });