diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 13c41a99..11d77ebe 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -4,6 +4,24 @@ import { generateContractNumber, paginate, buildPaginationResponse } from '../ut import { encrypt, decrypt } from '../utils/encryption.js'; import { sanitizeCustomerStrict } from '../utils/sanitize.js'; +// Pentest 51.3 (LOW, 2026-06-01): Telefon-/Vorwahl-Felder dürfen NIE CRLF +// oder andere Control-Chars enthalten – sonst könnten sie über Header- +// Injection (Mail, HTTP) missbraucht werden, wenn der Wert mal in einen +// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf +// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t` +// und macht den Schutz wirkungslos. Allowed: Ziffern, Plus, Minus, Slash, +// Klammern, Punkt, einfaches Leerzeichen. Bis 40 Zeichen. +const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/; +function sanitizePhoneField(raw: string | null | undefined, fieldLabel: string): string | undefined { + if (raw == null) return undefined; + const trimmed = String(raw).trim(); + if (trimmed === '') return undefined; + if (!PHONE_FIELD_ALLOWED.test(trimmed)) { + throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`); + } + return trimmed; +} + export interface ContractFilters { customerId?: number; customerIds?: number[]; // Für Kundenportal: eigene ID + vertretene Kunden @@ -345,8 +363,8 @@ export async function createContract(data: ContractCreateData) { phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0 ? { create: internetDetails.phoneNumbers.map((pn) => ({ - phoneNumber: pn.phoneNumber, - areaCode: pn.areaCode, + phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '', + areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'), isMain: pn.isMain ?? false, sipUsername: pn.sipUsername, sipPasswordEncrypted: pn.sipPassword @@ -543,8 +561,8 @@ export async function updateContract( return { internetContractDetailsId: existing.id, - phoneNumber: pn.phoneNumber, - areaCode: pn.areaCode, + phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '', + areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'), isMain: pn.isMain ?? false, sipUsername: pn.sipUsername, // Preserve existing sipPassword if no new value provided @@ -567,8 +585,8 @@ export async function updateContract( phoneNumbers: phoneNumbers ? { create: phoneNumbers.map((pn) => ({ - phoneNumber: pn.phoneNumber, - areaCode: pn.areaCode, + phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '', + areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'), isMain: pn.isMain ?? false, sipUsername: pn.sipUsername, sipPasswordEncrypted: pn.sipPassword diff --git a/backend/src/utils/ssrfGuard.ts b/backend/src/utils/ssrfGuard.ts index 0b587374..d7138bb9 100644 --- a/backend/src/utils/ssrfGuard.ts +++ b/backend/src/utils/ssrfGuard.ts @@ -23,8 +23,14 @@ const BLOCKED_PATTERNS: RegExp[] = [ /^23[0-9]\./, // 230-239 Multicast /^24[0-9]\./, // 240-249 reserved /^25[0-5]\./, // 250-255 reserved + // Pentest 51.2 (LOW, 2026-06-01): 100.64.0.0/10 CGNAT (RFC 6598) + // wird teils von Cloud-Providern für interne Pfade genutzt; 100.100.x.x + // ist konkret Alibaba Cloud Metadata. + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, /^fd00:ec2::/i, // AWS IPv6 Metadata /^fe80:/i, // IPv6 Link-Local + // Pentest 51.1: lang ausgeschriebene fe80-Form abdecken + /^fe80:0*:/i, /^ff/i, // IPv6 Multicast ]; @@ -40,12 +46,23 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [ /^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 + // IPv6 Loopback in allen Schreibweisen, die DNS/URL-Parser liefern können + /^::1$/, // kompakte Form + /^0:0:0:0:0:0:0:1$/, // voll ausgeschrieben + /^::ffff:127\./i, // IPv4-mapped Loopback (kompakt) + /^0:0:0:0:0:ffff:127\./i, // IPv4-mapped Loopback (ausgeschrieben) /^::ffff:10\./i, // IPv4-mapped 10/8 + /^0:0:0:0:0:ffff:10\./i, /^::ffff:192\.168\./i, // IPv4-mapped 192.168/16 + /^0:0:0:0:0:ffff:192\.168\./i, /^::ffff:172\.(1[6-9]|2\d|3[01])\./i, - /^f[cd]/i, // fc00::/7 Unique-Local + /^0:0:0:0:0:ffff:172\.(1[6-9]|2\d|3[01])\./i, + // Pentest 51.1 (MEDIUM, 2026-06-01): fc00::/7 deckt fc00..fdff ab + // (Unique-Local + Site-Local). Das alte `/^f[cd]/i` greift nur am + // Anfang einer einzelnen Hex-Stelle; lang ausgeschriebene Formen + // fingen wir nicht zuverlässig. Jetzt explizit auf das erste Group- + // Hex-Block-Prefix `fc` oder `fd` (gefolgt von 2 Hex + ':'). + /^f[cd][0-9a-f]{2}:/i, ]; const PRIVATE_HOSTNAMES = new Set([ @@ -59,6 +76,12 @@ const BLOCKED_HOSTNAMES = new Set([ 'metadata.goog', 'metadata', '169.254.169.254', + // Pentest 51.2 (LOW, 2026-06-01): Alibaba Cloud Metadata + '100.100.100.200', + // Vollständig ausgeschriebene IPv6-Loopback und gängige Cloud-Provider- + // Hostnamen, die DNS-Auflösung in geblockten Ranges liefern würden. + '0:0:0:0:0:0:0:1', + '[::1]', ]); export function isBlockedSsrfHost(host: string | null | undefined): boolean { @@ -142,7 +165,11 @@ export async function safeResolveHost( if (!host || !host.trim()) { throw new Error(`${label} fehlt`); } - const trimmed = host.trim(); + // URL.hostname liefert IPv6-Hosts mit eckigen Klammern (`[::1]`). + // Damit `net.isIP` und die Regex-Pattern korrekt matchen, hier strippen. + // Pentest 51.1 (2026-06-01): ohne dieses Stripping fiel `::1` durch + // ins DNS-Branch und die Block-Patterns liefen ins Leere. + const trimmed = host.trim().replace(/^\[|\]$/g, ''); const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost; // IP-Literal? Direkt prüfen, kein DNS nötig.