diff --git a/backend/src/services/provider.service.ts b/backend/src/services/provider.service.ts index f06e2acc..1bf6c700 100644 --- a/backend/src/services/provider.service.ts +++ b/backend/src/services/provider.service.ts @@ -1,5 +1,5 @@ import prisma from '../lib/prisma.js'; -import { stripHtml, isValidEmail, sanitizePhoneField, sanitizeNotes } from '../utils/sanitize.js'; +import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js'; import { validateHttpUrl } from '../utils/url.js'; // Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl. @@ -84,10 +84,16 @@ function stripProviderStrings(data: T): T { const v = sanitizePhoneField(out[k], phoneLabels[k]); out[k] = v === undefined ? null : v; } + const addressLabels: Record = { + contactAddress: 'Kontakt-Postadresse', + cancellationAddress: 'Kündigungs-Postadresse', + }; for (const k of ['contactAddress', 'cancellationAddress'] as const) { if (out[k] === undefined) continue; - if (out[k] === '' || out[k] === null) { out[k] = null; continue; } - out[k] = sanitizeNotes(out[k], 500); + // R89.1/R89.2: validateProviderAddress wirft 400 bei Längen- + // Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate, + // kein silent null-overwrite mehr. + out[k] = validateProviderAddress(out[k], addressLabels[k]); } return out; } diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 43078c26..fb3bc681 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -396,6 +396,53 @@ export function validateContractIdentifier( return trimmed; } +// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am +// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes +// hat das Length-Cap silent durchgeschoben (slice statt Error) und +// stripHtml lief vor dem Length-Check – derselbe Fehler wie R87: +// `` reduziert auf leeren String → null in der +// DB → vorheriger Wert ohne Fehlermeldung überschrieben. +// +// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation. +// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80) +// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML) +// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc) +// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen) +// - CRLF → LF normalisieren +const PROVIDER_ADDRESS_MAX_LEN = 500; +// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) – +// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail +// und nichts, was ein Mensch je tippt. +const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/; + +export function validateProviderAddress( + raw: unknown, + fieldLabel: string, +): string | null { + if (raw == null) return null; + if (typeof raw !== 'string') { + throw new ApiError(400, `${fieldLabel} muss ein Text sein.`); + } + // CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der + // immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei + // Zeichen gegen das 500er-Cap zählen. + const normalized = raw.replace(/\r\n?/g, '\n'); + if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) { + throw new ApiError( + 400, + `${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`, + ); + } + if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) { + throw new ApiError( + 400, + `${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`, + ); + } + const trimmed = normalized.trim(); + return trimmed === '' ? null : trimmed; +} + const NOTES_DEFAULT_MAX = 2000; export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null { if (raw == null) return null; diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index 851f60b2..f0db7ce1 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -575,6 +575,45 @@ Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backe --- +## 🔒 Runde 89 – Provider-Adressfelder härten + +**Findings (R89.1 MEDIUM + R89.2 LOW):** + +Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden +`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)` +geleitet. Zwei Probleme: + +- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 – 501+ Zeichen + wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert. +- **R89.2**: stripHtml lief vor dem Length-Check – derselbe Bug wie R87. + `` → leerer String → `null` in der DB → vorheriger + Wert ohne Fehlermeldung überschrieben. + +**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in +[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts): + +- Validiert den Raw-Input direkt – kein stripHtml davor. +- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung. +- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` – erlaubt ist + nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle + anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor + für CSV-/Mail-Exporte und in einer Postadresse nie legitim. +- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor + mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt. +- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen). + +Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die +übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch +`isValidEmail` / `sanitizePhoneField` – die hat der Pentester explizit +als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt). + +**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`). +Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped +korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil +einer mehrzeiligen Postadresse. + +--- + ## 🧭 Wann ist „dicht" dicht? 100 % gibt es nicht. Erreicht ist: diff --git a/docs/todo.md b/docs/todo.md index 85be3c40..f31b8be3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Pentest R89 – Provider-Adressfelder härten** + - R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)` + statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit + 200 OK gespeichert. + - R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `` + reduzierte auf leeren String → `null` in der DB → vorheriger Wert + silent überschrieben (R87.1-Pattern auf Adress-Feldern). + - Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input, + max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control- + Chars außer `\n`. CRLF → LF normalisiert vor Length-Check. + Eingehängt in `stripProviderStrings`. + - R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester + bestätigt "kein unmittelbares Risiko", React escaped korrekt, + sind legitime Bestandteile mehrzeiliger Postadressen. + - Doku in `SECURITY-HARDENING.md § Runde 89`. + - [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten** - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`, `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,