L10a Qualität & Abnahmetests: HTTP-Sicherheitstest (manipulierte IDs aller /api/v1-Routen, Datei-Routen, Uploads, CSRF, Sessions, Login-Sperre, Header) gegen next start
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { prisma } from "../src/server/db";
|
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 { applyOperations } from "../src/server/services/sync/apply";
|
||||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||||
import { getFieldBundle } from "../src/server/services/field/queries";
|
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");
|
section("7. Ungültige Ops, Scope, Mandantentrennung");
|
||||||
const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] });
|
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");
|
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)
|
// An op type without server handler (neither field handler nor registered in services/sync/external-ops.ts)
|
||||||
const unavailable = op("signature.capture", { reportId: "x" });
|
// 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] });
|
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)");
|
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 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" })] });
|
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");
|
ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found");
|
||||||
|
|||||||
@@ -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<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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user