L10a Qualität & Abnahmetests: Sicherheitstests (Rollen-Matrix, schädliche Uploads, Login-Sperre/Rate-Limit/Sessions) und Audit-Log append-only für craftvia_app

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:25:55 +02:00
co-authored by Claude Opus 5
parent 4507862375
commit c297cfbe83
5 changed files with 340 additions and 9 deletions
+164
View File
@@ -0,0 +1,164 @@
// L10a Sicherheitstest §43.4 „Rate Limiting", „Session-Handling", „Audit-Log-Manipulation" (Service-/DB-Ebene).
//
// Login: 5 Fehlversuche → Identität gesperrt (auch das richtige Passwort wird abgewiesen), Fehlversuche
// und Sperre auditiert, unbekannte E-Mail ohne Orakel, Ausnahme „letzter aktiver Mandanten-Admin"
// (keine DoS-Sperre, aber protokolliert). Rate-Limit der Wiederherstellungs-Abläufe je IP und Konto.
// Session-Kill-Switch (isTokenStillValid, fail-closed ohne iat). Audit-Log: kein Fachpfad (Services,
// Actions, Route Handler) ändert oder löscht Audit-Einträge (statische Prüfung), die App-Rolle
// craftvia_app hat auf audit_logs weder UPDATE noch DELETE (Migration *_qualitaet_audit_append_only),
// der Mandanten-Guard verhindert fremde Zugriffe. Passwort-Reset/MFA-Härtung: test-reset-flow.ts,
// test-mfa-hardening.ts; Session-Cookies über HTTP: test-security-http.ts.
//
// Lauf: npx tsx scripts/test-security-auth.ts
import "dotenv/config";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { prisma, dbForTenant } from "../src/server/db";
import { hashPassword } from "../src/server/password";
import { authorizeTenantCredentials } from "../src/server/auth";
import { checkRateLimit, resetRateLimits } from "../src/server/rate-limit";
import { isTokenStillValid } from "../src/server/sessions";
import { writeAuditLog } from "../src/server/audit";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG = "zz-q-sec-auth";
const SLUG_B = "zz-q-sec-auth-b";
const PASSWORD = "Pruef-Passwort-2026!";
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
return statSync(p).isDirectory() ? walk(p) : /\.(ts|tsx)$/.test(name) ? [p] : [];
});
}
function defaultRlsUrl(): string {
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
base.username = "craftvia_app";
base.password = "craftvia_app_local";
return base.toString();
}
runSuite("Sicherheit: Login-Sperre, Rate-Limit, Sessions, Audit-Log", [SLUG, SLUG_B], async () => {
const A = await createTenant(SLUG);
const B = await createTenant(SLUG_B);
const hash = await hashPassword(PASSWORD);
await prisma.identity.updateMany({ where: { id: { in: [A.users.tech.identityId, A.users.admin.identityId] } }, data: { passwordHash: hash, failedLogins: 0, lockedUntil: null } });
const techEmail = A.users.tech.email;
section("Login: Sperre nach Fehlversuchen");
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "richtiges Passwort → Anmeldung möglich");
for (let i = 1; i <= 5; i++) {
const res = await authorizeTenantCredentials({ email: techEmail, password: `falsch-${i}`, tenant: SLUG });
if (res !== null) ok(false, `Fehlversuch ${i} wurde angenommen`);
}
const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech.identityId } });
ok(!!locked.lockedUntil && locked.lockedUntil.getTime() > Date.now() + 10 * 60_000, `nach 5 Fehlversuchen gesperrt bis ${locked.lockedUntil?.toISOString()}`);
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) === null, "gesperrt: auch das richtige Passwort wird abgewiesen");
const denied = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: A.users.tech.identityId } });
ok(denied.length >= 6 && denied.some((d) => (d.after as { locked?: boolean } | null)?.locked === true) && denied.some((d) => (d.after as { reason?: string } | null)?.reason === "locked"), `Fehlversuche, Sperre und Versuch während der Sperre auditiert (${denied.length})`);
await prisma.identity.update({ where: { id: locked.id }, data: { lockedUntil: new Date(Date.now() - 1000) } });
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "nach Ablauf der Sperre wieder anmeldbar, Zähler zurückgesetzt");
ok((await prisma.identity.findUniqueOrThrow({ where: { id: locked.id } })).failedLogins === 0, "Fehlversuchszähler nach erfolgreicher Anmeldung 0");
ok((await authorizeTenantCredentials({ email: `unbekannt${Date.now()}@zz-qualitaet.test`, password: PASSWORD, tenant: SLUG })) === null, "unbekannte E-Mail → gleiche Antwort (null), kein Konto-Orakel");
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG_B })) === null, "Anmeldung in einem Mandanten ohne Mitgliedschaft abgewiesen");
section("Login: letzter aktiver Mandanten-Admin wird nicht ausgesperrt (DoS-Schutz)");
for (let i = 0; i < 6; i++) await authorizeTenantCredentials({ email: A.users.admin.email, password: "falsch", tenant: SLUG });
const admin = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.admin.identityId } });
ok(!admin.lockedUntil || admin.lockedUntil.getTime() < Date.now(), "letzter Admin nicht gesperrt");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: admin.id } })) >= 6, "Fehlversuche des Admins trotzdem auditiert");
console.log(" Hinweis: Das Login zählt Fehlversuche je Identität (Sperre); ein IP-basiertes Limit gegen Password-Spraying über viele Konten gibt es nur für Reset/Einladung (siehe Bericht).");
section("Rate-Limit Wiederherstellung (je IP und je Konto)");
resetRateLimits();
const results = Array.from({ length: 6 }, () => checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "opfer@zz-qualitaet.test" }).allowed);
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "Reset-Anfrage: 5 erlaubt, 6. blockiert");
const blocked = checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "anderes@zz-qualitaet.test" });
ok(!blocked.allowed && blocked.retryAfterSeconds > 0, "gleiche IP, anderes Konto → weiter blockiert (mit Wartezeit)");
ok(!checkRateLimit("passwordResetRequest", { ip: "198.51.100.1", account: "opfer@zz-qualitaet.test" }).allowed, "anderes Konto-Ziel über neue IP → Konto-Zähler blockiert");
ok(checkRateLimit("passwordResetRequest", { ip: "198.51.100.2", account: "frei@zz-qualitaet.test" }).allowed, "unbeteiligte IP + Konto → erlaubt");
resetRateLimits();
const redeem = Array.from({ length: 11 }, () => checkRateLimit("passwordResetRedeem", { ip: "203.0.113.9" }).allowed);
ok(redeem.filter(Boolean).length === 10 && !redeem[10], "Reset-Einlösung: 10 Versuche je IP, dann blockiert");
resetRateLimits();
section("Session-Kill-Switch");
const now = Math.floor(Date.now() / 1000);
ok(isTokenStillValid(now, null), "ohne Invalidierungsmarke gültig");
ok(!isTokenStillValid(now - 60, new Date(now * 1000)), "Token vor der Marke ausgestellt → ungültig");
ok(isTokenStillValid(now + 1, new Date(now * 1000)), "Token nach der Marke → gültig");
ok(!isTokenStillValid(undefined, new Date()), "Token ohne iat bei gesetzter Marke → fail-closed");
section("Audit-Log: kein Fachpfad ändert oder löscht Einträge");
const root = process.cwd();
const scanned = ["src/app", "src/server/actions", "src/server/services", "src/server/api", "src/server/jobs", "src/lib"].flatMap((d) => walk(join(root, d)));
const offenders = scanned.filter((file) => {
const text = readFileSync(file, "utf8");
return /auditLog\s*\.\s*(update|updateMany|delete|deleteMany|upsert)\b/.test(text) || /(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?audit_logs/i.test(text);
});
ok(scanned.length > 100 && offenders.length === 0, `${scanned.length} Dateien geprüft, keine Mutation von audit_logs${offenders.length ? ` — ${offenders.map((f) => relative(root, f)).join(", ")}` : ""}`);
const apiRoutes = walk(join(root, "src/app/api"));
ok(!apiRoutes.some((f) => /audit/i.test(relative(root, f))), "keine /api-Route für Audit-Einträge");
const viewer = readFileSync(join(root, "src/server/services/audit/viewer.ts"), "utf8");
ok(!/\.(create|update|delete|upsert)\w*\(/.test(viewer.replace(/writeAuditLog/g, "")), "Audit-Viewer-Service ist rein lesend");
const privileges = await prisma.$queryRawUnsafe<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }[]>(
`SELECT has_table_privilege('craftvia_app', 'audit_logs', 'SELECT') AS sel, has_table_privilege('craftvia_app', 'audit_logs', 'INSERT') AS ins,
has_table_privilege('craftvia_app', 'audit_logs', 'UPDATE') AS upd, has_table_privilege('craftvia_app', 'audit_logs', 'DELETE') AS del`,
);
ok(privileges[0].sel && privileges[0].ins, "App-Rolle darf Audit-Einträge lesen und anlegen");
ok(!privileges[0].upd && !privileges[0].del, "App-Rolle darf Audit-Einträge weder ändern noch löschen (append-only)");
await writeAuditLog({ tenantId: A.tenantId, actorId: A.users.backoffice.id, action: "update", entity: "zz_audit_probe", entityId: "p1", before: { v: 1 }, after: { v: 2 } });
const probe = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz_audit_probe" } });
ok(probe.actorId === A.users.backoffice.id && (probe.before as { v: number }).v === 1, "writeAuditLog protokolliert Akteur und before/after");
let foreignBlocked = false;
try {
await dbForTenant(B.tenantId).auditLog.update({ where: { id: probe.id }, data: { entity: "manipuliert" } });
} catch {
foreignBlocked = true;
}
ok(foreignBlocked && (await prisma.auditLog.count({ where: { tenantId: B.tenantId, entity: "manipuliert" } })) === 0, "fremder Mandant kann den Eintrag nicht ändern (Tenant-Guard)");
let deletedByB = -1;
try {
deletedByB = (await dbForTenant(B.tenantId).auditLog.deleteMany({ where: { id: probe.id } })).count;
} catch (err) {
// RLS_ENFORCED=true: the app role has no DELETE privilege on audit_logs at all
if (/permission denied/i.test((err as Error).message)) deletedByB = 0;
else throw err;
}
ok(deletedByB === 0, "fremder Mandant kann den Eintrag nicht löschen");
const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) });
try {
await app.$queryRawUnsafe("SELECT 1");
const attempt = async (sql: string) => {
try {
await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
await tx.$executeRawUnsafe(sql, probe.id);
});
return "executed";
} catch (err) {
return /permission denied/i.test((err as Error).message) ? "denied" : `error:${(err as Error).message.split("\n")[0]}`;
}
};
ok((await attempt(`UPDATE audit_logs SET entity = 'manipuliert' WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: UPDATE audit_logs → permission denied");
ok((await attempt(`DELETE FROM audit_logs WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: DELETE audit_logs → permission denied");
const readable = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
return tx.auditLog.count({ where: { id: probe.id } });
});
ok(readable === 1, "craftvia_app liest den eigenen Eintrag weiterhin");
} catch (err) {
if (process.env.RLS_TEST_REQUIRED === "true") throw err;
console.log(`⚠ DB-Rollenprüfung übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`);
} finally {
await app.$disconnect();
}
ok((await prisma.auditLog.findUniqueOrThrow({ where: { id: probe.id } })).entity === "zz_audit_probe", "Audit-Eintrag nach allen Versuchen unverändert");
});