leistungsbilanz-ts/tests/security/baseline.test.ts
Grovy311 02637c1f32 chore(security): Baseline-Update 2026-10-27 (mutation_check, Testvorlage)
Aus cj/security-baseline a04c20b übernommen:
- scripts/security/mutation_check.py: Syntax-/Importfehler aus einer Mutation zählen nicht
  mehr als getötet ([UNGÜLTIG]); getötet nur bei echtem Testfehler (pytest, unittest,
  node:test spec und TAP), TODO-/SKIP-Tests sind kein Beleg.
- Sicherheitstest-Vorlage: ungültige Eingaben müssen konfiguriert sein (Leerlauf-Sperre).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:04:48 +01:00

195 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string>;
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
let app: Awaited<ReturnType<typeof config.createApp>>;
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<string, unknown>) =>
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<void>((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<string, unknown>[]).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");
});
});