Pentest 51.1/51.2/51.3: IPv6 SSRF, CGNAT/Alibaba, Phone-CRLF

51.1 MEDIUM (IPv6-Ranges nicht zuverlässig geblockt):
- URL.hostname liefert IPv6 mit eckigen Klammern ("[::1]") –
  safeResolveHost strippt sie jetzt am Eingang, sonst greift
  weder net.isIP noch das Regex-Matching.
- PRIVATE_IP_PATTERNS auf Hex-Group-Boundaries gehoben:
  /^f[cd][0-9a-f]{2}:/i deckt fc00..fdff zuverlässig ab statt
  nur "f[cd]" am String-Anfang.
- Ausgeschriebene IPv6-Formen (0:0:0:0:0:0:0:1, 0:0:0:0:0:ffff:10.x)
  als eigene Patterns ergänzt; "[::1]" + "0:0:0:0:0:0:0:1" auch
  als BLOCKED_HOSTNAMES.
- fe80: zusätzlich für lange Form (/^fe80:0*:/i).

51.2 LOW (CGNAT + Alibaba Metadata):
- 100.64.0.0/10 (RFC 6598 Carrier-Grade-NAT) → BLOCKED_PATTERNS
- 100.100.100.200 (Alibaba Cloud Metadata) → BLOCKED_HOSTNAMES

51.3 LOW (CRLF in phone-Feldern):
- sanitizePhoneField in contract.service.ts: Allowlist
  /^[0-9+\-/(). ]{0,40}$/ – Whitespace bewusst auf literales
  Space, NICHT \s, weil \s sonst \r\n\t matched und den
  Header-Injection-Schutz aufhebt. Eingesetzt auf phoneNumber
  und areaCode in beiden Create-Pfaden und im Update-Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:06:40 +02:00
parent c3321a2aa9
commit 71d3ea7a2e
2 changed files with 55 additions and 10 deletions
+24 -6
View File
@@ -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