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
130
tests/security/security.config.ts
Normal file
130
tests/security/security.config.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue