Pentest R89: Provider-Adressfelder härten

R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).

Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.

R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 13:35:56 +02:00
parent 8b10316683
commit f02824fe7d
4 changed files with 111 additions and 3 deletions
+9 -3
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma.js'; 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'; import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl. // Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -84,10 +84,16 @@ function stripProviderStrings<T extends object>(data: T): T {
const v = sanitizePhoneField(out[k], phoneLabels[k]); const v = sanitizePhoneField(out[k], phoneLabels[k]);
out[k] = v === undefined ? null : v; out[k] = v === undefined ? null : v;
} }
const addressLabels: Record<string, string> = {
contactAddress: 'Kontakt-Postadresse',
cancellationAddress: 'Kündigungs-Postadresse',
};
for (const k of ['contactAddress', 'cancellationAddress'] as const) { for (const k of ['contactAddress', 'cancellationAddress'] as const) {
if (out[k] === undefined) continue; if (out[k] === undefined) continue;
if (out[k] === '' || out[k] === null) { out[k] = null; continue; } // R89.1/R89.2: validateProviderAddress wirft 400 bei Längen-
out[k] = sanitizeNotes(out[k], 500); // Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate,
// kein silent null-overwrite mehr.
out[k] = validateProviderAddress(out[k], addressLabels[k]);
} }
return out; return out;
} }
+47
View File
@@ -396,6 +396,53 @@ export function validateContractIdentifier(
return trimmed; 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:
// `<script>…</script>` 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; 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;
+39
View File
@@ -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.
`<script>…</script>` → 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? ## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist: 100 % gibt es nicht. Erreicht ist:
+16
View File
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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 `<script>…</script>`
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** - [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
- Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`, - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
`contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`, `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,