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:
Grovy311 2026-10-15 09:40:33 +02:00
parent 67476ea59b
commit a7a1595cbf
38 changed files with 5852 additions and 442 deletions

View file

@ -111,7 +111,17 @@ describe("distribution-board feeder circuit planning", () => {
sectionId: "three",
displayName: "",
});
const requests: Array<{ url: string; method: string; body: any }> = [];
type FeederCommandBody = {
expectedRevision: number;
command: {
type: string;
payload: {
circuit: { id: string };
boardUpdate: { payload: { changes: Array<{ field: string; value: unknown }> } };
};
};
};
const requests: Array<{ url: string; method: string; body: FeederCommandBody }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (request, init) => {
requests.push({

View file

@ -7,7 +7,7 @@ import { createExternalXlsxInitialImportPlan } from "../src/external-model/appli
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
@ -79,4 +79,3 @@ describe("Linear XLSX follow-up import", () => {
assert.equal(rejected.rowTransitions.length, 0);
});
});

View file

@ -27,7 +27,7 @@ export const quotedRevitCsv = [
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Doppelsteckdose";"Arbeitsplatz";"120,5";"2";"${firstIfcGuid}"`,
'"UV_AV_01-2F1: 2";"";"";"";"";"";"";""',
'"";"01/102";"Büro";"Steckdose: Standard";"Arbeitsplatz";"100";"1";""',
`"";"01/103";"Büro \"\"Nord\"\"";"Steckdose: Standard";"Arbeitsplatz";"100";"1";"${secondIfcGuid}"`,
`"";"01/103";"Büro ""Nord""";"Steckdose: Standard";"Arbeitsplatz";"100";"1";"${secondIfcGuid}"`,
].join("\r\n") + "\r\n";
export function utf8Bytes(value: string, withBom = false) {

View file

@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { safeFileName } from "../src/server/controllers/external-return-workbook.controller.js";
// Regression for the regex-free rewrite (ESLint no-control-regex): same output as the
// former /[<>:"/\\|?*\u0000-\u001f]/g replacement.
describe("safeFileName", () => {
it("replaces Windows-forbidden characters", () => {
assert.equal(safeFileName('a<b>c:d"e/f\\g|h?i*j'), "a_b_c_d_e_f_g_h_i_j");
});
it("replaces ASCII control characters but keeps umlauts and spaces", () => {
assert.equal(safeFileName("Büro\u0000 Nord\u001f\t"), "Büro_ Nord_");
assert.equal(safeFileName("x\u007fy"), "x\u007fy");
});
it("trims and falls back to Projekt", () => {
assert.equal(safeFileName(" Haus A "), "Haus A");
assert.equal(safeFileName(" "), "Projekt");
});
});

View 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");
});
});

View file

@ -0,0 +1,4 @@
{
"//": "Baseline-Tests nutzen Top-Level-await und laufen deshalb als ES-Module.",
"type": "module"
}

View file

@ -0,0 +1,88 @@
/**
* App-spezifische Sicherheitstests (Leistungsbilanz): die Rollen-Grenze admin/user und die
* Pflicht zum Passwortwechsel – über echte HTTP-Requests gegen createApp().
* Ergänzt baseline.test.ts, das nur Login-Pflicht und Objekt-Zugriff kennt.
*/
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import { after, before, describe, it } from "node:test";
import config, { START_PASSWORD, WRITE_HEADER, signIn } from "./security.config.ts";
type Headers = Record<string, string>;
let server: import("node:http").Server;
let baseUrl = "";
let admin: Headers = {};
let user: Headers = {};
let userId = "";
async function call(method: string, path: string, headers: Headers, body?: unknown) {
return fetch(baseUrl + path, {
method,
headers: { ...headers, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
before(async () => {
const app = await config.createApp();
const { userService } = await import("../../src/server/composition/auth.js");
await userService.createUser({
username: "chefin-admin",
name: "chefin-admin",
role: "admin",
initialPassword: START_PASSWORD,
});
await userService.createUser({
username: "neu-ohne-wechsel",
name: "neu-ohne-wechsel",
role: "user",
initialPassword: START_PASSWORD,
});
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}`;
admin = await signIn(baseUrl, "chefin-admin");
user = await signIn(baseUrl, "planer-a");
const list = (await (await call("GET", "/api/users", admin)).json()) as { id: string; username: string }[];
userId = list.find((entry) => entry.username === "planer-b")!.id;
});
after(() => new Promise<void>((resolve) => server.close(() => resolve())));
describe("Rollen: Benutzerverwaltung nur für admin", () => {
it("normaler Nutzer bekommt 403 auf jede /api/users-Route", async () => {
const cases: [string, string, unknown?][] = [
["GET", "/api/users"],
["POST", "/api/users", { username: "eingeschleust", name: "x", role: "admin", initialPassword: "lang-genug-123" }],
["PATCH", `/api/users/${userId}`, { role: "admin" }],
["POST", `/api/users/${userId}/reset-password`, { newPassword: "lang-genug-123" }],
];
const failures: string[] = [];
for (const [method, path, body] of cases) {
const res = await call(method, path, user, body);
if (res.status !== 403) failures.push(`${method} ${path} als user -> ${res.status}`);
}
assert.deepEqual(failures, [], failures.join("\n"));
});
it("admin erreicht die Benutzerverwaltung (Gegenprobe)", async () => {
const res = await call("GET", "/api/users", admin);
assert.equal(res.status, 200);
});
});
describe("Startpasswort", () => {
it("ohne Passwortwechsel ist außer Konto-Endpunkten alles gesperrt", async () => {
const login = await call("POST", "/api/auth/login", WRITE_HEADER, {
username: "neu-ohne-wechsel",
password: START_PASSWORD,
});
assert.equal(login.status, 200);
const cookie = login.headers.getSetCookie().find((c) => c.startsWith("lb_session="))!.split(";")[0];
const headers = { ...WRITE_HEADER, cookie };
assert.equal((await call("GET", "/api/projects", headers)).status, 403);
assert.equal((await call("POST", "/api/projects", headers, { name: "x" })).status, 403);
assert.equal((await call("GET", "/api/auth/me", headers)).status, 200, "Gegenprobe: /auth/me bleibt erreichbar");
});
});

View file

@ -0,0 +1,82 @@
/**
* Routen-Inventar für Express 5.
*
* Express 5 (Paket `router` v2) speichert an gemounteten Routern den Mount-Pfad nicht
* mehr. Ein Test, der nur `app.router.stack` liest, findet deshalb die Routen unter
* `app.use("/api", apiRouter)` nicht mit vollem Pfad – und läuft grün, ohne zu prüfen.
* Darum wird `Router.prototype.use` VOR dem Import der App instrumentiert und jeder
* Mount-Pfad mitgeschrieben.
*
* Reihenfolge ist Pflicht: `installRouteRecorder()` aufrufen, DANACH die App importieren.
*/
import { createRequire } from "node:module";
import path from "node:path";
type Layer = { route?: { path: string | string[]; methods: Record<string, boolean> }; handle: unknown };
type RouterLike = { stack: Layer[] };
const mountPaths = new WeakMap<object, string[]>();
let RouterProto: Record<string, unknown> | undefined;
function loadRouterPrototype(): Record<string, unknown> {
const fromApp = createRequire(path.join(process.cwd(), "package.json"));
const expressEntry = fromApp.resolve("express");
// Dasselbe `router`-Modul, das Express selbst lädt (gleiche Datei = gleicher Cache-Eintrag).
const Router = createRequire(expressEntry)("router");
return Router.prototype;
}
export function installRouteRecorder(): void {
if (RouterProto) return;
RouterProto = loadRouterPrototype();
const originalUse = RouterProto.use as (...args: unknown[]) => unknown;
RouterProto.use = function patchedUse(this: RouterLike, ...args: unknown[]) {
const mount = typeof args[0] === "string" ? (args[0] as string) : Array.isArray(args[0]) ? null : "/";
const before = this.stack.length;
const result = originalUse.apply(this, args);
for (const layer of this.stack.slice(before)) {
const existing = mountPaths.get(layer) ?? [];
// Arrays/RegExp als Mount-Pfad werden als "?" markiert und fallen im Test auf.
mountPaths.set(layer, [...existing, mount ?? "?"]);
}
return result;
};
}
function isRouter(handle: unknown): handle is RouterLike {
return typeof handle === "function" && Array.isArray((handle as Partial<RouterLike>).stack);
}
function join(prefix: string, suffix: string): string {
const joined = `${prefix}/${suffix}`.replace(/\/+/g, "/");
return joined.length > 1 ? joined.replace(/\/$/, "") : joined;
}
export type Route = { method: string; path: string };
/** Alle Routen der App mit vollem Pfad, z. B. { method: "GET", path: "/api/projects/:id" }. */
export function collectRoutes(app: { router: RouterLike }): Route[] {
if (!RouterProto) throw new Error("installRouteRecorder() wurde nicht vor dem App-Import aufgerufen");
const routes: Route[] = [];
const walk = (router: RouterLike, prefix: string) => {
for (const layer of router.stack) {
if (layer.route) {
const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
for (const routePath of paths) {
for (const [method, enabled] of Object.entries(layer.route.methods)) {
if (enabled && method !== "_all") routes.push({ method: method.toUpperCase(), path: join(prefix, routePath) });
}
}
} else if (isRouter(layer.handle)) {
for (const mount of mountPaths.get(layer) ?? ["?"]) walk(layer.handle, join(prefix, mount));
}
}
};
walk(app.router, "/");
return routes.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`));
}
/** Ersetzt :param und *wildcard durch einen Beispielwert. */
export function fillPath(routePath: string, value = "1"): string {
return routePath.replace(/:[A-Za-z0-9_]+/g, value).replace(/\*[A-Za-z0-9_]*/g, value);
}

View file

@ -0,0 +1,130 @@
/**
* App-spezifische Konfiguration für baseline.test.ts (Leistungsbilanz, Express 5).
*
* Identität: Session-Cookie `lb_session` nach POST /api/auth/login. Jeder Schreibzugriff
* braucht zusätzlich den Header `X-Leistungsbilanz-Request: 1` (CSRF-Schutz).
* Die App wird nur DYNAMISCH in createApp() importiert, damit das Routen-Inventar greift.
* Testdaten sind synthetisch; die Datenbank liegt nur im Speicher dieses Testprozesses.
*/
import path from "node:path";
import type { Express } from "express";
type Headers = Record<string, string>;
type Case = { method: string; path: string; body?: unknown };
export const WRITE_HEADER = { "x-leistungsbilanz-request": "1" };
// Synthetische Testkonten, nur in der In-Memory-Datenbank des Testlaufs.
export const START_PASSWORD = "start-passwort-nur-test";
const TEST_PASSWORD = "baseline-passwort-nur-test";
const TEST_USERS = ["planer-a", "planer-b"] as const;
function sessionCookie(res: Response): string {
// Das letzte Cookie zählt: der Auth-Guard verlängert zuerst die alte Session, danach setzt
// z. B. der Passwortwechsel eine neue.
const cookie = res.headers.getSetCookie().filter((c) => c.startsWith("lb_session=")).at(-1);
if (!cookie) throw new Error(`Kein Session-Cookie (${res.status})`);
return cookie.split(";")[0];
}
/** Loggt einen Testnutzer ein, setzt das Startpasswort neu und liefert die Request-Header. */
export async function signIn(baseUrl: string, username: string): Promise<Headers> {
const post = (url: string, body: unknown, cookie?: string) =>
fetch(baseUrl + url, {
method: "POST",
headers: { ...WRITE_HEADER, "content-type": "application/json", ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body),
});
const login = await post("/api/auth/login", { username, password: START_PASSWORD });
if (!login.ok) throw new Error(`Login ${username}: ${login.status} ${await login.text()}`);
const changed = await post(
"/api/auth/change-password",
{ currentPassword: START_PASSWORD, newPassword: TEST_PASSWORD },
sessionCookie(login),
);
if (!changed.ok) throw new Error(`Passwortwechsel ${username}: ${changed.status} ${await changed.text()}`);
return { ...WRITE_HEADER, cookie: sessionCookie(changed) };
}
const config = {
async createApp(): Promise<Express> {
process.env.LB_DATABASE_PATH = ":memory:";
process.env.LOG_LEVEL ??= "error";
delete process.env.AUTH_BOOTSTRAP_USERNAME;
delete process.env.AUTH_BOOTSTRAP_PASSWORT;
const { migrate } = await import("drizzle-orm/better-sqlite3/migrator");
const { db } = await import("../../src/db/client.js");
migrate(db, { migrationsFolder: path.resolve("src", "db", "migrations") });
const { userService } = await import("../../src/server/composition/auth.js");
for (const username of TEST_USERS) {
await userService.createUser({ username, name: username, role: "user", initialPassword: START_PASSWORD });
}
const { createApp } = await import("../../src/server/app.js");
return createApp();
},
// Ohne Login erreichbar: Healthcheck und die offenen Pfade von createAuthGuard.
publicRoutes: [
"GET /health",
"GET /api/auth/status",
"POST /api/auth/login",
"POST /api/auth/logout",
] as string[],
// Untergrenze geschützter Routen (Leerlauf-Sperre), Stand beim Einbau.
minProtectedRoutes: 86,
// Zwei Nutzer derselben Rolle ("user").
ownerHeaders: (baseUrl: string): Promise<Headers> => signIn(baseUrl, TEST_USERS[0]),
otherHeaders: (baseUrl: string): Promise<Headers> => signIn(baseUrl, TEST_USERS[1]),
// Gefälschte Identität: erfundenes Session-Cookie und Proxy-Header, aber mit Schreib-Header.
spoofedHeaders: {
...WRITE_HEADER,
cookie: "lb_session=gefaelschte-session",
"x-remote-user": "admin",
} as Headers,
async createOwnedObject(baseUrl: string, headers: Headers): Promise<Record<string, unknown>> {
const res = await fetch(`${baseUrl}/api/projects`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ name: "Testprojekt" }),
});
if (res.status !== 201) throw new Error(`createOwnedObject: ${res.status} ${await res.text()}`);
return (await res.json()) as Record<string, unknown>;
},
// OFFENE LÜCKE (bewusst, Entscheidung Julian): Projekte haben keinen Besitzer, jeder
// angemeldete Nutzer sieht und bearbeitet alle Projekte. Die IDOR-Tests laufen trotzdem
// und zeigen den Ist-Zustand, zählen aber als TODO statt als Fehler. Wird Besitz bzw.
// Freigabe pro Projekt eingeführt, diese Zeile entfernen – dann müssen sie grün werden.
idorGap: "Projekte sind für alle angemeldeten Nutzer freigegeben (kein Besitz pro Projekt)",
idorCases: [
{ method: "GET", path: "/api/projects/{id}" },
{ method: "GET", path: "/api/projects/{id}/export" },
{ method: "GET", path: "/api/projects/{id}/history" },
] as Case[],
listCases: [{ path: "/api/projects", key: "id" }],
// Felder, die der Client nicht setzen darf.
massAssignmentCases: [
{ method: "POST", path: "/api/projects", body: { name: "x", id: "fremde-id" } },
{ method: "POST", path: "/api/projects", body: { name: "x", revision: 99 } },
{ method: "POST", path: "/api/projects", body: { name: "x", role: "admin" } },
] as Case[],
invalidInputCases: [
{ method: "POST", path: "/api/projects", body: { name: 123 } },
{ method: "POST", path: "/api/projects", body: {} },
{ method: "POST", path: "/api/projects", body: "{kaputt" },
{ method: "PUT", path: "/api/projects/{id}", body: {} },
{ method: "PUT", path: "/api/projects/{id}", body: "{kaputt" },
] as Case[],
// Keine Cross-Origin-Freigabe: Browser erreichen die API nur same-origin über den
// Next.js-Rewrite. Deshalb gibt es keine erlaubte Origin für die Gegenprobe.
allowedOrigin: undefined as string | undefined,
forbiddenOrigin: "https://evil.example.test",
expectSecurityHeaders: true,
};
export default config;