diff --git a/backend/src/controllers/emailProvider.controller.ts b/backend/src/controllers/emailProvider.controller.ts index 925f6448..5d79a450 100644 --- a/backend/src/controllers/emailProvider.controller.ts +++ b/backend/src/controllers/emailProvider.controller.ts @@ -123,10 +123,15 @@ export async function testConnection(req: Request, res: Response): Promise // SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen // und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass // ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte. + // Pentest 50.1: strict=true – test-connection darf NIE auf private IPs, + // Loopback oder Cloud-Metadata zeigen, unabhängig von + // SSRF_BLOCK_PRIVATE_IPS. On-Prem mit echtem internen Plesk kann das + // per SSRF_ALLOW_INTERNAL_TESTING=true opt-outen (Default: blockiert). + const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true'; if (testData?.apiUrl) { try { const url = new URL(testData.apiUrl); - await safeResolveHost(url.hostname, 'apiUrl-Host'); + await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting }); } catch (err) { if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) { const ctx = contextFromRequest(req); @@ -247,12 +252,14 @@ export async function testMailAccess(req: Request, res: Response): Promise // geblockte IPs prüfen. Connection läuft danach gegen die IP, der // ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann // ein zweiter DNS-Lookup keine andere IP unterschieben. + // Pentest 50.1 analog testConnection: strict, opt-out via env. + const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true'; let smtpResolved: { ip: string; servername: string }; let imapResolved: { ip: string; servername: string }; try { [smtpResolved, imapResolved] = await Promise.all([ - safeResolveHost(smtpServer, 'SMTP-Server'), - safeResolveHost(imapServer, 'IMAP-Server'), + safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }), + safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }), ]); } catch (err) { const ctx = contextFromRequest(req); diff --git a/backend/src/services/emailProvider/emailProviderService.ts b/backend/src/services/emailProvider/emailProviderService.ts index 8cf8fc40..ac553f04 100644 --- a/backend/src/services/emailProvider/emailProviderService.ts +++ b/backend/src/services/emailProvider/emailProviderService.ts @@ -2,6 +2,22 @@ import prisma from '../../lib/prisma.js'; import { decrypt } from '../../utils/encryption.js'; +import { stripHtml } from '../../utils/sanitize.js'; + +// Pentest 48.1 (MEDIUM, 2026-06-01): customerEmailLabel landete roh in der +// DB und kam über /api/email-providers/public-settings 1:1 raus. React +// escapt zwar als Textnode, aber Defense-in-Depth verlangt Stripping schon +// beim Schreiben (PDF/Mail-Templates wären sofort betroffen). Zudem war +// das Längenlimit nur frontendseitig gesetzt – hier 60 Zeichen enforced. +const CUSTOMER_EMAIL_LABEL_MAX = 60; +function sanitizeCustomerEmailLabel(raw: unknown): string | null { + if (raw == null) return null; + if (typeof raw !== 'string') return null; + const stripped = stripHtml(raw) as string; + const trimmed = stripped.trim(); + if (trimmed === '') return null; + return trimmed.slice(0, CUSTOMER_EMAIL_LABEL_MAX); +} import { IEmailProvider, EmailProviderConfig, @@ -126,7 +142,7 @@ export async function createProviderConfig(data: CreateProviderConfigData) { allowSelfSignedCerts: data.allowSelfSignedCerts ?? false, systemEmailAddress: data.systemEmailAddress || null, systemEmailPasswordEncrypted, - customerEmailLabel: data.customerEmailLabel || null, + customerEmailLabel: sanitizeCustomerEmailLabel(data.customerEmailLabel), isActive: data.isActive ?? true, isDefault: data.isDefault ?? false, }, @@ -159,7 +175,7 @@ export async function updateProviderConfig( if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption; if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts; if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null; - if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = data.customerEmailLabel?.trim() || null; + if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = sanitizeCustomerEmailLabel(data.customerEmailLabel); if (data.isActive !== undefined) updateData.isActive = data.isActive; if (data.isDefault !== undefined) updateData.isDefault = data.isDefault; @@ -532,7 +548,10 @@ export async function getProviderPublicSettings(): Promise<{ }> { const config = await getActiveProviderConfig(); const domain = config?.domain ?? null; - const customLabel = config?.customerEmailLabel?.trim(); + // Read-Time-Defensive (Pentest 48.1): falls je rohe Alt-Daten in der DB + // landeten, hier nochmal durch den Sanitizer schicken. Stellt sicher, + // dass /public-settings nicht ungewollt XSS-Payloads rausreicht. + const customLabel = sanitizeCustomerEmailLabel(config?.customerEmailLabel) ?? null; return { domain, diff --git a/backend/src/utils/ssrfGuard.ts b/backend/src/utils/ssrfGuard.ts index 55f830e2..0b587374 100644 --- a/backend/src/utils/ssrfGuard.ts +++ b/backend/src/utils/ssrfGuard.ts @@ -106,10 +106,17 @@ export function isPrivateOrBlockedHost(host: string | null | undefined): boolean /** * Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist. * Caller sollte den Fehler in 400er Response umsetzen. + * + * `strict=true` (Pentest 50.1, 2026-06-01): private/Loopback-Ranges werden + * UNABHÄNGIG von `SSRF_BLOCK_PRIVATE_IPS` immer geblockt. Für Endpunkte mit + * besonders kritischer Angriffsfläche (test-connection, test-mail-access), + * die im Cloud-Deployment sonst Metadata-/Internal-Service-Probes erlauben + * würden. */ -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).`); +export function assertAllowedHost(host: string | null | undefined, label = 'Host', opts: { strict?: boolean } = {}): void { + const blocked = opts.strict ? isPrivateOrBlockedHost(host) : isBlockedSsrfHost(host); + if (blocked) { + throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved / privater Host).`); } } @@ -127,18 +134,29 @@ import net from 'net'; * * Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft. */ -export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> { +export async function safeResolveHost( + host: string | null | undefined, + label = 'Host', + opts: { strict?: boolean } = {}, +): Promise<{ ip: string; servername: string }> { if (!host || !host.trim()) { throw new Error(`${label} fehlt`); } const trimmed = host.trim(); + const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost; // IP-Literal? Direkt prüfen, kein DNS nötig. if (net.isIP(trimmed)) { - assertAllowedHost(trimmed, label); + assertAllowedHost(trimmed, label, opts); return { ip: trimmed, servername: trimmed }; } + // Pentest 50.1 Defense-in-Depth: bereits vor DNS prüfen, ob der + // Hostname selbst auf der Blocklist steht (z.B. "metadata", + // "metadata.google.internal", "localhost"). DNS könnte sonst je nach + // Resolver legitime IPs liefern und so die Hostname-Blocklist umgehen. + assertAllowedHost(trimmed, label, opts); + // Hostname → resolve to IPv4 + IPv6 let ips: string[] = []; try { @@ -155,7 +173,7 @@ export async function safeResolveHost(host: string | null | undefined, label = ' // Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung. for (const ip of ips) { - if (isBlockedSsrfHost(ip)) { + if (check(ip)) { throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`); } }