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(/([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 (`<` → `<`).
+ .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(/