// L10a Sicherheitstest §43.4 über HTTP gegen den echten Next.js-Server (Route Handler, Proxy, Auth.js-Cookies). // // Server: SECURITY_BASE=http://localhost:3110 (laufender Server) — sonst wird der Produktions-Build (.next, // z. B. aus `npm run gate`) per `next start` auf einem freien Port gestartet. Ohne Build und ohne // SECURITY_BASE wird der Test mit Hinweis übersprungen (Exit 0). // Sessions werden wie in scripts/smoke-auth.ts ohne Passworteingabe ausgestellt (finalizeIdentityLogin + // Auth.js encode); nur der Login-Sperrtest nutzt ein Test-Passwort einer zz-Test-Identität. // // Nachweise: (1) ohne Session: /api/v1 → 401, Datei-Routen liefern keine Bytes; (2) manipulierte IDs: // jede /api/v1-Route und jede Datei-Route mit IDs aus Mandant A (Session aus Mandant B bzw. Monteur ohne // Zuweisung) → 404, nie 200/403, keine Daten im Body; unsinnige IDs → 404 statt 500; (3) unzulässige // Rollenaktionen → 403 + Audit „denied“; (4) schädliche Uploads über alle Upload-Routen; Polyglot wird nur // als Anhang mit nosniff ausgeliefert; Pfad-Traversal/CRLF im Dateinamen; Übergröße; Server bleibt // erreichbar; (5) CSRF: fremder Origin → 403; (6) Session-Handling: manipuliertes/abgelaufenes/fremd // signiertes Cookie, Kill-Switch, deaktiviertes Konto; (7) Audit-Log nicht über HTTP änderbar; // (8) Login-Sperre über den echten Credentials-Endpunkt; (9) Sicherheits-Header. // Statuscodes: `invalid` wird als 400 oder 422 akzeptiert, `blocked` als 409 oder 422 (Vereinheitlichung L10b). // // Lauf: npx tsx scripts/test-security-http.ts (oder SECURITY_BASE=http://localhost:3110 npx tsx …) import "dotenv/config"; import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { createServer } from "node:net"; import { randomUUID } from "node:crypto"; import { encode } from "next-auth/jwt"; import { prisma } from "../src/server/db"; import { finalizeIdentityLogin } from "../src/server/auth"; import { hashPassword } from "../src/server/password"; import { createWorkOrder } from "../src/server/services/work-orders/create"; import { assignWorkOrder } from "../src/server/services/work-orders/assign"; import { startSession } from "../src/server/services/field/sessions"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { createNote } from "../src/server/services/field/notes"; import { createDailyReport } from "../src/server/services/reports/create"; import { createImport } from "../src/server/services/imports/upload"; import { noteCreatePayload, sessionStartPayload } from "../src/lib/sync/ops"; import { buildPdf } from "./make-sample-pdfs"; import { createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-sec-http-a"; const SLUG_B = "zz-q-sec-http-b"; const MARK_A = `Hausverwaltung ${SLUG_A}`; const INVALID = [400, 422]; async function freePort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); srv.once("error", reject); srv.listen(0, () => { const port = (srv.address() as { port: number }).port; srv.close(() => resolve(port)); }); }); } async function startServer(): Promise<{ base: string; stop: () => void; logs: () => string } | null> { if (process.env.SECURITY_BASE) return { base: process.env.SECURITY_BASE.replace(/\/$/, ""), stop: () => undefined, logs: () => "" }; if (!existsSync(".next/BUILD_ID")) return null; const port = await freePort(); let output = ""; const child: ChildProcess = spawn(process.execPath, ["node_modules/next/dist/bin/next", "start", "-p", String(port)], { env: { ...process.env, NODE_ENV: "production", PORT: String(port) }, stdio: ["ignore", "pipe", "pipe"], }); child.stdout?.on("data", (d) => (output += d)); child.stderr?.on("data", (d) => (output += d)); const base = `http://localhost:${port}`; for (let i = 0; i < 120; i++) { try { const res = await fetch(`${base}/api/v1/work-orders`, { signal: AbortSignal.timeout(2000) }); if (res.status === 401) return { base, stop: () => child.kill("SIGTERM"), logs: () => output }; } catch { /* not ready yet */ } await new Promise((r) => setTimeout(r, 500)); } child.kill("SIGTERM"); throw new Error(`next start did not become ready on ${base}:\n${output.slice(-2000)}`); } type Res = { status: number; text: string; headers: Headers }; runSuite("Sicherheit über HTTP", [SLUG_A, SLUG_B], async () => { const server = await startServer(); if (!server) { console.log("⚠ ÜBERSPRUNGEN: kein Produktions-Build (.next/BUILD_ID) und kein SECURITY_BASE gesetzt. `npm run build` oder SECURITY_BASE=http://localhost:3110."); return; } const { base } = server; console.log(`Server: ${base}`); const cookieName = base.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; try { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); async function cookieFor(f: TenantFixture, persona: keyof TenantFixture["users"], opts: { secret?: string; maxAge?: number } = {}) { const user = await finalizeIdentityLogin(f.users[persona].identityId, f.slug); if (!user) throw new Error(`no session user for ${persona}`); const token = { sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId, tenantId: user.tenantId, tenantSlug: user.tenantSlug, activeMembershipId: user.activeMembershipId, memberships: user.memberships, roles: user.roles, permissions: user.permissions, isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled, }; const value = await encode({ token, secret: opts.secret ?? process.env.AUTH_SECRET!, salt: cookieName, maxAge: opts.maxAge ?? 30 * 60 }); return `${cookieName}=${value}`; } async function call(method: string, path: string, opts: { cookie?: string; json?: unknown; form?: FormData; origin?: string | null; accept?: string } = {}): Promise { const headers: Record = {}; if (opts.cookie) headers.cookie = opts.cookie; if (opts.accept) headers.accept = opts.accept; const origin = opts.origin === undefined ? (method === "GET" ? null : base) : opts.origin; if (origin) headers.origin = origin; let body: BodyInit | undefined; if (opts.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.json); } else if (opts.form) body = opts.form; const res = await fetch(base + path, { method, headers, body, redirect: "manual", signal: AbortSignal.timeout(60_000) }); return { status: res.status, text: await res.text(), headers: res.headers }; } const leaks = (r: Res) => r.text.includes(MARK_A) || /prisma|stack|at \/|node_modules|PrismaClient|SELECT /i.test(r.text); const expectStatus = (label: string, r: Res, allowed: number[]) => ok(allowed.includes(r.status) && !leaks(r), `${label} → ${r.status}${allowed.includes(r.status) ? "" : ` (erwartet ${allowed.join("|")})`}${leaks(r) ? " — Body enthält Daten/Interna" : ""}`); // ---------- Daten in Mandant A ---------- const bo = A.ctx.backoffice; const wo = await createWorkOrder(bo, { title: "HTTP-Sicherheit A", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, materials: [{ name: "Rohr", plannedQuantity: 1, unit: "m" }] }); await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] }); const free = await createWorkOrder(bo, { title: "Unzugeordnet A", customerId: A.customerId }); await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() })); const jpeg = await jpegBytes("HTTP"); const photo = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "a.jpg", type: "image/jpeg" }); await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "HTTP-Test" })); const report = (await createDailyReport(A.ctx.tech, { workOrderId: wo.id })).report; const pdf = buildPdf([[{ text: "HTTP-Sicherheit" }]]); const imp = await createImport(bo, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); const cA = { bo: await cookieFor(A, "backoffice"), admin: await cookieFor(A, "admin"), tech: await cookieFor(A, "tech"), outsider: await cookieFor(A, "outsider"), lead: await cookieFor(A, "lead") }; const cB = { bo: await cookieFor(B, "backoffice"), admin: await cookieFor(B, "admin"), tech: await cookieFor(B, "tech") }; // ================= (1) ohne Session ================= section("(1) Ohne Session"); const anonymous: [string, string][] = [ ["GET", "/api/v1/work-orders"], ["POST", "/api/v1/work-orders"], ["GET", `/api/v1/work-orders/${wo.id}`], ["POST", `/api/v1/work-orders/${wo.id}/transition`], ["GET", "/api/v1/customers"], ["GET", `/api/v1/customers/${A.customerId}`], ["GET", `/api/v1/sites/${A.siteId}/history`], ["POST", "/api/v1/sync"], ["POST", "/api/v1/uploads"], ["GET", "/api/v1/field/bundle"], ["GET", `/api/v1/field/documents/${photo.documentId}`], ["GET", `/api/v1/reports/${report.id}/pdf`], ["GET", `/api/v1/imports/${imp.id}`], ["POST", "/api/v1/work-orders/import"], ]; for (const [m, p] of anonymous) { const r = await call(m, p, { json: m === "POST" ? {} : undefined }); ok(r.status === 401 && !leaks(r) && /unauthorized/.test(r.text), `${m} ${p} ohne Session → ${r.status}`); } for (const p of [`/files/${photo.documentId}`, `/imports/${imp.id}/file`, "/dashboard", "/m"]) { const r = await call("GET", p); ok([302, 303, 307].includes(r.status) && (r.headers.get("location") ?? "").includes("/login") && r.text.length < 2000 && !r.text.includes("JFIF"), `GET ${p} ohne Session → Umleitung zur Anmeldung, keine Datei`); } // ================= (2) manipulierte IDs ================= section("(2) Manipulierte IDs: Mandant B greift auf IDs aus Mandant A zu"); const jpegForm = () => { const f = new FormData(); f.set("file", new File([new Uint8Array(jpeg)], "b.jpg", { type: "image/jpeg" })); return f; }; const uploadForm = (workOrderId: string, bytes: Uint8Array = jpeg, name = "b.jpg", type = "image/jpeg") => { const f = new FormData(); f.set("file", new File([new Uint8Array(bytes)], name, { type })); f.set("clientId", randomUUID()); f.set("workOrderId", workOrderId); f.set("kind", "photo"); return f; }; const validReviewForm = { customerMode: "new", customer: { companyName: "B-Kunde" }, siteMode: "none", site: {}, contact: {}, order: { title: "B" }, positions: [] }; const foreign: [string, string, Parameters[2]][] = [ ["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo }], ["PATCH", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo, json: { title: "B" } }], ["POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cB.bo, json: { teamId: B.teamId } }], ["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cB.bo, json: { to: "cancelled", reason: "B" } }], ["GET", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo }], ["POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo, json: { name: "B", plannedQuantity: 1, unit: "Stk" } }], ["POST", `/api/v1/work-orders/${wo.id}/documents`, { cookie: cB.bo, form: jpegForm() }], ["POST", `/api/v1/work-orders/${wo.id}/daily-report`, { cookie: cB.tech, json: {} }], ["POST", `/api/v1/work-orders/${wo.id}/completion-report`, { cookie: cB.tech, json: {} }], ["POST", `/api/v1/reports/${report.id}/approve`, { cookie: cB.bo }], ["GET", `/api/v1/reports/${report.id}/pdf`, { cookie: cB.bo }], ["GET", `/api/v1/reports/${report.id}/files/${photo.documentId}`, { cookie: cB.bo }], ["GET", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo }], ["PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo, json: { companyName: "B" } }], ["GET", `/api/v1/sites/${A.siteId}/history`, { cookie: cB.bo }], ["GET", `/api/v1/imports/${imp.id}`, { cookie: cB.bo }], ["POST", `/api/v1/imports/${imp.id}/confirm`, { cookie: cB.admin, json: validReviewForm }], ["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cB.tech }], ["GET", `/api/v1/field/documents/${photo.documentId}?variant=preview`, { cookie: cB.tech }], ["POST", "/api/v1/uploads", { cookie: cB.tech, form: uploadForm(wo.id) }], ["GET", `/files/${photo.documentId}`, { cookie: cB.admin }], ["GET", `/files/${imp.documentId}`, { cookie: cB.admin }], ["GET", `/imports/${imp.id}/file`, { cookie: cB.admin }], ]; for (const [m, p, o] of foreign) expectStatus(`B: ${m} ${p.replace(/[a-z0-9]{20,}/g, "")}`, await call(m, p, o), [404]); const syncB = await call("POST", "/api/v1/sync", { cookie: cB.tech, json: { deviceId: "b", operations: [{ clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: wo.id, text: "B" }, clientCreatedAt: new Date().toISOString() }] } }); ok(syncB.status === 200 && /"not_found"/.test(syncB.text) && !/"applied"/.test(syncB.text), "B: POST /api/v1/sync mit Auftrag aus A → Op rejected not_found"); const bundleB = await call("GET", "/api/v1/field/bundle", { cookie: cB.tech }); ok(bundleB.status === 200 && !bundleB.text.includes(wo.id) && !leaks(bundleB), "B: Bundle enthält keine Aufträge aus A"); const listB = await call("GET", "/api/v1/work-orders", { cookie: cB.bo }); ok(listB.status === 200 && !listB.text.includes(wo.id) && !listB.text.includes(free.id), "B: Auftragsliste ohne Aufträge aus A"); const customersB = await call("GET", `/api/v1/customers?q=${encodeURIComponent(SLUG_A)}`, { cookie: cB.bo }); ok(customersB.status === 200 && !leaks(customersB), "B: Kundensuche nach A-Namen findet nichts"); section("(2b) Monteur ohne Zuweisung und unsinnige IDs"); for (const [m, p, o] of [ ["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cA.outsider }], ["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cA.outsider, json: { to: "paused" } }], ["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cA.outsider }], ["POST", "/api/v1/uploads", { cookie: cA.outsider, form: uploadForm(wo.id) }], ["GET", `/api/v1/customers/${A.customerId}`, { cookie: cA.outsider }], ["GET", `/files/${photo.documentId}`, { cookie: cA.outsider }], ] as [string, string, Parameters[2]][]) { const r = await call(m, p, o); ok(r.status === 404 && !r.text.includes("HTTP-Sicherheit A"), `Monteur ohne Zuweisung: ${m} ${p.replace(/[a-z0-9]{20,}/g, "")} → ${r.status}`); } for (const bad of ["..%2F..%2Fetc%2Fpasswd", "' OR '1'='1", "x".repeat(300), "%00", "../../api/v1/customers"]) { const r1 = await call("GET", `/api/v1/work-orders/${encodeURIComponent(bad)}`, { cookie: cA.bo }); const r2 = await call("GET", `/files/${encodeURIComponent(bad)}`, { cookie: cA.bo }); ok([400, 404].includes(r1.status) && [400, 404].includes(r2.status) && !leaks(r1) && !leaks(r2), `unsinnige ID ${JSON.stringify(bad.slice(0, 24))} → ${r1.status}/${r2.status} (kein 500)`); } // ================= (3) Rollen ================= section("(3) Unzulässige Rollenaktionen über HTTP"); const deniedBefore = await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } }); const roleCases: [string, string, string, Parameters[2]][] = [ ["Monteur legt Kunden an", "POST", "/api/v1/customers", { cookie: cA.tech, json: { companyName: "Monteur GmbH" } }], ["Monteur ändert Kunden", "PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.tech, json: { companyName: "Monteur" } }], ["Monteur legt Auftrag an", "POST", "/api/v1/work-orders", { cookie: cA.tech, json: { title: "x", customerId: A.customerId } }], ["Monteur weist zu", "POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cA.tech, json: { teamId: A.teamId } }], ["Monteur ändert Materialvorgabe", "POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cA.tech, json: { name: "x", plannedQuantity: 1, unit: "Stk" } }], ["Monteur gibt Bericht frei", "POST", `/api/v1/reports/${report.id}/approve`, { cookie: cA.tech }], ["Monteur öffnet Import", "GET", `/api/v1/imports/${imp.id}`, { cookie: cA.tech }], ["Monteur lädt Auftragsbestätigung hoch", "POST", "/api/v1/work-orders/import", { cookie: cA.tech, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(pdf)], "a.pdf", { type: "application/pdf" })); return f; })() }], ["Teamleiter legt Objekt an", "POST", "/api/v1/sites", { cookie: cA.lead, json: { customerId: A.customerId, name: "Teamleiter-Objekt" } }], ["Backoffice nutzt Einsatz-Upload", "POST", "/api/v1/uploads", { cookie: cA.bo, form: uploadForm(wo.id) }], ]; for (const [label, m, p, o] of roleCases) expectStatus(label, await call(m, p, o), [403]); ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } })) > deniedBefore, "abgewiesene Rollenaktionen als „denied“ auditiert"); const techList = await call("GET", "/api/v1/work-orders", { cookie: cA.tech }); ok(techList.status === 200 && techList.text.includes(wo.id) && !techList.text.includes(free.id), "Monteur: Auftragsliste nur mit eigenen Aufträgen"); // ================= (4) schädliche Uploads ================= section("(4) Schädliche Uploads über HTTP"); const docForm = (bytes: Uint8Array, name: string, type: string) => { const f = new FormData(); f.set("file", new File([new Uint8Array(bytes)], name, { type })); f.set("category", "other"); f.set("visibility", "team"); f.set("customerId", A.customerId); return f; }; const exe = Buffer.concat([Buffer.from("MZ\x90\x00", "binary"), Buffer.alloc(256, 0x41)]); const html = Buffer.from(""); const up = (bytes: Uint8Array, name: string, type: string, extra: Parameters[2] = {}) => call("POST", "/documents/upload", { cookie: cA.bo, form: docForm(bytes, name, type), accept: "application/json", ...extra }); const rExe = await up(exe, "rechnung.pdf", "application/pdf"); ok(INVALID.includes(rExe.status) && /type_mismatch/.test(rExe.text), `EXE als rechnung.pdf → ${rExe.status} type_mismatch`); const rHtml = await up(html, "bild.jpg", "image/jpeg"); ok(INVALID.includes(rHtml.status) && /type_mismatch/.test(rHtml.text), `HTML als bild.jpg → ${rHtml.status}`); const rSvg = await up(Buffer.from(""), "logo.svg", "image/svg+xml"); ok(INVALID.includes(rSvg.status) && /unsupported_type/.test(rSvg.text), `SVG mit Skript → ${rSvg.status}`); const rPoly = await up(Buffer.concat([jpeg, html]), "polyglot.jpg", "image/jpeg"); const polyId = (JSON.parse(rPoly.text || "{}") as { data?: { id?: string } }).data?.id; ok(rPoly.status === 201 && !!polyId, "Polyglot (JPEG + HTML) als Bild angenommen"); if (polyId) { const dl = await call("GET", `/files/${polyId}`, { cookie: cA.bo }); ok(dl.status === 200 && dl.headers.get("content-type") === "image/jpeg" && /^attachment;/.test(dl.headers.get("content-disposition") ?? "") && dl.headers.get("x-content-type-options") === "nosniff", "Polyglot-Download: image/jpeg, attachment, nosniff (kein Ausführen als HTML)"); ok(/default-src 'self'/.test(dl.headers.get("content-security-policy") ?? ""), "Datei-Auslieferung mit Content-Security-Policy"); await expectStatus("Polyglot aus Mandant B", await call("GET", `/files/${polyId}`, { cookie: cB.admin }), [404]); } const rTrav = await up(pdf, "../../../etc/passwd.pdf", "application/pdf"); const travId = (JSON.parse(rTrav.text || "{}") as { data?: { id?: string } }).data?.id; const travDoc = travId ? await prisma.document.findUnique({ where: { id: travId } }) : null; ok(rTrav.status === 201 && !!travDoc && !/[\\/]/.test(travDoc.fileName) && travDoc.storageKey.startsWith(`${A.tenantId}/`) && !travDoc.storageKey.includes(".."), `Pfad-Traversal im Dateinamen → gespeichert als ${travDoc?.fileName}, Key im Mandantenpräfix`); const rCrlf = await up(pdf, "bericht\r\nX-Injected: 1.pdf", "application/pdf"); const crlfId = (JSON.parse(rCrlf.text || "{}") as { data?: { id?: string } }).data?.id; if (crlfId) { const dl = await call("GET", `/files/${crlfId}`, { cookie: cA.bo }); ok(dl.status === 200 && !dl.headers.has("x-injected") && !/[\r\n]/.test(dl.headers.get("content-disposition") ?? ""), "CR/LF im Dateinamen führt nicht zu Header-Injection beim Download"); } else ok(INVALID.includes(rCrlf.status), `CR/LF-Dateiname abgewiesen (${rCrlf.status})`); const big = Buffer.concat([pdf, Buffer.alloc(25 * 1024 * 1024 + 1024)]); const rBig = await up(big, "gross.pdf", "application/pdf"); ok([400, 413, 422].includes(rBig.status) && !leaks(rBig), `PDF > 25 MB → ${rBig.status}`); const rUpExe = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, exe, "foto.jpg", "image/jpeg") }); ok(INVALID.includes(rUpExe.status) && !leaks(rUpExe), `Einsatz-Upload: EXE als Foto → ${rUpExe.status}`); const rUpBig = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, Buffer.concat([jpeg, Buffer.alloc(26 * 1024 * 1024)])) }); ok([400, 413, 422].includes(rUpBig.status), `Einsatz-Upload > 25 MB → ${rUpBig.status}`); const rImpExe = await call("POST", "/api/v1/work-orders/import", { cookie: cA.bo, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(exe)], "auftrag.pdf", { type: "application/pdf" })); return f; })() }); ok(INVALID.includes(rImpExe.status) && !leaks(rImpExe), `Import: EXE als PDF → ${rImpExe.status}`); const alive = await call("GET", "/api/v1/work-orders", { cookie: cA.bo }); ok(alive.status === 200, "Server nach Übergrößen-Uploads weiter erreichbar"); // ================= (5) CSRF ================= section("(5) CSRF: fremder Origin"); const evil = "https://evil.example"; expectStatus("Dokument-Upload mit fremdem Origin", await up(pdf, "csrf.pdf", "application/pdf", { origin: evil }), [403]); expectStatus("PATCH Kunde mit fremdem Origin", await call("PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.bo, json: { companyName: "CSRF" }, origin: evil }), [403]); expectStatus("Sync mit fremdem Origin", await call("POST", "/api/v1/sync", { cookie: cA.tech, json: { deviceId: "x", operations: [] }, origin: evil }), [403]); ok((await prisma.customer.findUniqueOrThrow({ where: { id: A.customerId } })).companyName === MARK_A + " GmbH", "Kunde durch CSRF-Versuche unverändert"); // ================= (6) Sessions ================= section("(6) Session-Handling"); const probe = (cookie?: string) => call("GET", "/api/v1/customers?pageSize=1", { cookie }); ok((await probe(cA.bo)).status === 200, "gültige Session → 200"); const value = cA.bo.split("=")[1]; const tampered = `${cookieName}=${value.slice(0, -6)}${value.slice(-6).split("").reverse().join("")}`; ok((await probe(tampered)).status === 401, "manipuliertes Session-Cookie → 401"); ok((await probe(await cookieFor(A, "backoffice", { secret: "x".repeat(48) }))).status === 401, "mit fremdem Schlüssel signiertes Cookie → 401"); ok((await probe(await cookieFor(A, "backoffice", { maxAge: -60 }))).status === 401, "abgelaufenes Session-Cookie → 401"); const page = await call("GET", "/dashboard", { cookie: tampered }); ok([302, 303, 307].includes(page.status) && (page.headers.get("location") ?? "").includes("/login"), "Seite mit manipuliertem Cookie → Anmeldung"); await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: new Date(Date.now() + 5000) } }); ok((await probe(cA.bo)).status === 401, "Kill-Switch (alle Sitzungen beendet) → alte Session 401"); await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: null } }); await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "DEACTIVATED" } }); const deact = await probe(cA.bo); ok([401, 403].includes(deact.status) && !leaks(deact), `deaktiviertes Konto mit gültigem Cookie → ${deact.status}`); await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "ACTIVE" } }); ok((await probe(cA.bo)).status === 200, "reaktiviert → wieder 200"); // ================= (7) Audit-Log ================= section("(7) Audit-Log nicht über HTTP änderbar"); const auditRow = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId }, orderBy: { createdAt: "asc" } }); const snapshot = JSON.stringify(auditRow); for (const p of ["/api/v1/audit", "/api/v1/audit-logs", `/api/v1/audit-logs/${auditRow.id}`, `/api/v1/audit/${auditRow.id}`, `/settings/audit/${auditRow.id}`]) { for (const m of ["PATCH", "PUT", "DELETE"]) { const r = await call(m, p, { cookie: cA.admin, json: { entity: "manipuliert" } }); if (r.status >= 200 && r.status < 300) ok(false, `${m} ${p} → ${r.status} (erwartet kein 2xx)`); } } ok(JSON.stringify(await prisma.auditLog.findUniqueOrThrow({ where: { id: auditRow.id } })) === snapshot, "keine Route ändert/löscht Audit-Einträge (PATCH/PUT/DELETE auf Audit-Pfade ohne Wirkung)"); const viewer = await call("GET", "/settings/audit", { cookie: cA.admin }); // read-only by construction (no mutating service/route, see test-security-auth.ts static scan); the page renders ok(viewer.status === 200 && viewer.text.includes(auditRow.entity), `Audit-Protokoll (Admin) rendert Einträge des eigenen Mandanten (${viewer.status})`); const auditB = await call("GET", `/settings/audit?detail=${auditRow.id}`, { cookie: cB.admin }); ok(auditB.status === 200 && !auditB.text.includes(auditRow.id.slice(0, 20) + "\"") && !auditB.text.includes(MARK_A), "Audit-Detail aus Mandant B zeigt keinen Eintrag von A"); // ================= (8) Login-Sperre ================= section("(8) Login-Sperre über den Credentials-Endpunkt"); const providers = await call("GET", "/api/auth/providers"); if (providers.status !== 200 || !providers.text.includes('"credentials"')) { console.log(`⚠ Login-Test übersprungen: Credentials-Provider nicht gefunden (${providers.status})`); } else { const password = "Http-Pruef-Passwort-2026!"; await prisma.identity.update({ where: { id: A.users.tech2.identityId }, data: { passwordHash: await hashPassword(password), failedLogins: 0, lockedUntil: null } }); const csrf = await fetch(`${base}/api/auth/csrf`); const { csrfToken } = (await csrf.json()) as { csrfToken: string }; const jar = csrf.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); const login = async (pw: string) => { const res = await fetch(`${base}/api/auth/callback/credentials`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", cookie: jar, origin: base }, body: new URLSearchParams({ email: A.users.tech2.email, password: pw, tenant: SLUG_A, csrfToken }), redirect: "manual", }); return res.headers.getSetCookie().some((c) => c.startsWith(`${cookieName}=`) && !c.startsWith(`${cookieName}=;`)); }; ok(await login(password), "Positivkontrolle: richtiges Passwort setzt ein Session-Cookie"); for (let i = 0; i < 5; i++) ok(!(await login(`falsch-${i}`)), `Fehlversuch ${i + 1} → kein Session-Cookie`); const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech2.identityId } }); ok(!!locked.lockedUntil && locked.lockedUntil > new Date(), "nach 5 Fehlversuchen über HTTP gesperrt"); ok(!(await login(password)), "gesperrt: richtiges Passwort erhält kein Session-Cookie"); } // ================= (9) Header ================= section("(9) Sicherheits-Header"); const loginPage = await call("GET", "/login"); const h = loginPage.headers; ok(/frame-ancestors 'none'/.test(h.get("content-security-policy") ?? "") && h.get("x-frame-options") === "DENY", "Clickjacking-Schutz (CSP frame-ancestors, X-Frame-Options)"); ok(h.get("x-content-type-options") === "nosniff" && /max-age=\d+/.test(h.get("strict-transport-security") ?? "") && !!h.get("referrer-policy"), "nosniff, HSTS, Referrer-Policy gesetzt"); const apiErr = await call("GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo }); ok(/no-store/.test(apiErr.headers.get("cache-control") ?? "") || apiErr.status === 404, "API-Antworten nicht cachebar"); } finally { server.stop(); } });