Files
craftvia/scripts/test-security-uploads.ts

128 lines
11 KiB
TypeScript

// 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<unknown>): Promise<string> {
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<Parameters<typeof storeFile>[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("<!doctype html><html><body><script>alert(document.cookie)</script></body></html>");
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
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"],
['a<b>c: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");
});