Pentest 48.1 MEDIUM + 50.1 MEDIUM: customerEmailLabel-Strip + SSRF strict
48.1 (XSS in customerEmailLabel):
- Neuer sanitizeCustomerEmailLabel-Helper (stripHtml + trim +
60-Zeichen-Cap)
- Eingesetzt in createProviderConfig + updateProviderConfig
(Write-Pfad) und getProviderPublicSettings (Read-Defensive)
- Damit landet kein <script>/<img onerror>/<svg onload> mehr roh
in der DB, das Längen-Limit ist serverseitig erzwungen, und
Alt-Daten kommen über /public-settings ebenfalls gestrippt raus.
50.1 (SSRF, unvollständige Blockliste bei test-connection):
- safeResolveHost + assertAllowedHost akzeptieren jetzt
{ strict: boolean }. strict=true → isPrivateOrBlockedHost
(sperrt 127/8, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7
unabhängig von SSRF_BLOCK_PRIVATE_IPS).
- test-connection und test-mail-access nutzen strict=true per
Default. Opt-out via env SSRF_ALLOW_INTERNAL_TESTING=true
für On-Prem mit internem Plesk.
- Defense-in-Depth: assertAllowedHost wird jetzt auch VOR der
DNS-Resolution auf den Hostname selbst angewendet, damit
Block-Hostnames (z.B. "metadata.google.internal", "localhost")
nicht via custom-DNS umgangen werden können.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,10 +123,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
// 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
|
// 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.
|
// 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) {
|
if (testData?.apiUrl) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(testData.apiUrl);
|
const url = new URL(testData.apiUrl);
|
||||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||||
const ctx = contextFromRequest(req);
|
const ctx = contextFromRequest(req);
|
||||||
@@ -247,12 +252,14 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
// 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 smtpResolved: { ip: string; servername: string };
|
||||||
let imapResolved: { ip: string; servername: string };
|
let imapResolved: { ip: string; servername: string };
|
||||||
try {
|
try {
|
||||||
[smtpResolved, imapResolved] = await Promise.all([
|
[smtpResolved, imapResolved] = await Promise.all([
|
||||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }),
|
||||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }),
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const ctx = contextFromRequest(req);
|
const ctx = contextFromRequest(req);
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
import prisma from '../../lib/prisma.js';
|
import prisma from '../../lib/prisma.js';
|
||||||
import { decrypt } from '../../utils/encryption.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 {
|
import {
|
||||||
IEmailProvider,
|
IEmailProvider,
|
||||||
EmailProviderConfig,
|
EmailProviderConfig,
|
||||||
@@ -126,7 +142,7 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
|
|||||||
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
||||||
systemEmailAddress: data.systemEmailAddress || null,
|
systemEmailAddress: data.systemEmailAddress || null,
|
||||||
systemEmailPasswordEncrypted,
|
systemEmailPasswordEncrypted,
|
||||||
customerEmailLabel: data.customerEmailLabel || null,
|
customerEmailLabel: sanitizeCustomerEmailLabel(data.customerEmailLabel),
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
},
|
},
|
||||||
@@ -159,7 +175,7 @@ export async function updateProviderConfig(
|
|||||||
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
||||||
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
||||||
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
|
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.isActive !== undefined) updateData.isActive = data.isActive;
|
||||||
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
||||||
|
|
||||||
@@ -532,7 +548,10 @@ export async function getProviderPublicSettings(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const config = await getActiveProviderConfig();
|
const config = await getActiveProviderConfig();
|
||||||
const domain = config?.domain ?? null;
|
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 {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
@@ -106,10 +106,17 @@ export function isPrivateOrBlockedHost(host: string | null | undefined): boolean
|
|||||||
/**
|
/**
|
||||||
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
||||||
* Caller sollte den Fehler in 400er Response umsetzen.
|
* 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 {
|
export function assertAllowedHost(host: string | null | undefined, label = 'Host', opts: { strict?: boolean } = {}): void {
|
||||||
if (isBlockedSsrfHost(host)) {
|
const blocked = opts.strict ? isPrivateOrBlockedHost(host) : isBlockedSsrfHost(host);
|
||||||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
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.
|
* 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()) {
|
if (!host || !host.trim()) {
|
||||||
throw new Error(`${label} fehlt`);
|
throw new Error(`${label} fehlt`);
|
||||||
}
|
}
|
||||||
const trimmed = host.trim();
|
const trimmed = host.trim();
|
||||||
|
const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost;
|
||||||
|
|
||||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||||
if (net.isIP(trimmed)) {
|
if (net.isIP(trimmed)) {
|
||||||
assertAllowedHost(trimmed, label);
|
assertAllowedHost(trimmed, label, opts);
|
||||||
return { ip: trimmed, servername: trimmed };
|
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
|
// Hostname → resolve to IPv4 + IPv6
|
||||||
let ips: string[] = [];
|
let ips: string[] = [];
|
||||||
try {
|
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.
|
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
||||||
for (const ip of ips) {
|
for (const ip of ips) {
|
||||||
if (isBlockedSsrfHost(ip)) {
|
if (check(ip)) {
|
||||||
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user