128 lines
11 KiB
TypeScript
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"],
|
|
["rech |