Pentest 59.4 HIGH: IPv4-mapped IPv6 in SSRF-Guard blocken (alle Schreibweisen)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:16:19 +02:00
parent 6b1d493f0b
commit f4ac1c29db
3 changed files with 76 additions and 3 deletions
+9 -2
View File
@@ -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
+7 -1
View File
@@ -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<string> {
// 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<string> {
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
const raw = (fromSettings && fromSettings.trim())
|| process.env.PUBLIC_URL
+60
View File
@@ -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;
}