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;
}
+29
View File
@@ -120,6 +120,35 @@ 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-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 prüft, konnte eine als
`application/pdf` deklarierte `.html`-Datei auf Disk landen
Express bestimmt beim Senden den Content-Type aus der Extension
(`.html``text/html`) und der Browser hätte gerendert. Stored
XSS für eingeloggte Empfänger. Fix: `Content-Disposition:
attachment; filename=<safe>` + bestehendes `X-Content-Type-
Options: nosniff`. Browser lädt jetzt herunter statt zu rendern,
selbst wenn der Type stimmt. Filename wird auf
`[A-Za-z0-9._-]` gesäubert.
- **30.14 SSRF Private-IP-Block opt-in** (INFO): Neuer Env-Flag
`SSRF_BLOCK_PRIVATE_IPS=true` erweitert die SSRF-Block-Liste auf
127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1,
fc00::/7 + IPv4-mapped Varianten + Hostnamen "localhost"/
"ip6-localhost". Default off, damit On-Prem-Installationen
(Plesk/Dovecot auf 127.0.0.1) nicht brechen. Cloud-Deployments
setzen den Flag.
- **Live-verifiziert** auf dev:
- Upload + Download: Header zeigt
`Content-Disposition: attachment; filename="…"` +
`X-Content-Type-Options: nosniff`
- Default-ssrfGuard: 127.0.0.1 / 10.x / 192.168.x / localhost → false
(durchgelassen für on-prem); 169.254.169.254 → true (Cloud-
Metadata weiter geblockt)
- Mit `SSRF_BLOCK_PRIVATE_IPS=true`: alle privaten Ranges → true;
8.8.8.8 (legit public) → false
- [x] **🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)**
- **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig
`blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst