From 9cf8c505afecdbbe766bd40cc9e1ee743681ba38 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 20 May 2026 18:47:44 +0200 Subject: [PATCH] Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28.1 Restarbeit (URI-Schemata): DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:, ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht geblockt (legitime URLs in Notizfeldern). 29.1 Cyrillic-Homoglyph: "jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII- Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip angewendet. 29.2 Percent-Encoding: "java%73cript:" und "java%2573cript:" umgingen den Filter. percentDecode() laeuft jetzt iterativ bis zu 5 Runden. 29.3 Zero-Width-Joiner: "j​av​ascript:" mit U+200B/200C/200D etc. zerteilte die Regex- Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode- Steuerzeichen, bevor irgendwas anderes laeuft. 28.3 Partial (PDF-Validierung tiefer): Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB + Pattern-Scan der ersten 4 KB auf #!/, 404: parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt Number.isFinite(id) und liefert 404. Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten, Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format, /providers/email) – alle erwarteten Antworten. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/customer.controller.ts | 25 ++++ backend/src/controllers/gdpr.controller.ts | 61 +++++++-- .../src/controllers/provider.controller.ts | 15 ++- backend/src/controllers/user.controller.ts | 14 +- backend/src/utils/sanitize.ts | 126 +++++++++++++++--- docs/todo.md | 47 +++++++ 6 files changed, 257 insertions(+), 31 deletions(-) diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 21e0e8ed..b8c77773 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -11,6 +11,7 @@ import { sanitizeCustomerStrict, pickCustomerCreate, pickCustomerUpdate, + isValidEmail, } from '../utils/sanitize.js'; import { canAccessMeter, @@ -79,6 +80,16 @@ export async function createCustomer(req: Request, res: Response): Promise try { // Whitelist: nur erlaubte Felder aus req.body übernehmen const data: any = pickCustomerCreate(req.body); + // Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als + // SMTP-Header-Injection-Vektor in der DB (Pentest 29.4). + if (data.email && !isValidEmail(data.email)) { + res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); + return; + } + if (data.portalEmail && !isValidEmail(data.portalEmail)) { + res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); + return; + } // Convert birthDate string to Date if present if (data.birthDate) { data.birthDate = new Date(data.birthDate); @@ -110,6 +121,15 @@ export async function updateCustomer(req: Request, res: Response): Promise try { const customerId = parseInt(req.params.id); // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) + // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4) + if (req.body?.email && !isValidEmail(req.body.email)) { + res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); + return; + } + if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) { + res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); + return; + } const data: any = pickCustomerUpdate(req.body); // Vorherigen Stand laden für Audit @@ -937,6 +957,11 @@ export async function updatePortalSettings(req: Request, res: Response): Promise return; } const { portalEnabled, portalEmail } = body; + // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4) + if (portalEmail && !isValidEmail(portalEmail)) { + res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); + return; + } // Vorherigen Stand laden für Audit const before = await prisma.customer.findUnique({ diff --git a/backend/src/controllers/gdpr.controller.ts b/backend/src/controllers/gdpr.controller.ts index 4d54abe2..2b474df3 100644 --- a/backend/src/controllers/gdpr.controller.ts +++ b/backend/src/controllers/gdpr.controller.ts @@ -891,26 +891,71 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); } - // Magic-Byte-Check: multer prüft nur den client-gemeldeten MIME-Type, - // ein Angreifer kann beliebige Daten als "application/pdf" hochladen. - // PDF beginnt mit "%PDF-" (0x25 0x50 0x44 0x46 0x2D). Wenn nicht, - // gleich wegwerfen. (Pentest 2026-05-20 LOW 28.3) + // Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten + // MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf" + // hochladen. Wir verlangen: + // 1) Magic-Bytes "%PDF-" am Anfang + // 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende) + // 3) keinen Shebang ("#!") und kein " headStr.includes(m)); + if (hit) { + fs.closeSync(fd); + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + return res.status(400).json({ + success: false, + error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`, + }); + } + + // EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am + // Dateiende, aber viele Tools schreiben Whitespace/Newlines + // nach %%EOF, deshalb prüfen wir das letzte KB. + if (stat.size >= 5) { + const tailSize = Math.min(stat.size, 1024); + const tailBuf = Buffer.alloc(tailSize); + fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize); + if (!tailBuf.toString('latin1').includes('%%EOF')) { + fs.closeSync(fd); + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + return res.status(400).json({ + success: false, + error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).', + }); + } + } + fs.closeSync(fd); + } catch (_e) { try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } return res.status(400).json({ success: false, diff --git a/backend/src/controllers/provider.controller.ts b/backend/src/controllers/provider.controller.ts index fa40ab70..9f9da24d 100644 --- a/backend/src/controllers/provider.controller.ts +++ b/backend/src/controllers/provider.controller.ts @@ -18,7 +18,20 @@ export async function getProviders(req: Request, res: Response): Promise { export async function getProvider(req: Request, res: Response): Promise { try { - const provider = await providerService.getProviderById(parseInt(req.params.id)); + // `req.params.id` ist Pfad-Segment – bei /api/providers/email landet + // hier der String "email", den parseInt zu NaN macht. Ohne Validierung + // fuhr Prisma dann gegen `WHERE id = NaN` und warf 500. + // Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes + // wie /api/providers//tariffs greifen weiter wie gehabt. + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || id < 1) { + res.status(404).json({ + success: false, + error: 'Anbieter nicht gefunden', + } as ApiResponse); + return; + } + const provider = await providerService.getProviderById(id); if (!provider) { res.status(404).json({ success: false, diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 7d5dc50e..a6126df1 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -3,7 +3,7 @@ import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse } from '../types/index.js'; -import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js'; +import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Users @@ -53,6 +53,12 @@ export async function createUser(req: Request, res: Response): Promise { try { // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) const data = pickUserCreate(req.body) as any; + // Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB + // (Pentest 29.4 – SMTP-Header-Injection). + if (!isValidEmail(data?.email) || !data?.email) { + res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); + return; + } if (data?.password) { const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!c.ok) { @@ -108,6 +114,12 @@ export async function updateUser(req: Request, res: Response): Promise { } // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) const data = pickUserUpdate(req.body); + // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4). + // null/leer ist OK (Email darf optional sein), nur falsches Format prüfen. + if (data?.email !== undefined && !isValidEmail(data.email)) { + res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); + return; + } // Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess / // hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden. diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 7818b66d..3e382415 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -237,36 +237,67 @@ const USER_CREATE_FIELDS = [ * (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML * im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber * Defense-in-Depth speichert lieber gleich nichts Bösartiges. - * Pentest Runde 11 (2026-05-18), M2: in - * companyName landete vorher ungefiltert in der DB. * - * Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata - * unschädlich gemacht (`javascript:`, `data:`, `vbscript:`, `file:`, - * `ftp:`). Plain-Text-Felder enthalten legitime URLs ohnehin selten; - * ein gespeicherter `javascript:alert(1)` würde ansonsten in einem - * `` sofort feuern. `file:` + `ftp:` ergänzt nach - * Pentest 28.1 – kein direkter XSS, aber Data-Exfil/Lokalpfad-Vektor. - * - * Pentest 28.2: HTML-Entity-Decoding VOR dem Strip, sonst umgehen - * `javascript:` und `<script>` die Regex. + * Verlauf: + * - Pentest Runde 11 (2026-05-18):