From f4ac1c29dbcd5f7e9e625cf17287e77e119d6dee Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 1 Jun 2026 22:16:19 +0200 Subject: [PATCH] Pentest 59.4 HIGH: IPv4-mapped IPv6 in SSRF-Guard blocken (alle Schreibweisen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's URL-Parser normalisiert IPv4-mapped IPv6 von Dotted- in Hex-Form: `::ffff:127.0.0.1` → `::ffff:7f00:1`, `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS-Metadata!), `::ffff:10.0.0.1` → `::ffff:a00:1`. Die bisherigen Patterns (`::ffff:127\.` etc.) matchten nur die Dotted-Form. Sobald die URL durch `new URL()` lief, wurde der Host in Hex-Form herausgereicht und kam an der Blocklist vorbei – live verifiziert auf test-mail-access mit allen drei Payloads. Fix in ssrfGuard.ts: - Neuer extractMappedIPv4-Helper: erkennt Compact-Dotted, Compact-Hex, Expanded-Dotted, Expanded-Hex – konvertiert auf Dotted-IPv4. - Neuer checkIPv4-Helper: läuft die IPv4 durch BLOCKED_PATTERNS und (optional) PRIVATE_IP_PATTERNS, mit BLOCKED/PRIVATE_HOSTNAMES. - isBlockedSsrfHost + isPrivateOrBlockedHost rufen den IPv4-Check bei Mapped-IPv6 zusätzlich auf. Plain IPv4 und Hex-Form werden damit gleich behandelt. Verifiziert mit 15-Tests: ::ffff:7f00:1, ::ffff:a9fe:a9fe, 0:0:0:0:0:ffff:7f00:1 etc. werden alle geblockt; legitime IPs (8.8.8.8, ::ffff:8.8.8.8) bleiben durchlässig. Nebenbefund (Consent-URL = localhost): - getPublicUrl in auth.service jetzt EXPORT (vorher private). - gdpr.controller (sendConsentLink + send-privacy-link) nutzt jetzt getPublicUrl statt direkt PUBLIC_URL/origin/localhost- Kette. Damit greift die admin-konfigurierte AppSetting `portalLoginUrl` auch hier. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/controllers/gdpr.controller.ts | 11 +++- backend/src/services/auth.service.ts | 8 ++- backend/src/utils/ssrfGuard.ts | 60 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/gdpr.controller.ts b/backend/src/controllers/gdpr.controller.ts index 168f670a..3846760e 100644 --- a/backend/src/controllers/gdpr.controller.ts +++ b/backend/src/controllers/gdpr.controller.ts @@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js'; import * as consentService from '../services/consent.service.js'; import * as consentPublicService from '../services/consent-public.service.js'; import * as appSettingService from '../services/appSetting.service.js'; +import { getPublicUrl } from '../services/auth.service.js'; import { canAccessCustomer } from '../utils/accessControl.js'; import { createAuditLog, logChange } from '../services/audit.service.js'; import { ConsentType, DeletionRequestStatus } from '@prisma/client'; @@ -572,7 +573,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) { // ConsentHash sicherstellen const hash = await consentPublicService.ensureConsentHash(customerId); - const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173'; + // 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über + // getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting + // `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback. + const baseUrl = await getPublicUrl(); const consentUrl = `${baseUrl}/datenschutz/${hash}`; // Bei E-Mail: tatsächlich senden @@ -715,7 +719,10 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response) return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' }); } - const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173'; + // 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über + // getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting + // `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback. + const baseUrl = await getPublicUrl(); const portalUrl = `${baseUrl}/privacy`; // E-Mail senden diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index bc360271..27791642 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -593,7 +593,13 @@ function generateResetToken(): string { * Hat Trailing-Slash-Bereinigung, sonst kommen Links wie * `https://crm.de//portal/login` zustande. */ -async function getPublicUrl(): Promise { +// Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit +// `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und +// req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt +// EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.) +// dieselbe Quelle der Wahrheit nutzt – inklusive admin-konfigurierbarer +// portalLoginUrl App-Setting. +export async function getPublicUrl(): Promise { const fromSettings = await appSettingService.getSetting('portalLoginUrl'); const raw = (fromSettings && fromSettings.trim()) || process.env.PUBLIC_URL diff --git a/backend/src/utils/ssrfGuard.ts b/backend/src/utils/ssrfGuard.ts index d7138bb9..31f02884 100644 --- a/backend/src/utils/ssrfGuard.ts +++ b/backend/src/utils/ssrfGuard.ts @@ -84,6 +84,57 @@ const BLOCKED_HOSTNAMES = new Set([ '[::1]', ]); +/** + * Pentest 59.4 (HIGH, 2026-06-01): Node's URL-Parser normalisiert + * IPv4-mapped IPv6 zur **Hex-Form**: + * `::ffff:127.0.0.1` → `::ffff:7f00:1` + * `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS Metadata!) + * `::ffff:10.0.0.1` → `::ffff:a00:1` + * + * Die alten Patterns (`::ffff:127\.`, `::ffff:10\.` etc.) griffen nur + * auf die Dotted-Form – Angreifer konnten via URL-Brackets die Hex-Form + * an der Blocklist vorbeischleusen, weil `new URL()` umnormalisiert. + * + * Lösung: aus IPv4-mapped IPv6 extrahieren wir den IPv4-Anteil und + * lassen ihn durch die IPv4-Patterns laufen. Das deckt beide Schreib- + * weisen + ausgeschriebene Long-Form ab. + */ +function extractMappedIPv4(addr: string): string | null { + // Compact dotted: ::ffff:127.0.0.1 + let m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr); + if (m) return m[1]; + // Compact hex: ::ffff:7f00:1 + m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr); + if (m) { + const h1 = parseInt(m[1], 16); + const h2 = parseInt(m[2], 16); + if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null; + return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`; + } + // Expanded dotted: 0:0:0:0:0:ffff:127.0.0.1 + m = /^0:0:0:0:0:ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr); + if (m) return m[1]; + // Expanded hex: 0:0:0:0:0:ffff:7f00:1 + m = /^0:0:0:0:0:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr); + if (m) { + const h1 = parseInt(m[1], 16); + const h2 = parseInt(m[2], 16); + if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null; + return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`; + } + return null; +} + +function checkIPv4(ipv4: string, includePrivate: boolean): boolean { + if (BLOCKED_HOSTNAMES.has(ipv4)) return true; + for (const pattern of BLOCKED_PATTERNS) if (pattern.test(ipv4)) return true; + if (includePrivate) { + if (PRIVATE_HOSTNAMES.has(ipv4)) return true; + for (const pattern of PRIVATE_IP_PATTERNS) if (pattern.test(ipv4)) return true; + } + return false; +} + export function isBlockedSsrfHost(host: string | null | undefined): boolean { if (!host) return false; const h = host.trim().toLowerCase(); @@ -92,6 +143,10 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean { for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(h)) return true; } + // 59.4: IPv4-mapped IPv6 entpacken und die IPv4 separat prüfen – + // egal ob Hex- oder Dotted-Form, egal ob compact oder expanded. + const mappedV4 = extractMappedIPv4(h); + if (mappedV4 && checkIPv4(mappedV4, BLOCK_PRIVATE_IPS)) return true; if (BLOCK_PRIVATE_IPS) { if (PRIVATE_HOSTNAMES.has(h)) return true; for (const pattern of PRIVATE_IP_PATTERNS) { @@ -123,6 +178,11 @@ export function isPrivateOrBlockedHost(host: string | null | undefined): boolean for (const pattern of PRIVATE_IP_PATTERNS) { if (pattern.test(h)) return true; } + // 59.4: IPv4-mapped IPv6 strikt prüfen (Hex- + Dotted-Form, + // compact + expanded). Pentester konnte ::ffff:7f00:1 statt + // ::ffff:127.0.0.1 nutzen, weil URL-Parser umnormalisiert. + const mappedV4 = extractMappedIPv4(h); + if (mappedV4 && checkIPv4(mappedV4, true)) return true; return false; }