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:
@@ -4,6 +4,24 @@ import { generateContractNumber, paginate, buildPaginationResponse } from '../ut
|
|||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
import { sanitizeCustomerStrict } from '../utils/sanitize.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 {
|
export interface ContractFilters {
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
customerIds?: number[]; // Für Kundenportal: eigene ID + vertretene Kunden
|
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
|
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
|
||||||
? {
|
? {
|
||||||
create: internetDetails.phoneNumbers.map((pn) => ({
|
create: internetDetails.phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
areaCode: pn.areaCode,
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
@@ -543,8 +561,8 @@ export async function updateContract(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
internetContractDetailsId: existing.id,
|
internetContractDetailsId: existing.id,
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
areaCode: pn.areaCode,
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
// Preserve existing sipPassword if no new value provided
|
// Preserve existing sipPassword if no new value provided
|
||||||
@@ -567,8 +585,8 @@ export async function updateContract(
|
|||||||
phoneNumbers: phoneNumbers
|
phoneNumbers: phoneNumbers
|
||||||
? {
|
? {
|
||||||
create: phoneNumbers.map((pn) => ({
|
create: phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
areaCode: pn.areaCode,
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
|
|||||||
@@ -23,8 +23,14 @@ const BLOCKED_PATTERNS: RegExp[] = [
|
|||||||
/^23[0-9]\./, // 230-239 Multicast
|
/^23[0-9]\./, // 230-239 Multicast
|
||||||
/^24[0-9]\./, // 240-249 reserved
|
/^24[0-9]\./, // 240-249 reserved
|
||||||
/^25[0-5]\./, // 250-255 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
|
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
||||||
/^fe80:/i, // IPv6 Link-Local
|
/^fe80:/i, // IPv6 Link-Local
|
||||||
|
// Pentest 51.1: lang ausgeschriebene fe80-Form abdecken
|
||||||
|
/^fe80:0*:/i,
|
||||||
/^ff/i, // IPv6 Multicast
|
/^ff/i, // IPv6 Multicast
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,12 +46,23 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [
|
|||||||
/^10\./, // 10.0.0.0/8
|
/^10\./, // 10.0.0.0/8
|
||||||
/^192\.168\./, // 192.168.0.0/16
|
/^192\.168\./, // 192.168.0.0/16
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
||||||
/^::1$/, // IPv6 Loopback
|
// IPv6 Loopback in allen Schreibweisen, die DNS/URL-Parser liefern können
|
||||||
/^::ffff:127\./i, // IPv4-mapped Loopback
|
/^::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
|
/^::ffff:10\./i, // IPv4-mapped 10/8
|
||||||
|
/^0:0:0:0:0:ffff:10\./i,
|
||||||
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
|
/^::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,
|
/^::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([
|
const PRIVATE_HOSTNAMES = new Set([
|
||||||
@@ -59,6 +76,12 @@ const BLOCKED_HOSTNAMES = new Set([
|
|||||||
'metadata.goog',
|
'metadata.goog',
|
||||||
'metadata',
|
'metadata',
|
||||||
'169.254.169.254',
|
'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 {
|
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||||
@@ -142,7 +165,11 @@ export async function safeResolveHost(
|
|||||||
if (!host || !host.trim()) {
|
if (!host || !host.trim()) {
|
||||||
throw new Error(`${label} fehlt`);
|
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;
|
const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost;
|
||||||
|
|
||||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||||
|
|||||||
Reference in New Issue
Block a user