Files
craftvia/scripts/test-security-http.ts
T

384 lines
29 KiB
TypeScript

// 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<number> {
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<Res> {
const headers: Record<string, string> = {};
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<typeof call>[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, "<A-id>")}`, 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<typeof call>[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, "<id>")} → ${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<typeof call>[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("<html><script>alert(document.cookie)</script></html>");
const up = (bytes: Uint8Array, name: string, type: string, extra: Parameters<typeof call>[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("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"), "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();
}
});