Pentest 60.3 MEDIUM: sanitizePhoneField auf Customer + User-Felder ausweiten

Der Fix aus 51.3 deckte nur Contract-PhoneNumber-Felder ab. CRLF in
`Customer.phone`, `Customer.mobile` und (im selben Code-Pfad)
`User.whatsappNumber`, `User.signalNumber` ging weiter durch –
pickCustomerUpdate / pickUserUpdate macht nur stripHtml, das filtert
keine Control-Chars.

- sanitizePhoneField von contract.service nach utils/sanitize gezogen
  und EXPORT, damit alle Stellen denselben Allowlist-Check
  (/^[0-9+\-/(). ]{0,40}$/) nutzen. Literales Space, NICHT \s.
- customer.controller updateCustomer + createCustomer: phone + mobile
  durch sanitizePhoneField → 400 bei CRLF/Control-Chars.
- user.controller updateUser + createUser: whatsappNumber +
  signalNumber analog.
- contract.service nutzt jetzt den importierten Helper (Lokale
  Kopie entfernt – Single Source of Truth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:40:40 +02:00
parent f4ac1c29db
commit 5fa9d4d4f3
4 changed files with 84 additions and 21 deletions
@@ -11,6 +11,7 @@ import {
sanitizeCustomerStrict, sanitizeCustomerStrict,
pickCustomerCreate, pickCustomerCreate,
pickCustomerUpdate, pickCustomerUpdate,
sanitizePhoneField,
isValidEmail, isValidEmail,
} from '../utils/sanitize.js'; } from '../utils/sanitize.js';
import { import {
@@ -90,6 +91,20 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return; 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 // Convert birthDate string to Date if present
if (data.birthDate) { if (data.birthDate) {
data.birthDate = new Date(data.birthDate); data.birthDate = new Date(data.birthDate);
@@ -132,6 +147,23 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
} }
const data: any = pickCustomerUpdate(req.body); 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 // Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ where: { id: customerId } }); const before = await prisma.customer.findUnique({ where: { id: customerId } });
+30 -2
View File
@@ -4,7 +4,7 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js'; import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.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'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users // Users
@@ -70,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise<void> {
return; 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); const user = await userService.createUser(data);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'User', req, action: 'CREATE', resourceType: 'User',
@@ -114,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
return; return;
} }
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body); const data = pickUserUpdate(req.body) as Record<string, unknown>;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4). // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen. // null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) { if (data?.email !== undefined && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return; 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 / // Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden. // hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
+1 -19
View File
@@ -2,25 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js'; import { encrypt, decrypt } from '../utils/encryption.js';
import { sanitizeCustomerStrict } from '../utils/sanitize.js'; import { sanitizeCustomerStrict, sanitizePhoneField } 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;
+21
View File
@@ -225,6 +225,27 @@ export function validateContractDocumentType(raw: unknown): string {
return canonical; 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; const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null { export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null; if (raw == null) return null;