Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)

30.13 MIME-Extension-XSS (MEDIUM):
GET /api/files/download lieferte hochgeladene Dateien via
res.sendFile() aus. Da multer nur den client-gemeldeten MIME prueft,
konnte eine als application/pdf deklarierte .html-Datei auf Disk
landen – Express liest beim Senden den Content-Type aus der Extension
(text/html), Browser haette gerendert → Stored XSS.

Fix: Content-Disposition: attachment + safe filename. Browser laedt
jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost
ist gering (PDF-Preview offnet halt aus dem Download-Ordner).
X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt.

30.14 SSRF Private-IP-Block opt-in (INFO):
ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil
On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer
Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag
SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle
privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/
ip6-localhost. Default off (on-prem-kompatibel).

Live-verifiziert auf dev:
- Download-Header: Content-Disposition: attachment + safe filename
- Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen,
  169.254.169.254 (Cloud-Metadata) weiter geblockt
- SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt,
  8.8.8.8 (legitim) durchgelassen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:14:59 +02:00
parent 9cf8c505af
commit a95aa384a2
3 changed files with 76 additions and 1 deletions
@@ -78,7 +78,21 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
return;
}
// Content-Type aus Extension bestimmen (konservativ Express macht das eh)
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
// durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
// nicht, wenn der Server selbst text/html liefert.
//
// Fix: alle Files via Content-Disposition: attachment ausliefern.
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
// Für legitime PDF/Bild-Vorschau ist das vertretbar Browser
// öffnen den Download dann eben aus dem Datei-Manager.
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.sendFile(absolute);
}
+32
View File
@@ -28,6 +28,32 @@ const BLOCKED_PATTERNS: RegExp[] = [
/^ff/i, // IPv6 Multicast
];
// Opt-in für Cloud-Deployments: ALLE privaten IP-Ranges blocken, nicht
// nur Cloud-Metadata. On-Prem-Default ist `false`, weil On-Prem-Setups
// häufig Plesk/Dovecot/Postfix auf 127.0.0.1 oder im internen Netz
// laufen lassen. (Pentest 2026-05-20 INFO 30.14.) Aktivieren mit
// `SSRF_BLOCK_PRIVATE_IPS=true` in der Umgebung.
const BLOCK_PRIVATE_IPS = (process.env.SSRF_BLOCK_PRIVATE_IPS || '').toLowerCase() === 'true';
const PRIVATE_IP_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8
/^192\.168\./, // 192.168.0.0/16
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
/^::1$/, // IPv6 Loopback
/^::ffff:127\./i, // IPv4-mapped Loopback
/^::ffff:10\./i, // IPv4-mapped 10/8
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
/^f[cd]/i, // fc00::/7 Unique-Local
];
const PRIVATE_HOSTNAMES = new Set([
'localhost',
'ip6-localhost',
'ip6-loopback',
]);
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
@@ -43,6 +69,12 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
if (BLOCK_PRIVATE_IPS) {
if (PRIVATE_HOSTNAMES.has(h)) return true;
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(h)) return true;
}
}
return false;
}