Pentest R95: portalUsername (Manual-Modus) härten

R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor
R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation
R95.4 LOW: >190 Zeichen → VARCHAR-Overflow → 500 statt 400

Fix: validatePortalUsername() in sanitize.ts mit Whitelist
^[A-Za-z0-9_\-/.@+ ]{0,100}$. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → ApiError(400) → R95.4. Raw-Input vor stripHtml
geprüft (R87-Pattern). Eingehängt in sanitizeContractBody.

R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen:
portalUsername ist im Manual-Modus nicht zwingend eine Email
(Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder
Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md
§ Runde 95.

Frontend: maxLength={100} am Input als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:24:57 +02:00
parent f1102a24b7
commit 4ab0340473
5 changed files with 118 additions and 1 deletions
+40
View File
@@ -614,6 +614,46 @@ einer mehrzeiligen Postadresse.
---
## 🔒 Runde 95 Portal-Username-Validierung
**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**
`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
Validierung. Drei nachweisbare Effekte:
- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
gespeichert → Header-Injection-Vektor sobald der Wert in
Mail-Templates oder PDF-Footers landet.
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
`stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
auf neuem Feld).
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
statt sauberem 400.
**Fix:** `validatePortalUsername(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
→ R95.1 und R95.3 ohne extra Check.
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
- Raw-Input direkt validiert (kein `stripHtml` davor) gleicher
R87-Pattern wie bei Contract-Identifier und Provider-Address.
- Eingehängt in `sanitizeContractBody` als eigener Branch.
**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**
Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
Modus hängt sowieso an einer schon validierten Email-Stammdate
(`assertValidForwardingEmail`).
---
## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist:
+17
View File
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔒 Pentest R95 Portal-Username (Manual-Modus) härten**
- R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
sobald der Wert in Mail-Templates / PDF-Footer landet.
- R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
(R87.1-Pattern, dritter Treffer auf demselben Bug).
- R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
- Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
`^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
- Frontend: `maxLength={100}` am Input.
- **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
Feld ist im Manual-Modus nicht zwingend eine E-Mail Vodafone,
1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.
- [x] **🔒 Pentest R93 Leerer String != fehlender Param**
- R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.