Add test and security baseline
- make quick/check/mutation from cj/security-baseline (Makefile unchanged, settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint security rules, Forgejo workflow - tests/security: generic baseline tests plus app config and role tests against createApp() with an in-memory database - extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests - fix gaps found by the tests: reject unknown fields on project create, answer malformed JSON with 400 instead of 500, set nosniff on the API - npm audit fix (next 16.4.1 and others, no high/critical left) - clear ESLint errors, safeFileName without control-character regex - per-project ownership stays an open, documented gap (idorGap -> TODO) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
67476ea59b
commit
a7a1595cbf
38 changed files with 5852 additions and 442 deletions
194
tests/security/baseline.test.ts
Normal file
194
tests/security/baseline.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* 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 () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue