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:
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user