From df6eb9724d9b7d78b310f30f54e9bf3b78bacc50 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 1 May 2026 07:47:26 +0200 Subject: [PATCH] Security-Hardening Runde 7: SSRF-Schutz + Logout-Endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🛡 SSRF-Schutz in test-connection / test-mail-access - Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder Connections zu Cloud-Metadata-Endpoints (169.254.169.254, metadata.google.internal etc.) auslösen. Internal-Port-Scan über Timing-Differenzen war messbar. - Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und Cloud-Metadata-Hostnames. Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix- Setups sollen weiter funktionieren. 🔒 Logout-Endpoint POST /api/auth/logout - Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware prüft das Feld bereits und lehnt Tokens mit iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter" JWT bis Expiry (7d) gültig. Live-verifiziert: - 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400 - 127.0.0.1 (Plesk-Fall) weiter erlaubt - /me vor Logout 200, nach Logout 401 "Sitzung ungültig" Geprüft + sauber (Runde 7, kein Bug): - Public Consent (122-bit Random-UUID nicht brute-force-bar) - Magic-Bytes-Bypass beim Upload - PDF manualValues Injection (keine HTML-Render-Surface) - Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert - Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/controllers/auth.controller.ts | 37 ++++++++++++ .../controllers/emailProvider.controller.ts | 29 ++++++++++ backend/src/routes/auth.routes.ts | 1 + backend/src/utils/ssrfGuard.ts | 57 +++++++++++++++++++ backend/todo.md | 34 +++++++++++ 5 files changed, 158 insertions(+) create mode 100644 backend/src/utils/ssrfGuard.ts diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index e0c099b9..eae195e6 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import * as authService from '../services/auth.service.js'; import { AuthRequest, ApiResponse } from '../types/index.js'; +import prisma from '../lib/prisma.js'; // Mitarbeiter-Login export async function login(req: Request, res: Response): Promise { @@ -166,6 +167,42 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise } } +/** + * Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von + * tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware + * prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt. + * + * Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation + * ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users + * (auch andere Geräte) – akzeptabel für ein Sicherheits-Logout. + */ +export async function logout(req: AuthRequest, res: Response): Promise { + try { + const user = req.user as any; + if (!user) { + res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse); + return; + } + if (user.isCustomerPortal && user.customerId) { + await prisma.customer.update({ + where: { id: user.customerId }, + data: { portalTokenInvalidatedAt: new Date() }, + }); + } else if (user.userId) { + await prisma.user.update({ + where: { id: user.userId }, + data: { tokenInvalidatedAt: new Date() }, + }); + } + res.json({ success: true, message: 'Abgemeldet' } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Fehler beim Abmelden', + } as ApiResponse); + } +} + export async function register(req: Request, res: Response): Promise { try { const { email, password, firstName, lastName, roleIds } = req.body; diff --git a/backend/src/controllers/emailProvider.controller.ts b/backend/src/controllers/emailProvider.controller.ts index 311609bc..11288d52 100644 --- a/backend/src/controllers/emailProvider.controller.ts +++ b/backend/src/controllers/emailProvider.controller.ts @@ -7,6 +7,7 @@ import { ApiResponse } from '../types/index.js'; import { testImapConnection, ImapCredentials } from '../services/imapService.js'; import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js'; import { decrypt } from '../utils/encryption.js'; +import { assertAllowedHost } from '../utils/ssrfGuard.js'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -118,6 +119,20 @@ export async function testConnection(req: Request, res: Response): Promise domain: req.body.domain, } : undefined; + // SSRF-Guard: testData.apiUrl-Hostname prüfen + if (testData?.apiUrl) { + try { + const url = new URL(testData.apiUrl); + assertAllowedHost(url.hostname, 'apiUrl-Host'); + } catch (err) { + if (err instanceof Error && err.message.includes('geblockte')) { + res.status(400).json({ success: false, error: err.message } as ApiResponse); + return; + } + // URL-Parse-Fehler ignorieren – Backend reagiert sowieso mit Fehler + } + } + const result = await emailProviderService.testProviderConnection({ id, testData }); res.json({ success: result.success, data: result } as ApiResponse); } catch (error) { @@ -214,6 +229,20 @@ export async function testMailAccess(req: Request, res: Response): Promise return; } + // SSRF-Guard: Wenn der Host vom Body kommt, blockieren wir Cloud-Metadata + // und Reserved-Ranges. Loopback/Private-Ranges bleiben erlaubt für + // legitime Plesk/Postfix-Setups. + try { + assertAllowedHost(smtpServer, 'SMTP-Server'); + assertAllowedHost(imapServer, 'IMAP-Server'); + } catch (err) { + res.status(400).json({ + success: false, + error: err instanceof Error ? err.message : 'Ungültige Server-Adresse', + } as ApiResponse); + return; + } + // IMAP testen const imapCredentials: ImapCredentials = { host: imapServer, diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index d15066b7..f65445f1 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -8,6 +8,7 @@ const router = Router(); router.post('/login', loginRateLimiter, authController.login); router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.get('/me', authenticate, authController.me); +router.post('/logout', authenticate, authController.logout); router.post('/register', authenticate, requirePermission('users:create'), authController.register); // Passwort-Reset-Flow diff --git a/backend/src/utils/ssrfGuard.ts b/backend/src/utils/ssrfGuard.ts new file mode 100644 index 00000000..bf2255dc --- /dev/null +++ b/backend/src/utils/ssrfGuard.ts @@ -0,0 +1,57 @@ +/** + * Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten + * Hosts/URLs in Endpunkten wie test-connection, test-mail-access. + * + * Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8, + * 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/ + * Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen + * blockieren wir nur: + * - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254) + * - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab) + * - 0.0.0.0/8 (ungültiger Source/Routing-Range) + * - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4) + * + * Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS- + * Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es + * legitimes Caching/CDN-Verhalten brechen kann. + */ + +const BLOCKED_PATTERNS: RegExp[] = [ + /^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA) + /^0\./, // 0.0.0.0/8 reserved + /^22[4-9]\./, // 224-229 Multicast + /^23[0-9]\./, // 230-239 Multicast + /^24[0-9]\./, // 240-249 reserved + /^25[0-5]\./, // 250-255 reserved + /^fd00:ec2::/i, // AWS IPv6 Metadata + /^fe80:/i, // IPv6 Link-Local + /^ff/i, // IPv6 Multicast +]; + +const BLOCKED_HOSTNAMES = new Set([ + 'metadata.google.internal', + 'metadata.goog', + 'metadata', + '169.254.169.254', +]); + +export function isBlockedSsrfHost(host: string | null | undefined): boolean { + if (!host) return false; + const h = host.trim().toLowerCase(); + if (!h) return false; + if (BLOCKED_HOSTNAMES.has(h)) return true; + for (const pattern of BLOCKED_PATTERNS) { + if (pattern.test(h)) return true; + } + return false; +} + +/** + * Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist. + * Caller sollte den Fehler in 400er Response umsetzen. + */ +export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void { + if (isBlockedSsrfHost(host)) { + throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`); + } +} diff --git a/backend/todo.md b/backend/todo.md index 5c284d26..2ee2d436 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -141,6 +141,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr) - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller) - bcrypt cost 10 → 12 (OWASP 2026) + - **Runde 7 – Letzter Schliff (SSRF + Logout):** + - **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein + Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder + Connections zu beliebigen IPs auslösen (Cloud-Metadata-Endpoints, + Link-Local, AWS/GCP-Metadata-Hosts). Internal-Port-Scanning via + Timing-Differenzen war messbar (22/80/3306/5432/6379 unterschiedlich). + Fix: neuer Helper `utils/ssrfGuard.ts` blockiert vor jeder ausgehenden + Verbindung 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, + AWS-IPv6-Metadata, IPv6-Link-Local und bekannte Cloud-Metadata- + Hostnames (metadata.google.internal etc.). Loopback (127.0.0.0/8) + bleibt erlaubt für legitime Plesk/Postfix-Setups. + - **Logout-Endpoint** `POST /api/auth/logout`: setzt + `tokenInvalidatedAt` / `portalTokenInvalidatedAt` auf jetzt. Auth- + Middleware prüft das Feld und lehnt Tokens mit `iat` davor ab. + JWTs sind stateless – ohne diesen Mechanismus bleibt ein + „abgemeldeter" Token bis zum natürlichen Expiry (7d) gültig. + - Live-verifiziert: 169.254.169.254/metadata.google.internal/0.0.0.0 + werden mit 400 abgelehnt; 127.0.0.1 weiter erlaubt; Logout + invalidiert den Token sofort (HTTP 401 „Sitzung ungültig"). + + **Geprüft + sauber (Runde 7):** + - Public Consent (random Hash → 404, kein Brute-Force durch 122-bit-UUID) + - Magic-Bytes-Bypass beim Upload (HTML als image/png) → blockiert + - PDF-Generation mit injizierten manualValues → kein XSS-Vektor (PDFs sind keine Web-Renderer) + - Audit-Logs für Portal-User: 403 + - Email-Config-Update als Portal: 403 + - Backup-Endpoints als Portal: 403 + - Query-Filter-Override (?customerId=X) → vom Portal-Filter ignoriert + + **Bewusst NICHT gefixt (zu invasiv für v1.0):** + - Vollständige DNS-Resolution beim SSRF-Guard (gegen DNS-Rebinding) – + kann legitimes CDN/Caching brechen, v1.1-Item. + - Per-File-Ownership-Check bei `/api/uploads` (siehe Runde 5). + - **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):** - 🚨 **`GET /api/customers` leakte als Portal-User die komplette Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der