From 388ca85f548948a41b2cbc0edcf6a3c01c254b40 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:34:29 +0200 Subject: [PATCH] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20HTTP?= =?UTF-8?q?-Sicherheitstest=20(manipulierte=20IDs=20aller=20/api/v1-Routen?= =?UTF-8?q?,=20Datei-Routen,=20Uploads,=20CSRF,=20Sessions,=20Login-Sperre?= =?UTF-8?q?,=20Header)=20gegen=20next=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/test-e2e-offline-sync.ts | 18 +- scripts/test-security-http.ts | 383 +++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 scripts/test-security-http.ts diff --git a/scripts/test-e2e-offline-sync.ts b/scripts/test-e2e-offline-sync.ts index de9e7a2..8119270 100644 --- a/scripts/test-e2e-offline-sync.ts +++ b/scripts/test-e2e-offline-sync.ts @@ -9,7 +9,8 @@ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; -import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import { CONFLICTING_OPS, SYNC_OP_TYPES, type SyncOperationInput } from "../src/lib/sync/envelope"; +import { EXTERNAL_OPS } from "../src/server/services/sync/external-ops"; import { applyOperations } from "../src/server/services/sync/apply"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { getFieldBundle } from "../src/server/services/field/queries"; @@ -137,10 +138,17 @@ runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => { section("7. Ungültige Ops, Scope, Mandantentrennung"); const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] }); ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid"); - // signature.capture is not registered in services/sync/external-ops.ts (reports lane uses server actions) - const unavailable = op("signature.capture", { reportId: "x" }); - const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); - ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, "nicht verfügbare Op → rejected, nicht gespeichert (später wiederholbar)"); + // An op type without server handler (neither field handler nor registered in services/sync/external-ops.ts) + // must be answered without being stored, so devices can retry after the owning lane is deployed. + const FIELD_OPS = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "note.create", "checklist.toggle", "material.upsert", "photo.attach", "voice.attach"]; + const unregistered = SYNC_OP_TYPES.find((t) => !FIELD_OPS.includes(t) && !EXTERNAL_OPS[t] && !CONFLICTING_OPS.includes(t)); + if (unregistered) { + const unavailable = op(unregistered, { reportId: "x" }); + const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); + ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, `nicht verfügbare Op ${unregistered} → rejected, nicht gespeichert (später wiederholbar)`); + } else { + console.log("• alle Sync-Op-Typen sind registriert — Prüfung „nicht verfügbare Op“ entfällt"); + } const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } }); const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] }); ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found"); diff --git a/scripts/test-security-http.ts b/scripts/test-security-http.ts new file mode 100644 index 0000000..e3d7f12 --- /dev/null +++ b/scripts/test-security-http.ts @@ -0,0 +1,383 @@ +// 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(); + } +});