diff --git a/backend/src/controllers/gdpr.controller.ts b/backend/src/controllers/gdpr.controller.ts index f192a492..4d54abe2 100644 --- a/backend/src/controllers/gdpr.controller.ts +++ b/backend/src/controllers/gdpr.controller.ts @@ -891,6 +891,33 @@ 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) + const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF- + try { + const fd = fs.openSync(req.file.path, 'r'); + const head = Buffer.alloc(5); + fs.readSync(fd, head, 0, 5, 0); + fs.closeSync(fd); + if (!head.equals(PDF_MAGIC)) { + // Hochgeladene Nicht-PDF-Datei sofort wieder löschen, sonst + // bleibt der Müll im uploads/-Volume. + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + return res.status(400).json({ + success: false, + error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).', + }); + } + } catch (e) { + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + return res.status(400).json({ + success: false, + error: 'Hochgeladene Datei konnte nicht gelesen werden.', + }); + } + const documentPath = `/uploads/authorizations/${req.file.filename}`; const auth = await authorizationService.updateAuthorizationDocument( customerId, diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index 835d5a25..bc306449 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -81,3 +81,26 @@ export const passwordResetRateLimiter = rateLimit({ res.status(options.statusCode).json(options.message); }, }); + +/** + * Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind + * unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk, + * aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und + * den Service durch Audit-Log-Spam + Mail-Versand belasten. + * (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht + * für legitime Kunden weit aus. + */ +export const publicConsentRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 30, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: { + success: false, + error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.', + }, + handler: (req, res, _next, options) => { + onLimitReached('public-consent', 'MEDIUM')(req, res); + res.status(options.statusCode).json(options.message); + }, +}); diff --git a/backend/src/routes/consent-public.routes.ts b/backend/src/routes/consent-public.routes.ts index db225615..8f5322c8 100644 --- a/backend/src/routes/consent-public.routes.ts +++ b/backend/src/routes/consent-public.routes.ts @@ -1,9 +1,13 @@ import { Router } from 'express'; import * as controller from '../controllers/consent-public.controller.js'; +import { publicConsentRateLimiter } from '../middleware/rateLimit.js'; const router = Router(); -// Öffentliche Routes - KEINE Authentifizierung erforderlich +// Öffentliche Routes - KEINE Authentifizierung erforderlich. +// Rate-Limit gegen DoS – siehe publicConsentRateLimiter +// (Pentest 2026-05-20 INFO 28.4). +router.use(publicConsentRateLimiter); router.get('/:hash', controller.getConsentPage); router.post('/:hash/grant', controller.grantAllConsents); router.get('/:hash/pdf', controller.getConsentPdf); diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 1eec7e52..7818b66d 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -241,16 +241,42 @@ const USER_CREATE_FIELDS = [ * companyName landete vorher ungefiltert in der DB. * * Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata - * unschädlich gemacht (`javascript:`, `data:`, `vbscript:`). Plain-Text- - * Felder enthalten legitime URLs ohnehin selten; ein gespeicherter - * `javascript:alert(1)` würde ansonsten in einem `` - * sofort feuern. + * 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. */ -const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript)\s*:/gi; +const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript|file|ftp)\s*:/gi; + +function decodeHtmlEntities(s: string): string { + return s + // Numeric decimal: j → 'j' + .replace(/&#(\d+);?/g, (_m, code) => { + const n = parseInt(code, 10); + return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : ''; + }) + // Numeric hex: j → 'j' + .replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => { + const n = parseInt(code, 16); + return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : ''; + }) + // Häufige Named-Entities (bewusst klein gehalten – wir wollen nur + // XSS-relevante Bypässe verhindern, nicht jeden Entity-Sonderfall). + // & ZULETZT, sonst doppel-dekodiert (`&lt;` → `<`). + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/&#?apos;/gi, "'") + .replace(/&/gi, '&'); +} export function stripHtml(value: unknown): unknown { if (typeof value !== 'string') return value; - return value + return decodeHtmlEntities(value) .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<\/?[a-z][^>]*>/gi, '') diff --git a/docs/todo.md b/docs/todo.md index 44959397..953b3812 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -120,6 +120,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf. +- [x] **🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)** + - **28.1 URI-Schema unvollständig**: `DANGEROUS_URI_SCHEMES`-Regex + erweitert um `file:` und `ftp:`. `ftp://evil.com/x.js` und + `file:///etc/passwd` in companyName werden jetzt zu + `blocked://...` bzw. `blocked:///...` neutralisiert. + - **28.2 HTML-Entity-Decoding-Bypass**: `stripHtml` lief vorher + direkt über den Roh-String, sodass `javascript:`, + `<script>` und `<script>` an der Regex + vorbeischlüpften. Neuer `decodeHtmlEntities()` läuft VOR dem + Strip und dekodiert numerische (`&#NN;`/`&#xHH;`) + gängige + Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale + Tag- und URI-Säuberung wieder. + - **28.3 Vollmacht-Upload Magic-Byte-Check**: multer prüfte nur + den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als + `application/pdf` kam durch. Neuer Check liest die ersten 5 + Bytes nach dem Upload und verlangt `%PDF-` – sonst wird die + Datei gelöscht und 400 geliefert. Greift bevor irgendwas in + der DB landet. + - **28.4 Rate-Limit auf /api/public/consent**: 30 Requests pro + IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash + schon, aber ohne Limit konnte ein Angreifer das System per + POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer + `publicConsentRateLimiter` greift jetzt auf alle drei + Sub-Routes (`/:hash`, `/:hash/grant`, `/:hash/pdf`). + - **Live-verifiziert** auf dev: + - `ftp://evil.com/x.js` → `blocked://evil.com/x.js` + - `file:///etc/passwd` → `blocked:///etc/passwd` + - `javascript:alert(1)` → `blocked:alert(1)` + - `<script>alert(1)</script>OK` → `OK` + - `<script>bad()</script>Legit` → `Legit` + - HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht + - Echtes PDF (Magic-Bytes ok) → 200 + - 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429 + - [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix** - **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte `documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`