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:
@@ -78,7 +78,21 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
|||||||
return;
|
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('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
res.sendFile(absolute);
|
res.sendFile(absolute);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@ const BLOCKED_PATTERNS: RegExp[] = [
|
|||||||
/^ff/i, // IPv6 Multicast
|
/^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([
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
'metadata.google.internal',
|
'metadata.google.internal',
|
||||||
'metadata.goog',
|
'metadata.goog',
|
||||||
@@ -43,6 +69,12 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
|||||||
for (const pattern of BLOCKED_PATTERNS) {
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
if (pattern.test(h)) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
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)**
|
- [x] **🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)**
|
||||||
- **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig –
|
- **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig –
|
||||||
`blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst
|
`blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst
|
||||||
|
|||||||
Reference in New Issue
Block a user