Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
/**
|
|
* Upload-Whitelist Suite
|
|
*
|
|
* Verifiziert das serverseitige Whitelist-Gate `assertAllowedUploadKind`:
|
|
* Nur ausdrücklich erlaubte Dateitypen (Belege: PDF/JPG/PNG/WebP/HEIC, Profilbilder:
|
|
* JPG/PNG/WebP) werden akzeptiert — entschieden wird ausschließlich über die
|
|
* Magic Bytes des Buffers. Der deklarierte MIME-Type ist vom Client steuerbar
|
|
* und darf NIE Zugriff gewähren. Kein beliebiger Dateityp darf die
|
|
* Verarbeitungspipeline erreichen.
|
|
*/
|
|
|
|
import { describe, test, expect } from "./runner";
|
|
import {
|
|
AVATAR_ALLOWED_KINDS,
|
|
RECEIPT_ALLOWED_KINDS,
|
|
UnsupportedFileTypeError,
|
|
assertAllowedUploadKind,
|
|
} from "../../src/lib/ingest/acceptedTypes";
|
|
|
|
function bufferFromHex(hex: string): Buffer {
|
|
return Buffer.from(hex.replace(/\s+/g, ""), "hex");
|
|
}
|
|
|
|
/* ----------------------- Echte Magic-Byte-Buffer (Inline) ------------------ */
|
|
|
|
// JPEG: FF D8 FF
|
|
const JPEG_BUFFER = bufferFromHex("FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 01 00 01 00 00");
|
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
const PNG_BUFFER = bufferFromHex(
|
|
"89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 00 00 00 01 00 00 00 01 08 06 00 00 00 1F 15 C4 89"
|
|
);
|
|
// WebP: RIFF....WEBP
|
|
const WEBP_BUFFER = Buffer.from("RIFF" + "\x10\x00\x00\x00" + "WEBPVP8 ", "latin1");
|
|
// PDF: %PDF- im Kopf
|
|
const PDF_BUFFER = Buffer.from(
|
|
"%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n1 0 obj\n<< /Type /Catalog >>\nendobj\n",
|
|
"latin1"
|
|
);
|
|
|
|
// Erkannte, aber für Belege NICHT erlaubte Typen:
|
|
const GIF_BUFFER = Buffer.from("GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00", "latin1");
|
|
const TIFF_BUFFER = bufferFromHex("49 49 2A 00 08 00 00 00 00 00 00 00");
|
|
const BMP_BUFFER = bufferFromHex("42 4D 36 00 00 00 00 00 00 00 36 00 00 00");
|
|
const AVIF_BUFFER = Buffer.from("\x00\x00\x00\x1Cftypavif\x00\x00\x00\x00", "latin1");
|
|
const HEIC_BUFFER = Buffer.from("\x00\x00\x00\x18ftypheic\x00\x00\x00\x00", "latin1");
|
|
|
|
// Keine (erkennbare) Signatur:
|
|
const TEXT_BUFFER = Buffer.from("Dies ist nur Textinhalt und keine Datei.", "utf8");
|
|
// MZ-Header (Windows-EXE) — von der Erkennung nicht als zulässiger Typ klassifiziert:
|
|
const EXE_BUFFER = bufferFromHex("4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00");
|
|
|
|
const RECEIPT_MESSAGE = "Dateityp nicht erlaubt. Erlaubt sind: PDF, JPG, PNG, WebP, HEIC.";
|
|
const AVATAR_MESSAGE = "Dateityp nicht erlaubt. Erlaubt sind: JPG, PNG, WebP.";
|
|
|
|
function captureError(fn: () => unknown): unknown {
|
|
try {
|
|
fn();
|
|
return null;
|
|
} catch (err) {
|
|
return err;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("UploadWhitelist — Beleg-Scans (PDF/JPG/PNG/WebP/HEIC)", () => {
|
|
test("JPEG-Magic-Bytes werden akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(JPEG_BUFFER, "image/jpeg", RECEIPT_ALLOWED_KINDS)).toBe(
|
|
"jpeg"
|
|
);
|
|
});
|
|
|
|
test("PNG-Magic-Bytes werden akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(PNG_BUFFER, "image/png", RECEIPT_ALLOWED_KINDS)).toBe("png");
|
|
});
|
|
|
|
test("WebP-Magic-Bytes werden akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(WEBP_BUFFER, "image/webp", RECEIPT_ALLOWED_KINDS)).toBe(
|
|
"webp"
|
|
);
|
|
});
|
|
|
|
test("PDF-Magic-Bytes werden akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(PDF_BUFFER, "application/pdf", RECEIPT_ALLOWED_KINDS)).toBe(
|
|
"pdf"
|
|
);
|
|
});
|
|
|
|
test("deklarierter MIME-Type ist optional (Magic Bytes allein entscheiden)", () => {
|
|
expect(assertAllowedUploadKind(JPEG_BUFFER, undefined, RECEIPT_ALLOWED_KINDS)).toBe("jpeg");
|
|
});
|
|
|
|
test("deklarierter MIME-Type kann eine echte Datei nicht ausbremsen", () => {
|
|
// Echte JPEG-Signatur, aber unplausibler/geloggener MIME-Type → Magic Bytes gewinnen.
|
|
expect(
|
|
assertAllowedUploadKind(JPEG_BUFFER, "application/octet-stream", RECEIPT_ALLOWED_KINDS)
|
|
).toBe("jpeg");
|
|
expect(
|
|
assertAllowedUploadKind(PDF_BUFFER, "text/plain", RECEIPT_ALLOWED_KINDS)
|
|
).toBe("pdf");
|
|
});
|
|
|
|
test("deklarierter MIME-Type kann eine echte Datei nicht auf einen anderen erlaubten Typ umbiegen", () => {
|
|
// JPEG-Signatur mit deklariertem application/pdf → weiterhin "jpeg", nicht "pdf".
|
|
expect(
|
|
assertAllowedUploadKind(JPEG_BUFFER, "application/pdf", RECEIPT_ALLOWED_KINDS)
|
|
).toBe("jpeg");
|
|
});
|
|
|
|
test("GIF wird abgelehnt (erkannt, aber nicht in der Beleg-Whitelist)", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(GIF_BUFFER, "image/gif", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("gif");
|
|
expect((err as UnsupportedFileTypeError).message).toBe(RECEIPT_MESSAGE);
|
|
});
|
|
|
|
test("HEIC-Magic-Bytes werden akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(HEIC_BUFFER, "image/heic", RECEIPT_ALLOWED_KINDS)).toBe("heic");
|
|
});
|
|
|
|
test("TIFF/BMP/AVIF werden abgelehnt (erkannt, aber nicht in der Beleg-Whitelist)", () => {
|
|
const cases: Array<[Buffer, string, string]> = [
|
|
[TIFF_BUFFER, "image/tiff", "tiff"],
|
|
[BMP_BUFFER, "image/bmp", "bmp"],
|
|
[AVIF_BUFFER, "image/avif", "avif"],
|
|
];
|
|
for (const [buffer, mime, kind] of cases) {
|
|
const err = captureError(() => assertAllowedUploadKind(buffer, mime, RECEIPT_ALLOWED_KINDS));
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe(kind);
|
|
expect((err as UnsupportedFileTypeError).message).toBe(RECEIPT_MESSAGE);
|
|
}
|
|
});
|
|
|
|
test("Text-Buffer ohne Signatur wird abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(TEXT_BUFFER, "text/plain", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("unknown");
|
|
expect((err as UnsupportedFileTypeError).message).toBe(RECEIPT_MESSAGE);
|
|
});
|
|
|
|
test("EXE-Buffer (MZ-Header) wird abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(EXE_BUFFER, "application/octet-stream", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("unknown");
|
|
});
|
|
|
|
test("gespoofte deklarierte MIME-Types gewähren nie Zugriff", () => {
|
|
// Text-Inhalt, deklariert als image/jpeg → abgelehnt.
|
|
const jpegSpoof = captureError(() =>
|
|
assertAllowedUploadKind(TEXT_BUFFER, "image/jpeg", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(jpegSpoof).toBeInstanceOf(UnsupportedFileTypeError);
|
|
|
|
// Text-Inhalt, deklariert als application/pdf → abgelehnt (kein Zugriff über
|
|
// den deklarierten Typ — nur die Magic Bytes entscheiden).
|
|
const pdfSpoof = captureError(() =>
|
|
assertAllowedUploadKind(TEXT_BUFFER, "application/pdf", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(pdfSpoof).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((pdfSpoof as UnsupportedFileTypeError).kind).toBe("unknown");
|
|
});
|
|
|
|
test("leerer Buffer wird abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(Buffer.alloc(0), "image/jpeg", RECEIPT_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
});
|
|
});
|
|
|
|
describe("UploadWhitelist — Profilbilder (JPG/PNG/WebP, kein PDF)", () => {
|
|
test("JPEG/PNG/WebP werden für Profilbilder akzeptiert", () => {
|
|
expect(assertAllowedUploadKind(JPEG_BUFFER, "image/jpeg", AVATAR_ALLOWED_KINDS)).toBe(
|
|
"jpeg"
|
|
);
|
|
expect(assertAllowedUploadKind(PNG_BUFFER, "image/png", AVATAR_ALLOWED_KINDS)).toBe("png");
|
|
expect(assertAllowedUploadKind(WEBP_BUFFER, "image/webp", AVATAR_ALLOWED_KINDS)).toBe(
|
|
"webp"
|
|
);
|
|
});
|
|
|
|
test("PDF wird für Profilbilder abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(PDF_BUFFER, "application/pdf", AVATAR_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("pdf");
|
|
expect((err as UnsupportedFileTypeError).message).toBe(AVATAR_MESSAGE);
|
|
});
|
|
|
|
test("GIF wird für Profilbilder abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(GIF_BUFFER, "image/gif", AVATAR_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("gif");
|
|
});
|
|
|
|
test("Text-Inhalt mit deklariertem image/png wird auch für Profilbilder abgelehnt", () => {
|
|
const err = captureError(() =>
|
|
assertAllowedUploadKind(TEXT_BUFFER, "image/png", AVATAR_ALLOWED_KINDS)
|
|
);
|
|
expect(err).toBeInstanceOf(UnsupportedFileTypeError);
|
|
expect((err as UnsupportedFileTypeError).kind).toBe("unknown");
|
|
});
|
|
});
|