// L10a Sicherheitstest §43.4 / §27.4 „Schädliche Uploads" — Service-Ebene (alle Upload-Pfade laufen über // storeFile bzw. storeFieldUpload/createImport). // // Nachweise: EXE/ELF/ZIP/HTML/SVG mit harmloser Endung oder falschem Typ → abgewiesen; Polyglot-Dateien // (gültige Signatur + angehängter Fremdinhalt) werden nur mit dem ERKANNTEN Bildtyp gespeichert (nie // text/html) — Auslieferung als attachment + nosniff prüft test-security-http.ts; Übergröße je Art; // leere Datei; Pfad-Traversal/Steuerzeichen im Dateinamen → normalisiert, Speicher-Key bleibt im // Mandantenpräfix; Sichtbarkeits-Eskalation; Malware-Befund und nicht erreichbarer Scanner → fail closed, // nichts gespeichert; nichts davon landet in der Datenbank. // // Lauf: npx tsx scripts/test-security-uploads.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import { ServiceError, type ServiceCtx } from "../src/server/services/context"; import { storeFile, normalizeFileName } from "../src/server/services/documents/store"; import { ClamAvScanner, MagicByteScanner, type FileScanner } from "../src/server/services/documents/scanner"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { createImport } from "../src/server/services/imports/upload"; import { buildPdf } from "./make-sample-pdfs"; import { createTenant, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; const SLUG = "zz-q-sec-uploads"; const MB = 1024 * 1024; async function reason(fn: () => Promise): Promise { try { await fn(); return "stored"; } catch (err) { if (err instanceof ServiceError) return String((err.details as { reason?: string } | undefined)?.reason ?? err.code); return `error:${(err as Error).message}`; } } runSuite("Sicherheit: schädliche Uploads", [SLUG], async () => { const A = await createTenant(SLUG); const bo = A.ctx.backoffice; const wo = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "U-1", customerId: A.customerId, siteId: A.siteId, title: "Upload", status: "in_progress", assignedTeamId: A.teamId, assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }, }); const docCount = () => prisma.document.count({ where: { tenantId: A.tenantId } }); const store = (ctx: ServiceCtx, bytes: Uint8Array, fileName: string, declaredMime: string, extra: Partial[1]> = {}, scanner?: FileScanner) => storeFile(ctx, { bytes, fileName, declaredMime, category: "other", visibility: "team", links: { customerId: A.customerId }, ...extra }, scanner ? { scanner } : {}); const pdf = buildPdf([[{ text: "Harmlos" }]]); const jpeg = await jpegBytes("Upload"); const png = await pngBytes(); const exe = Buffer.concat([Buffer.from("MZ\x90\x00\x03\x00\x00\x00", "binary"), Buffer.alloc(512, 0x41)]); const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01]), Buffer.alloc(256)]); const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(256)]); const html = Buffer.from(""); const svg = Buffer.from(''); section("Falscher Typ hinter harmloser Endung"); const before = await docCount(); ok((await reason(() => store(bo, exe, "rechnung.pdf", "application/pdf"))) === "type_mismatch", "EXE als rechnung.pdf (application/pdf) → type_mismatch"); ok((await reason(() => store(bo, exe, "setup.exe", "application/x-msdownload"))) === "unsupported_type", "EXE mit ehrlichem Typ → unsupported_type"); ok((await reason(() => store(bo, elf, "foto.jpg", "image/jpeg"))) === "type_mismatch", "ELF-Binary als foto.jpg → type_mismatch"); ok((await reason(() => store(bo, zip, "plan.pdf", "application/pdf"))) === "type_mismatch", "ZIP als plan.pdf → type_mismatch"); ok((await reason(() => store(bo, html, "bild.jpg", "image/jpeg"))) === "type_mismatch", "HTML mit Skript als bild.jpg → type_mismatch"); ok((await reason(() => store(bo, html, "seite.html", "text/html"))) === "unsupported_type", "HTML mit ehrlichem Typ → unsupported_type"); ok((await reason(() => store(bo, svg, "logo.svg", "image/svg+xml"))) === "unsupported_type", "SVG mit Skript → unsupported_type"); ok((await reason(() => store(bo, png, "scan.pdf", "application/pdf"))) === "type_mismatch", "PNG als PDF deklariert → type_mismatch"); ok((await reason(() => store(bo, pdf, "foto.png", "image/png"))) === "type_mismatch", "PDF als PNG deklariert → type_mismatch"); ok((await docCount()) === before, "keine der abgewiesenen Dateien gespeichert"); section("Polyglot-Dateien"); const jpegHtml = Buffer.concat([jpeg, html]); const polyJ = await store(bo, jpegHtml, "polyglot.jpg", "image/jpeg"); ok(polyJ.mimeType === "image/jpeg" && polyJ.category === "other", "JPEG + angehängtes HTML: nur als image/jpeg gespeichert (Auslieferung als Anhang, nosniff)"); const polyP = await store(bo, Buffer.concat([pdf, zip]), "polyglot.pdf", "application/pdf"); ok(polyP.mimeType === "application/pdf", "PDF + angehängtes ZIP: nur als application/pdf gespeichert"); ok((await reason(() => store(bo, jpegHtml, "polyglot.html", "text/html"))) === "unsupported_type", "Polyglot mit deklariertem text/html → unsupported_type"); const polyScan = await new MagicByteScanner().scan({ bytes: jpegHtml, declaredMime: "image/jpeg", fileName: "x.jpg" }); ok(polyScan.ok && polyScan.detectedMime === "image/jpeg", "Magic-Byte-Scanner bestimmt den gespeicherten MIME-Typ (nicht der Client)"); section("Übergröße und leere Datei"); ok((await reason(() => store(bo, new Uint8Array(0), "leer.pdf", "application/pdf"))) === "empty_file", "leere Datei → empty_file"); ok((await reason(() => store(bo, Buffer.concat([jpeg, Buffer.alloc(15 * MB)]), "riesig.jpg", "image/jpeg"))) === "too_large", "Bild > 15 MB → too_large"); ok((await reason(() => store(bo, Buffer.concat([pdf, Buffer.alloc(25 * MB)]), "riesig.pdf", "application/pdf"))) === "too_large", "PDF > 25 MB → too_large"); const importBig = await reason(() => createImport(bo, { bytes: Buffer.concat([pdf, Buffer.alloc(25 * MB)]), fileName: "gross.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined })); ok(importBig !== "stored", `Import > 25 MB abgewiesen (${importBig})`); ok((await reason(() => createImport(bo, { bytes: exe, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }))) !== "stored", "Import: EXE als PDF abgewiesen"); section("Dateinamen: Pfad-Traversal, Steuerzeichen, Länge"); const names: [string, string][] = [ ["../../../etc/passwd.pdf", "passwd.pdf"], ["..\\..\\windows\\win.ini.pdf", "win.ini.pdf"], ["/absolute/path/plan.pdf", "plan.pdf"], ["rechnung\r\n.pdf", "rechnung.pdf"], ['ac:d"e|f?g*.pdf', "a_b_c_d_e_f_g_.pdf"], [".htaccess.pdf", "htaccess.pdf"], [" ", "datei"], ]; for (const [input, expected] of names) ok(normalizeFileName(input) === expected, `normalizeFileName(${JSON.stringify(input)}) → ${normalizeFileName(input)}`); ok(normalizeFileName(`${"x".repeat(400)}.pdf`).length <= 180 && normalizeFileName(`${"x".repeat(400)}.pdf`).endsWith(".pdf"), "überlange Namen gekürzt, Endung bleibt"); const traversal = await store(bo, pdf, "../../other-tenant/../../etc/passwd.pdf", "application/pdf"); ok(traversal.fileName === "passwd.pdf", "gespeicherter Dateiname ohne Pfadanteile"); ok(traversal.storageKey.startsWith(`${A.tenantId}/`) && !traversal.storageKey.includes(".."), `Speicher-Key im Mandantenpräfix ohne „..“ (${traversal.storageKey})`); const crlf = await store(bo, pdf, "bericht\r\nContent-Type: text/html.pdf", "application/pdf"); ok(!/[\r\n]/.test(crlf.fileName), "CR/LF im Dateinamen entfernt (kein Header-Splitting beim Download)"); section("Einsatz-Uploads (Fotos / Sprachnotizen)"); const tech = A.ctx.tech; ok((await reason(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: exe, name: "foto.jpg", type: "image/jpeg" }))) === "invalid", "EXE als Foto → invalid"); ok((await reason(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "voice_note" }, { bytes: jpeg, name: "notiz.webm", type: "audio/webm" }))) === "invalid", "Bild als Sprachnotiz → invalid"); ok((await reason(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: svg, name: "foto.jpg", type: "image/jpeg" }))) === "invalid", "SVG als Foto → invalid"); ok((await reason(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "text/html" }))) === "unsupported_type", "echtes Foto mit Client-Typ text/html → abgewiesen (deklarierter Typ muss erlaubt sein)"); const photo = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "../../x/foto.jpg", type: "" }); const photoDoc = await prisma.document.findUniqueOrThrow({ where: { id: photo.documentId } }); ok(photoDoc.mimeType === "image/jpeg" && photoDoc.fileName === "foto.jpg", "Foto ohne Client-Typ → Typ aus Magic Bytes (image/jpeg), Pfad im Namen entfernt"); section("Sichtbarkeit und Scanner"); ok((await reason(() => storeFile(tech, { bytes: pdf, fileName: "intern.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: wo.id } }))) === "visibility_not_allowed", "Monteur kann keine internen (backoffice_only) Dokumente anlegen"); ok((await reason(() => storeFile(tech, { bytes: pdf, fileName: "kunde.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { customerId: A.customerId } }))) === "forbidden", "Monteur kann keine Kundendokumente ohne Auftragsbezug anlegen"); const countBeforeScan = await docCount(); const infected: FileScanner = { name: "test-malware", scan: async () => ({ ok: false, reason: "malware", detail: "Eicar-Test-Signature FOUND" }) }; ok((await reason(() => store(bo, pdf, "eicar.pdf", "application/pdf", {}, infected))) === "malware", "Malware-Befund → abgewiesen"); const unreachable = new ClamAvScanner("127.0.0.1", 1, 1500); ok((await reason(() => store(bo, pdf, "scan.pdf", "application/pdf", {}, unreachable))) === "scanner_unavailable", "Virenscanner nicht erreichbar → fail closed (scanner_unavailable)"); ok((await docCount()) === countBeforeScan, "nichts gespeichert bei Malware/Scannerausfall"); const audited = new Set((await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "document", action: "create" }, select: { entityId: true } })).map((a) => a.entityId)); const storedIds = (await prisma.document.findMany({ where: { tenantId: A.tenantId }, select: { id: true } })).map((d) => d.id); ok(storedIds.every((id) => audited.has(id)) && audited.size === storedIds.length, "jede gespeicherte Datei auditiert, keine Audit-Anlage für abgewiesene Dateien"); });