From 1680dcb0fefba9f615b0bc55ba431aaa70265bf4 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 22 Jun 2026 00:45:59 +0200 Subject: [PATCH] Pentest R97: Attachment-Validierung im Send-Handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R97.1 LOW: malformed content (null, fehlend, true, "") landete mit rohem Buffer.from()-Fehlertext in der Response; "" liess sogar 0-Byte-Anhänge durch. R97.2 INFO: keine App-Level-Caps für Größe/Anzahl – die im Frontend dokumentierten 10/25 MB hingen am bodyParser. Fix: validateAttachments() läuft VOR sendEmail() im Controller: - max 25 Anhänge - filename non-empty String, content non-empty Base64, optionaler contentType als String - 10 MB pro Datei, 25 MB total (Größen-Schätzung über base64-Länge, kein Buffer.from während Validierung) Harte 400 mit klarer Meldung. Sanity-Test 18/18 grün. Co-Authored-By: Claude Opus 4.7 --- .../src/controllers/cachedEmail.controller.ts | 93 +++++++++++++++++++ docs/todo.md | 19 ++++ 2 files changed, 112 insertions(+) diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index ff6ca935..fb9e18df 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -392,6 +392,87 @@ function hasCRLF(value: unknown): boolean { return false; } +// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung +// vor `Buffer.from(...)` damit malformed Content (null, true, leerer +// String, falsches Base64) nicht erst tief im SMTP-Service kracht und +// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem +// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge), +// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt. +const MAX_ATTACHMENT_COUNT = 25; +const MAX_PER_FILE_BYTES = 10 * 1024 * 1024; +const MAX_TOTAL_BYTES = 25 * 1024 * 1024; +// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe). +const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +interface AttachmentValidationError { + status: 400; + error: string; +} + +function validateAttachments( + attachments: unknown, +): { ok: true } | AttachmentValidationError { + if (attachments === undefined) return { ok: true }; + if (!Array.isArray(attachments)) { + return { status: 400, error: 'attachments muss ein Array sein.' }; + } + if (attachments.length > MAX_ATTACHMENT_COUNT) { + return { + status: 400, + error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`, + }; + } + let totalBytes = 0; + for (let i = 0; i < attachments.length; i++) { + const a = attachments[i]; + const label = `Anhang ${i + 1}`; + if (!a || typeof a !== 'object') { + return { status: 400, error: `${label} hat das falsche Format.` }; + } + const filename = (a as Record).filename; + const content = (a as Record).content; + const contentType = (a as Record).contentType; + if (typeof filename !== 'string' || filename.trim() === '') { + return { status: 400, error: `${label} hat keinen Dateinamen.` }; + } + if (typeof content !== 'string' || content.length === 0) { + return { + status: 400, + error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`, + }; + } + if (!BASE64_RE.test(content)) { + return { + status: 400, + error: `Anhang "${filename}" ist kein gültiger Base64-String.`, + }; + } + if (contentType !== undefined && typeof contentType !== 'string') { + return { + status: 400, + error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`, + }; + } + // Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett + // dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier. + const approxBytes = Math.ceil(content.length * 0.75); + if (approxBytes > MAX_PER_FILE_BYTES) { + return { + status: 400, + error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`, + }; + } + totalBytes += approxBytes; + if (totalBytes > MAX_TOTAL_BYTES) { + return { + status: 400, + error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`, + }; + } + } + return { ok: true }; +} + // E-Mail senden export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise { try { @@ -408,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro return; } + // Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from() + // (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler + // in die Response und Limits waren nur Frontend-Doku. + const attachmentCheck = validateAttachments(attachments); + if (!('ok' in attachmentCheck)) { + res.status(attachmentCheck.status).json({ + success: false, + error: attachmentCheck.error, + } as ApiResponse); + return; + } + // StressfreiEmail laden const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId); diff --git a/docs/todo.md b/docs/todo.md index 9a80bcae..5a3dd090 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler** + - R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`) + erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der + Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang + durchgehen. + - R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im + Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am + bodyParser; falls der je hochgedreht wird, fällt die Sicherung. + - Fix: `validateAttachments()` im Controller `sendEmailFromAccount` + läuft **vor** dem `sendEmail`-Aufruf: + - `attachments` muss Array oder undefined sein + - max 25 Anhänge + - jeder: `filename` non-empty String, `content` non-empty Base64- + String (Regex), optional `contentType` String + - max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75, + kein Buffer.from-Aufruf während der Validierung) + - Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases + grün inkl. aller R97.1-Pentest-Payloads. + - [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen** - Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben "Datei anhängen":