diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index fb18a994..acb71381 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -11,6 +11,7 @@ import { sanitizeCustomerStrict, pickCustomerCreate, pickCustomerUpdate, + sanitizePhoneField, isValidEmail, } from '../utils/sanitize.js'; import { @@ -90,6 +91,20 @@ export async function createCustomer(req: Request, res: Response): Promise res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; } + // 60.3: Phone/Mobile auch beim Create gegen Header-Injection sichern. + try { + if ('phone' in data) { + const cleaned = sanitizePhoneField(data.phone, 'Telefon'); + data.phone = cleaned ?? null; + } + if ('mobile' in data) { + const cleaned = sanitizePhoneField(data.mobile, 'Mobil'); + data.mobile = cleaned ?? null; + } + } catch (err) { + res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse); + return; + } // Convert birthDate string to Date if present if (data.birthDate) { data.birthDate = new Date(data.birthDate); @@ -132,6 +147,23 @@ export async function updateCustomer(req: Request, res: Response): Promise } const data: any = pickCustomerUpdate(req.body); + // Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur + // stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile + // jetzt zusätzlich durch sanitizePhoneField (Allowlist). + try { + if ('phone' in data) { + const cleaned = sanitizePhoneField(data.phone, 'Telefon'); + data.phone = cleaned ?? null; + } + if ('mobile' in data) { + const cleaned = sanitizePhoneField(data.mobile, 'Mobil'); + data.mobile = cleaned ?? null; + } + } catch (err) { + res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse); + return; + } + // Vorherigen Stand laden für Audit const before = await prisma.customer.findUnique({ where: { id: customerId } }); diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 6a0490cb..504dca78 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -4,7 +4,7 @@ import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; -import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js'; +import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; // Users @@ -70,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise { return; } } + // 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern. + try { + if ('whatsappNumber' in data) { + const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer'); + data.whatsappNumber = cleaned ?? null; + } + if ('signalNumber' in data) { + const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer'); + data.signalNumber = cleaned ?? null; + } + } catch (err) { + res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); + return; + } const user = await userService.createUser(data); await logChange({ req, action: 'CREATE', resourceType: 'User', @@ -114,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise { return; } // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) - const data = pickUserUpdate(req.body); + const data = pickUserUpdate(req.body) as Record; // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4). // null/leer ist OK (Email darf optional sein), nur falsches Format prüfen. if (data?.email !== undefined && !isValidEmail(data.email)) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } + // 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern. + try { + if ('whatsappNumber' in data) { + const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer'); + data.whatsappNumber = cleaned ?? null; + } + if ('signalNumber' in data) { + const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer'); + data.signalNumber = cleaned ?? null; + } + } catch (err) { + res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); + return; + } // Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess / // hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden. diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 11d77ebe..2614458b 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -2,25 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client'; import prisma from '../lib/prisma.js'; import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; 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; -} +import { sanitizeCustomerStrict, sanitizePhoneField } from '../utils/sanitize.js'; export interface ContractFilters { customerId?: number; diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 0324bbec..a458b79f 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -225,6 +225,27 @@ export function validateContractDocumentType(raw: unknown): string { return canonical; } +// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder +// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie +// ein Header-Injection-Vektor (Mail, HTTP), 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`. +// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen. +// +// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` / +// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur +// stripHtml laufen ließ – das filtert keine Control-Chars. +const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/; +export function sanitizePhoneField(raw: unknown, 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; +} + const NOTES_DEFAULT_MAX = 2000; export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null { if (raw == null) return null;