c9f4fcf8de86703a7c89d6ee83ed76e8c1986e45
73 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f41d1843e4 |
fix: Portal-Passwörter im Vertrag wurden mutiliert
Folgefehler aus Pentest 31.1: die rekursive sanitizeContractBody()
lief auch über portalPassword. Passwörter mit HTML-Pattern
("Pass<TAG>word!" → "Password!") oder URI-Schema-Prefix
("data:secret" → "blocked:secret") wurden vom stripHtml-Strip
zerstört, bevor die Service-Schicht sie verschlüsseln konnte.
Fix: PASSTHROUGH_KEYS = {portalPassword, password}. Beim Walk
werden String-Werte unter diesen Keys NICHT gefiltert. Passwort
wird sowieso encrypt()-verschlüsselt in die DB geschrieben und
niemals als HTML ausgegeben – kein XSS-Risk.
Live-verifiziert:
- PUT portalPassword="MyP@ss<word>123!&data:foo"
→ GET /password decrypt liefert byte-identischen Wert
- PUT providerName="<script>...EvilProvider" → DB: "EvilProvider"
(XSS-Schutz weiter aktiv)
- PUT portalUsername="u<test>" → DB: "u" (Plain-Text-User wird
weiter gestrippt, ist kein Passwort)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
aa0900410b |
Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)
31.1 Stored XSS in Vertragsfeldern: providerName, tariffName, priceFirst12Months, priceFrom13Months, priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>, <svg/onload>, <img onerror>, javascript:, HTML-Entities) an und lieferten sie 1:1 an Portal-User zurueck. Fix: rekursiver sanitizeContractBody()-Walker im contract.controller, strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip + URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die sanitisierte Variante, sonst Audit ↔ DB-Drift. 31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere): requireCustomerAccess short-circuitete auf customers:read. Portal- User haben aber genau diese Perm im JWT (für eigene Daten) – damit kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/ Stressfrei-Emails von Kunde 3. Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle sechs requireCustomerAccess-Routes dicht. Defense-in-Depth: zusätzlicher canAccessCustomer-Call in stressfreiEmail.getEmailsByCustomer analog zum POST-Handler. Live-verifiziert auf dev: - Portal-User 1 → Customer 3: alle 6 Routes 403 - XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
897abc7b21 |
Datenschutzerklärung als unterschreibbare PDF-Vorlage
Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf generiert eine PDF mit: - Titel - Personalisiertem Kopf (Name / Firma + Kundennummer + Datum) - Voller Datenschutzerklärung (HTML → Text) - Einwilligungsklausel - Unterschriftenblock (Ort/Datum links, Unterschrift rechts, zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem Kundennamen) Auth: customers:read + canAccessCustomer. Filename: "datenschutzerklaerung-<kundennummer>.pdf". Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben" – Ausdrucken, unterschreiben lassen, scannen, wieder hochladen. Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende, 2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift- Linien. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
20d42c5270 |
Energie-Bonus aufgeteilt in Sofort + Neukunden
EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas- Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel + Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden müssen. Migration 20260524100000_split_energy_bonus: - ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus - bestehende `bonus`-Werte → instantBonus (Annahme: Sofort) - DROP COLUMN IF EXISTS bonus UI: - ContractForm zeigt zwei Input-Felder - Detail-Ansicht zeigt beide einzeln + Gesamtbonus - Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive Jahreskosten Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation liefert instantBonus, newCustomerBonus, totalBonus. PDF-Template: drei neue Variablen energyDetails.instantBonus, .newCustomerBonus, .totalBonus. Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert, GET liefert zurueck. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a95aa384a2 |
Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)
30.13 MIME-Extension-XSS (MEDIUM): GET /api/files/download lieferte hochgeladene Dateien via res.sendFile() aus. Da multer nur den client-gemeldeten MIME prueft, konnte eine als application/pdf deklarierte .html-Datei auf Disk landen – Express liest beim Senden den Content-Type aus der Extension (text/html), Browser haette gerendert → Stored XSS. Fix: Content-Disposition: attachment + safe filename. Browser laedt jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost ist gering (PDF-Preview offnet halt aus dem Download-Ordner). X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt. 30.14 SSRF Private-IP-Block opt-in (INFO): ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/ ip6-localhost. Default off (on-prem-kompatibel). Live-verifiziert auf dev: - Download-Header: Content-Disposition: attachment + safe filename - Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen, 169.254.169.254 (Cloud-Metadata) weiter geblockt - SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt, 8.8.8.8 (legitim) durchgelassen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9cf8c505af |
Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).
29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.
29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.
29.3 Zero-Width-Joiner:
"javascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.
28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).
29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.
29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.
Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
65ec07e274 |
Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
28.1 URI-Schema unvollstaendig: DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js" und "file:///etc/passwd" wurden vorher in companyName akzeptiert. 28.2 HTML-Entity-Decoding-Bypass: stripHtml() lief direkt ueber den Roh-String, "javascript:", "<script>" und "<script>" umgingen die Regex. decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) + gaengige named entities VOR dem Tag-/URI-Strip. 28.3 Vollmacht-Upload Magic-Byte-Check: multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als application/pdf durch. uploadAuthorizationDocument liest jetzt die ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400. 28.4 Rate-Limit auf /api/public/consent: 30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit- UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System mit Audit-Log- und Mail-Spam belasten. Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime Eingaben unangetastet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8e48d3b432 |
Pentest 2026-05-20 LOW/INFO Sammelfix
27.1 Path-Traversal-Strings in DB:
- cleanupConsents validierte documentPath zuvor nur per stripHtml,
ließ "../../../etc/passwd" durch. Neuer isValidDocumentPath-Check
akzeptiert nur "/uploads/<safe>", alles andere → NULL.
- cleanupDocumentPaths scannt fünf weitere Tabellen (BankCard,
IdentityDocument, Invoice, RepresentativeAuthorization nullable;
ContractDocument NOT NULL → nur Report).
Orphaned User:
- reportOrphanedUsers warnt beim Container-Start vor User ohne
Rollenzuordnung (im Permission-System unsichtbar). Löschen nicht
automatisch wegen False-Positive-Risiko.
Seed-PW-Policy:
- generateInitialPassword() nutzte Math.random() (vorhersagbar).
Jetzt crypto.randomInt() für Pick + Fisher-Yates-Shuffle.
PUT /users/:id mit permissions / password:
- Vorher silent-drop durch Whitelist + HTTP 200, Caller glaubte
faelschlich, Werte waeren uebernommen. Jetzt HTTP 400 mit
konkreter Hilfe-Message.
/api/health ohne Auth:
- Pentest-Befund INFO: bewusst so, Container-Healthcheck und
Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
{status,timestamp} – keine Version, kein DB-Status, kein
Info-Leak. Comment im Code dokumentiert die Entscheidung.
Live-verifiziert auf dev: alle fuenf Findings durchgetestet,
jeweils mit dirty Input → erwartete Sanitization/Antwort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
adc3b70492 |
Pentest 2026-05-20 MEDIUM+LOW Follow-ups
MEDIUM – Consent-Mass-Assignment:
PUT /api/gdpr/customer/:id/consents/:type nahm source/documentPath/
version ungefiltert aus dem Body. Portal-User konnte
source="ADMIN_OVERRIDE", version="<script>" oder
documentPath="../../etc/passwd" durchschmuggeln.
Fix: nur status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.
LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.
Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.
LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.
cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.
Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
bf7afdd9a6 |
Pentest KRITISCH: Backup-Restore braucht Confirm-Body
POST /api/settings/backup/:name/restore startete bei leerem Body
sofort den destruktiven Restore. Im Unterschied zu /factory-reset
fehlte der Magic-String-Confirm-Check, sodass ein versehentlicher
Re-Fire (Doppelklick, Browser-Tab-Replay, eingeloggter Admin auf
bösartiger Drittseite) die komplette DB stillschweigend
überschreiben konnte.
Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
{ "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Der
Magic-String ist absichtlich ein einzigartiges Token (kein Boolean),
damit kein Auto-JSON-Tooling/Replay aus Versehen triggern kann.
Frontend-API-Client setzt das Token im Body automatisch – der
existierende Bestätigungs-Dialog im UI bleibt UX-mäßig unverändert.
Live-verifiziert:
- leerer Body → 400
- { confirm: "ja" } → 400
- { confirm: "RESTORE-BESTAETIGT" } → 200, Restore läuft
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b3a6620da6 |
XSS-Sanitization für AppSettings (companyName & Co)
Pentest-Befund (MEDIUM): companyName und weitere Plain-Text-Setting- Keys nahmen via PUT /api/settings/:key XSS-Payloads wie <img src=x onerror=alert(1)> ungefiltert entgegen. Nur Admin triggerbar, aber E-Mail-Templates/PDF-Generatoren hätten den Wert unescaped rendern können. Fix in appSetting.service.ts: sanitizeSettingValue(key, value) strippt HTML außer für die expliziten Editor-Keys (imprintHtml, privacyPolicyHtml, authorizationTemplateHtml, websitePrivacyPolicyHtml). Greift in updateSetting + updateSettings. cleanup-xss-and-mass-assignment.ts bereinigt bestehende dreckige Werte beim Container-Start (idempotent). Live-verifiziert auf dev: - PUT companyName="<img onerror=alert(1)>OpenCRM<script>alert(2)</script>" → DB: "OpenCRM" - Bulk-PUT mit XSS auf companyName + defaultEmailDomain → gestrippt - imprintHtml mit "<h1>...<p>" → unverändert (HTML-allowed) - Cleanup-Skript auf dirty value: "EvilCo" statt mit Tags Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
37df8c0c4a |
Backup-Operations-Log + EBUSY-Fix beim Restore
Backup-Seite zeigt zwei neue Log-Panels: links Backup-Erstellung, rechts Backup-Wiederherstellung. Jeder Eintrag mit ✓/✗-Status, Summary, Timestamp + User. Klick öffnet Modal mit vollständigem Verlauf – alle console.log/error/warn/info-Zeilen werden während der Operation in einen Puffer mitgefangen und im fullLog-Feld persistiert. Auto-Refresh alle 5s. Persistenz: neue Tabelle BackupLog mit Migration 20260519100000_backup_log (CREATE TABLE IF NOT EXISTS für Re-Deploys auf DBs mit Vorab-db-push). fullLog auf 1 MB gecappt. Endpoints (settings:update): - GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50 - GET /api/settings/backup-logs/:id EBUSY-Fix: Der neue Log-Verlauf hat sofort einen alten Bug sichtbar gemacht. backup.service.restoreBackup rief deleteDirectory(UPLOADS_DIR) auf, dessen finales rmdirSync auf /app/uploads ein EBUSY warf – das Verzeichnis ist im Container ein Bind-Mount und lässt sich nicht aushängen. Fix: neuer Helper emptyDirectory() löscht nur die Inhalte, das Verzeichnis bleibt stehen. Live-verifiziert: 4867 Datensätze + 1 Datei in 13.2s wiederhergestellt; Log-Modal zeigt den vollständigen Verlauf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6ae815393e |
backup-restore: vollständiger Stack im Server-Log + lesbare UI-Details
Der globale ORM-Leak-Sanitizer ersetzt error/details, die TypeError/
"Cannot read properties of undefined" enthalten, durch "Operation
fehlgeschlagen". Das ist richtig für Auth-Endpoints, blockt aber bei
legitimen Admin-Operationen wie Restore die Diagnose-Info.
Backend (restoreBackup):
- console.error mit "[restore]"-Prefix loggt Backup-Name + vollen
Stack ins Server-Log. Per `docker logs opencrm-app | tail -200`
einsehbar.
- makeRestoreErrorReadable() strippt Stack-Frames, rephrased
bekannte JS-Runtime-Marker ("TypeError:" → "Code-Fehler:",
"Cannot read properties of undefined (reading 'x')" → "Wert
fehlt: x") + cuttet auf 500 Zeichen. Dadurch passiert die
Meldung den globalen Sanitizer und landet lesbar im Response.
- Response bekommt zusätzliches `hint`-Feld mit dem konkreten
docker-Befehl.
Frontend (DatabaseBackup):
- extractError liefert jetzt strukturiertes Objekt
{headline, details, hint} statt nur String.
- Dialog: Headline fett, details in Mono-Box, hint italic darunter.
- Toast: Headline + details zusammen, 10s sichtbar.
Live-verifiziert:
- Bad name → "Backup nicht gefunden" (klare Meldung)
- Echtes Backup → "4859 Datensätze wiederhergestellt" als Toast,
Dialog zu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
2cb6f172c9 |
Login-Rate-Limit pro (IP + Email)-Tupel + PUT /portal verbietet password
Login-Rate-Limit:
Bucket-Key jetzt `${ip}|${email-lowercase}`, ein Limiter (10/15min).
Vorher IP-only oder Email-only führten beide zu Problemen:
- IP-only: Proxy-Wechsel umgeht Sperre auf Account-Ebene
- Email-only: Familie hinter NAT (Max vertippt sich → Nina blockiert),
Account-Lockout-DoS möglich
- Tupel: Max gesperrt, Nina von gleicher IP weiterhin frei, Max von
anderer IP auch noch, eigener Account bleibt erreichbar.
Implementation:
- middleware/rateLimit.ts: keyGenerator → ip|email
- routes/auth.routes.ts: nur ein loginRateLimiter am /login + /customer-login
- controllers/rateLimitAdmin.controller.ts: Listing als (IP, Email)-
Tupel, Reset nimmt ipAddress + optional email. Audit-resourceId =
ip|email (gleich wie Bucket-Key) → Listing kann Reset herausfiltern.
- frontend/RateLimits.tsx: Tabelle mit IP- und Account-Spalte,
Reset-Button schickt beides.
PUT /customers/:id/portal:
Body-Felder password/portalPassword/portalPasswordHash/
portalPasswordEncrypted werden explizit mit 400 abgelehnt. Vorher
wurden sie silent ignoriert + HTTP 200, was den Client glauben ließ,
das PW sei gesetzt. Hinweis im Error-Body zeigt auf den dedizierten
POST /portal/password-Endpoint.
Live-verifiziert:
- 11x falsch max@x.de → 429
- Nina/Admin von gleicher IP → durch
- Reset (IP, max) → max wieder 401 statt 429
- PUT /portal {password:"abcd"} → 400 "Felder nicht erlaubt"
- PUT /portal ohne password → 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
373fab8e83 |
Security-Hardening Runde 16: KRITISCH – Update-Responses sanitisieren
Pentest Runde 15: 20.3 KRITISCH: PUT /customers/:id gab portalPasswordHash (bcrypt $2a$12$…) im Response zurück. updateCustomer reichte das rohe Service-Output ohne sanitize-Aufruf durch. 20.4 HOCH (gleiche Klasse): PUT-Response leakte portalPasswordResetToken, portalPasswordMustChange, consentHash, portalTokenInvalidatedAt. Fix: - updateCustomer + createCustomer rufen sanitizeCustomer bzw. sanitizeCustomerStrict je nach customers:update-Permission. - updateContract + createContract + createFollowUp + createRenewal analog mit sanitizeContract / sanitizeContractStrict je nach isCustomerPortal. - portalPasswordMustChange + portalTokenInvalidatedAt von PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS hochgezogen → greift auch in normaler sanitizeCustomer (Admin-Sicht). Live-verifiziert: - Admin PUT /customers/3 → 0 Leaks von Hash/Token/Expires/MustChange/ consentHash/TokenInvalidatedAt; portalPasswordEncrypted bleibt für Admin sichtbar (UI-Workflow, separater Endpoint mit Audit) - POST /customers → 0 Leaks - Portal-User GET /customers/3 → 0 Leaks auch bei portalPasswordEncrypted/notes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3e1fc3eab2 |
Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.
passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert
Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
(getPasswordResetAudience), User → 25, Customer → 12. Kein
Body-Hint, damit kein Downgrade-Trick möglich.
Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword
Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
sonst Log-Warnung + Random-Fallback
Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
zwei API-Calls (PUT + POST /users/:id/password) statt
Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu
Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
cf8c6c84c2 |
Security-Hardening Runde 15: Pentest Runde 12 Folge-Fixes
M2-Reste – XSS-Strings + Mass-Assignment-Settings noch in DB:
Idempotentes Cleanup-Script prisma/cleanup-xss-and-mass-assignment.ts.
Strippt HTML aus Customer/User-String-Feldern, entfernt AppSettings
ohne Whitelist-Eintrag. Wird im entrypoint.sh nach Migrations + Seed
einmalig pro Container-Start ausgeführt.
User-Update + password-Feld:
password aus USER_UPDATABLE_FIELDS raus (CREATE behält es), neuer
dedizierter Endpoint POST /api/users/:id/password mit Audit-Log
"Passwort … durch Admin gesetzt" und Komplexitäts-Check.
JS-Runtime-Fehler-Leak:
ORM_LEAK_PATTERNS um TypeError/ReferenceError/SyntaxError/RangeError +
"Cannot read properties of undefined/null" + "is not a function/
defined" erweitert. Greift im globalen res.json()-Wrapper.
POST /contracts substring-Crash:
Controller validiert type/customerId, sonst 400. generateContractNumber
fängt nullish type ab (Fallback "CON").
Seed-Admin-Passwort:
Default "admin" verletzte 12-Zeichen-Policy. Jetzt 16-char
Zufallspasswort (alle 4 Klassen garantiert via Fisher-Yates) oder per
SEED_ADMIN_PASSWORD-ENV überschreibbar. BCRYPT-Cost 12 (war 10).
Passwort wird einmalig in stdout ausgegeben mit Warnung.
AppSettings-Whitelist: companyName + defaultEmailDomain ergänzt
(kamen aus seed.ts, in 1. Whitelist vergessen).
Live-verifiziert:
- POST /contracts {} → 400 "Vertrags-Typ erforderlich" (vorher
TypeError-Stack)
- PUT /users/6 {password:"HackerPW2026!"} → 200 aber Login mit altem
PW geht weiter
- POST /users/6/password mit "kurz" → 400 mit Komplexitäts-Fehlern
- Cleanup-Script: planted XSS bereinigt, hackerSetting+debugMode
entfernt, idempotenter Re-Lauf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d545790a69 |
Security-Hardening Runde 14: Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip
Pentest Runde 11:
C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body die DB
plätten (3× in einer Pentest-Session passiert). Server erzwingt jetzt
confirm:"FACTORY-RESET-BESTAETIGT" als String. Frontend-API sendet
den Wert automatisch mit.
M1 – Settings Mass Assignment:
PUT /api/settings akzeptierte beliebige Keys (superAdminEmail,
debugMode, allowedOrigins). Neue Whitelist ALLOWED_SETTING_KEYS in
appSetting.service.ts; updateSetting + updateSettings prüfen jeden
Key, unbekannte → 400.
M3 – Prisma-Error-Leak:
Statt 30+ Controller einzeln zu fixen, globaler res.json()-Wrapper
unter /api: error/details-Strings werden durch Pattern-Filter
geschickt, der ORM-/Stack-Trace-Muster zu "Operation fehlgeschlagen"
ersetzt. Original bleibt im Server-Log.
M2 – Stored XSS in Customer/User-Strings:
Neuer stripHtml()-Helper. pickCustomerUpdate/Create + pickUserUpdate/
Create rufen ihn auf jeden String-Wert. Defense-in-Depth gegen PDF/
E-Mail-Template-XSS-Vektoren – React-Frontend ist eh auto-escaped.
Live-verifiziert:
- factory-reset {} / {confirm:true} / {confirm:false} → 400, DB ok
- PUT /settings {superAdminEmail,...} → 400 + Keys aufgezählt;
PUT /settings {customerSupportTicketsEnabled:"true"} → 200
- PUT /users/99999 → "Operation fehlgeschlagen" (vorher Prisma-Stack)
- PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} →
gespeichert als "EvilCorp"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ef238b0145 |
Security-Hardening Runde 13: Live-Vollmacht-Konsistenz + embedded DTOs
Pentest Runde 10: MEDIUM – Stale Token nach Vollmacht-Widerruf: Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer- Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live- Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter „kann vertreten". customerLogin und getCustomerPortalUser (= /me + Refresh) filtern representingFor jetzt zusätzlich über getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true landen im Token. MEDIUM – DTO-Leak in embedded Objekten: GET /customers/:id lieferte contracts[] mit commission/notes/ portalPasswordEncrypted/nextReviewDate; embedded customer in /contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt sanitizeContract(Strict) auf jedes Element von contracts[] auf; `notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen. LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403: Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert, die er nicht vertreten darf → 403. Live-verifiziert: - Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT representedCustomerIds=[], /me dito - Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter commission/notes; portalPasswordEncrypted generell weg - Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
28c91759df |
Security-Hardening Runde 12: Information-Disclosure + Input-Validation
Pentest Runde 7 (Anschlussrunde):
MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
passende Variante. Mitarbeiter sehen commission/notes weiterhin.
LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.
INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.
Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
portalPasswordEncrypted weg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c744eebfa3 |
Rate-Limit-Liste: bereits freigegebene IPs ausblenden
Die Liste basiert auf unveränderlichen SecurityEvents – ein Reset
leerte nur den In-Memory-Limiter, aber die historischen Events
blieben weitere 15 Min in der Anzeige stehen ("Freigeben klappt nicht").
Fix: für jede candidate-IP wird der letzte AuditLog-Eintrag
(resourceType=RateLimit) im 15-Min-Fenster geprüft. Liegt er nach dem
letzten Hit der IP, fliegt die IP aus der Liste – aber sobald wieder
ein RATE_LIMIT_HIT nach dem Reset kommt, taucht die IP wieder auf.
Live-verifiziert: trigger → 1 Eintrag; reset → 0 Einträge;
erneuter trigger → 1 Eintrag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
956bc394b8 |
Rate-Limit-Sperren: Admin-UI zum Freigeben
Bei zu vielen Login-Fehlversuchen war ohne Container-Restart kein Weg
zurück. Jetzt sehen Admins die aktiven Sperren und können einzeln
freigeben.
Backend:
- GET /api/settings/rate-limits/active (settings:read)
Liest SecurityEvent RATE_LIMIT_HIT der letzten 15 Min, gruppiert nach
IP, liefert lastEmail/limiters/hitCount/lastHit.
- POST /api/settings/rate-limits/reset (settings:update)
Body { ipAddress } → ruft loginRateLimiter.resetKey + passwordReset-
RateLimiter.resetKey auf (express-rate-limit v7), audited als
UPDATE auf resourceType=RateLimit.
Frontend:
- Neue Seite /settings/rate-limits: Tabelle mit IP/Email/Limiter/Hits/
Letzter-Hit/Aktion. Auto-Refresh alle 15s. Freigeben-Button pro IP.
- Kachel in Settings-Übersicht (orange, ShieldOff-Icon, settings:read).
Live-verifiziert: 11 failed Logins → 429 ab dem 11.; Liste zeigt
IP + Email; POST /reset → 200; danach wieder 401 statt 429; Audit-Log
„Rate-Limit für IP 127.0.0.1 manuell freigegeben" angelegt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
69b9a35674 |
Security-Hardening Runde 11: Pentest Runde 7 (Portal-PW + Download-Tokens)
Hit-List vom Pentester abgearbeitet. Hauptpunkte:
1) Contract/Mail-Credentials (password/internet/sip/simcard, mailbox/send/
reset-password): ALLE bereits durch canAccess* gesichert, keine Lücke.
2) GET /customers/:id/portal/password (Klartext-Portal-PW-Abruf):
fehlender canAccessCustomer-Check ergänzt. Defense in depth gegen
versehentliche customers:update-Permission an Portal/eingeschränkte
Mitarbeiter.
3) Admin-Endpoints (factory-reset, developer/*, audit-logs/rehash,
audit-logs/customer): durch admin-Permissions geschützt – Portal-User
haben diese nicht.
4) Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in
URLs für iframe-PDFs, Audit-Export-Window etc. → nginx-Logs +
Browser-History + Referer.
Lösung: kurzlebige Download-Tokens.
- signDownloadToken() liefert JWT mit type='download', exp=60s
- Auth-Middleware akzeptiert type='download' AUSSCHLIESSLICH via
?token=, niemals als Bearer-Header
- POST /api/auth/download-token Endpoint (authenticated)
- Frontend: authApi.getDownloadToken() utility
- 4 Stellen migriert: AuditLog-Export, PdfTemplate-Preview-iframe,
PdfTemplate-Generate, ContractDetail-PDF-Generate (2x),
Portal-Privacy-PDF
- fileUrl/getAttachmentUrl sind synchron + breit gestreut – Migration
bleibt für Folge-PR
Live-verifiziert:
- Download-Token: 1773 Zeichen, type=download, exp-iat=60s
- als Header → 401 (Falscher Token-Typ), als ?token= → 200
- portal-user (Customer 3) auf customers/2/portal/password → 403
Rate-Limiter-Check: express-rate-limit Fixed-Window, kein Reset bei jedem
Request (Pentester-Klage „Fenster reseted sich" stimmt mit dem Code nicht
überein – wahrscheinlich Retry-After-Misinterpretation). Kein Code-Bug
identifiziert; ggf. später Admin-Override-Endpoint nachrüsten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a982795388 |
Security-Hardening Runde 10: Pentest Runde 6 (8 Findings + struktureller Audit-Sweep)
KRITISCH: - emails/:id/thread bekommt canAccessCachedEmail - customers/:customerId/representatives/search bekommt canAccessCustomer (Buchstaben-Brute-Force konnte sonst die Kunden-DB enumerieren) HOCH: - birthdays/upcoming: Portal-User → 403 (Name/E-Mail/Telefon/Geb-Datum aller Kunden leakte) - contracts/:id/history (GET/POST/PUT/DELETE) bekommt canAccessContract - mailbox-accounts / unread-count / contracts/:id/emails/folder-counts bekommen canAccessCustomer bzw. canAccessContract - Vertreter-Vollmacht-Check ist jetzt live: neuer Helper getPortalAllowedCustomerIds() in accessControl.ts ruft hasAuthorization() für jedes vertretene Customer ab. Eingesetzt in getTasks/createSupportTicket/createCustomerReply/getAllTasks/ getTaskStats und updateCustomerConsent. Widerrufene Vollmachten haben jetzt SOFORT keinen Zugriff mehr (vorher: bis JWT abläuft). MITTEL: - confirmPasswordReset speichert portalPasswordEncrypted nicht mehr beim Self-Service-Reset (war nur für Admin-OTPs gedacht); + portalPasswordMustChange=false explizit - getCustomers pagination total reflektiert jetzt nur erlaubte IDs (über DB-Filter in customerService.getAllCustomers) Audit-Sweep (defense in depth, falls Rolle versehentlich Update- Permissions bekommt): - 16 cachedEmail-Operationen (markAsRead, toggleStar, assign/unassign, save-as-pdf/invoice/contract-document, save-to, attachment-targets, trash-ops) - 4 contract-Operationen (createFollowUp, createRenewal, snoozeContract, removeContractMeter) - 12 sub-CRUD-Operationen (address/bankcard/document/meter update+delete, meter-reading add/update/delete/transfer) - 2 representative-Operationen (add/remove) Live-verifiziert: Portal-Customer-3 auf alle fremden IDs → 403, Admin sieht alles, eigene Ressourcen weiterhin 200, Customer 1 mit widerrufener Vollmacht für Customer 3 → 0 fremde Verträge in der Response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
38c2d82c02 |
Security-Hardening Runde 9: Pentest Runde 5
KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check: Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer laden, portalPasswordMustChange=true erzwingen, sonst 403. NIEDRIG – consentHash leakte über GET /customers/🆔 Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer- Response gestrippt. Wer ihn legitim braucht, holt ihn über /gdpr/customer/:id/consent-status. NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records: POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert – Frontend liest eh nur success. Live-verifiziert: - Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false → erneuter Aufruf 403 - GET /customers/3 → consentHash null, portalPasswordHash null - /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar - Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
75c833500e |
Security-Hardening Runde 8: Cockpit-IDOR (Portal sah ALLE Kunden)
Pentest Runde 4 – HOCH:
GET /api/contracts/cockpit gab Portal-Usern mit contracts:read
die kompletten Vertrags-, Ausweis- und Zählerstand-Daten ALLER
Kunden zurück. Realer Angriff erfolgreich durchgespielt.
Fix:
contractCockpitService.getCockpitData({ customerIds? }) – wenn
gesetzt, werden ALLE internen Queries (Contract, CustomerConsent
GRANTED/WITHDRAWN, IdentityDocument-Expiry, MeterReading-Reported)
auf diese Customer-IDs eingeschränkt.
Controller getCockpit ermittelt customerIds analog getContracts:
- isCustomerPortal → [eigene, ...vertretene mit Vollmacht]
- sonst (Mitarbeiter/Admin) → undefined (alle Kunden)
Live-verifiziert:
- Admin: 17 Verträge über 3 Kunden (Baseline)
- Portal-User Customer 1: 12 Verträge, alle mit customerId=1
- Portal-User Customer 3: 3 Verträge, alle mit customerId=3
- 0 fremde Verträge in Portal-Responses
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a7d12b8540 |
Security-Hardening Runde 7: Pentest Runde 3 (3 Findings)
KRITISCH – Privilege Escalation: POST /api/developer/setup war ohne Auth erreichbar und konnte developer:access der Admin-Rolle hinzufügen → volle DB-Kontrolle via /developer/*-Routen. Endpoint ersatzlos entfernt; manuelles Setzen geht über prisma/add-developer-permission.ts (CLI). HOCH – Fehlende Migration auf Prod: portalPasswordMustChange war im Code, aber prod-DB hatte die Spalte nicht → jeder Kunden-Login warf Prisma-Schema-Error → DoS. Root Cause: db push statt migrate dev während Entwicklung → kein Migration-File im Repo. Fix: handgenerierte Migration 20260516173552_portal_password_must_change/migration.sql, lokal mit migrate resolve --applied registriert, durch shadow-DB-Reset verifiziert. entrypoint.sh führt migrate deploy bereits aus. MITTEL – Prisma-Internals-Leak im Login-Error: error.message wurde 1:1 an den Client gegeben → bei DB-Schema- Fehlern leakten Tabellen- und Spaltennamen. Whitelist-Filter safeLoginError() in auth.controller.ts: nur 'Ungültige Anmeldedaten' und 'E-Mail und Passwort erforderlich' werden durchgereicht, alles andere wird zu generischem 'Anmeldung fehlgeschlagen' maskiert. Original landet im Server-Log. Live-verifiziert: - POST /api/developer/setup → HTTP 404 - Falsches Customer-PW → 'Ungültige Anmeldedaten' (keine Internals) - Spalte testweise gedropped → 'Anmeldung fehlgeschlagen' (generisch), Original-Message nur im Server-Log - Shadow-DB-Reset + migrate deploy → Spalte korrekt erzeugt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8534be22d0 |
Einmalpasswort-Flow für Portal-Credentials
Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem Passwort kommt er ins Portal. Schema: - Customer.portalPasswordMustChange: Boolean @default(false) Backend: - sendPortalCredentials setzt Flag = true + erweitertes Mail-Template mit Einmalpasswort-Warnung - customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null, portalLastLogin aktualisiert), Response enthält mustChangePassword=true in token-payload + user-objekt - setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf - changeInitialPortalPassword: neue Service-Funktion + Endpoint POST /api/auth/change-initial-portal-password (authenticated, nur Portal-User), validiert Komplexität, setzt neuen Hash, löscht Encrypted, invalidiert Session via portalTokenInvalidatedAt Frontend: - User-Type erweitert um mustChangePassword - AuthContext.customerLogin gibt User zurück (für sofortige Routing- Entscheidung) - Login.tsx: redirect zu /change-initial-password wenn mustChangePassword - ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite - ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff - ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint, Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg Live-verifiziert (10 Schritte): - Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400, komplex=200 → neues Passwort funktioniert → Session invalidated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8a5ffbb563 |
Passwort-Komplexität + Portal-Credentials-UX
validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen) zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword, confirmPasswordReset, register, createUser, updateUser. Neue Endpoints: - POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort - POST /customers/:id/portal/send-credentials → Versand per Mail (nur wenn portalEnabled aktiv) Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe, alert() durch Toast-Notifications ersetzt. Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes Passwort → 200, Generator liefert 16-Zeichen-Passwort. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
08310ac302 |
security: CRITICAL IDOR-Fix auf Stressfrei-Email-Sub-Routes
Pentest hat einen echten Credential-Exfiltration-Angriff erfolgreich
durchgespielt: als Portal-User von Kunde A komplette Klartext-IMAP/SMTP-
Zugangsdaten der Mailbox von Kunde B abgreifbar.
Root Cause: GET /api/stressfrei-emails/:id hatte canAccessStressfreiEmail-
Check, ALLE 8 Sub-Endpoints unter :id/* hatten nur `authenticate +
requirePermission('customers:read')` — was jeder Portal-User de facto hat.
Betroffene Controller (alle gefixt mit canAccessStressfreiEmail als erster
Zeile):
stressfreiEmail.controller.ts:
- updateEmail (PUT /:id)
- deleteEmail (DELETE /:id)
- resetPassword (POST /:id/reset-password)
cachedEmail.controller.ts:
- getMailboxCredentials (GET /:id/credentials) ← KRITISCHSTER, lieferte
Klartext-IMAP/SMTP-Passwort + Server-Daten der fremden Mailbox
- getFolderCounts (GET /:id/folder-counts)
- syncAccount (POST /:id/sync)
- sendEmailFromAccount (POST /:id/send) — fremde Mailbox zum Versand
missbrauchbar
- enableMailbox (POST /:id/enable-mailbox)
- syncMailboxStatus (POST /:id/sync-mailbox-status)
Security-Monitor: canAccessResourceByCustomerId emittiert bei jedem
Fehlversuch ein ACCESS_DENIED MEDIUM-Event. Threshold-Detection erzeugt
bei >5 Versuchen in 5 min ein CRITICAL SUSPICIOUS-Event + Sofort-Alert.
Live-verifiziert (Portal-User Kunde A versucht Email-ID von Kunde B):
- alle 8 Sub-Routes → HTTP 403
- eigene Email-ID → 200/400 (Ownership-Check OK)
- 8× ACCESS_DENIED MEDIUM im Security-Monitor
Doku in docs/SECURITY-HARDENING.md als Runde 13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9830ac29a5 |
security: JWT raus aus localStorage – Refresh-Cookie-Pattern für SPA
Behebt das Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS war
der Token JS-erreichbar → Angreifer könnte alle Anbieter-Credentials
abrufen. Branchenstandard-Lösung für SPAs jetzt umgesetzt.
Architektur:
- Access-Token: 15 min Lifetime, lebt NUR im JavaScript-Memory
(api.ts tokenStore + AuthContext). Kein localStorage mehr.
- Refresh-Token: 7 Tage, im httpOnly-Cookie (Secure bei HTTPS_ENABLED,
SameSite=Strict, Path=/api/auth). JavaScript hat keinen Zugriff →
XSS klaut max. einen 15-min-Access-Token.
Backend:
- signAccessToken/signRefreshToken mit `type`-Claim
- Auth-Middleware verweigert Tokens mit type=refresh
- POST /api/auth/login + /customer-login: setzt refresh_token-Cookie,
gibt access-Token im Body
- POST /api/auth/refresh: liest Cookie, rotiert ihn, gibt neuen Access
aus. Prüft tokenInvalidatedAt (Logout/Rollenänderung = sofortige
Invalidation auch des Refresh-Tokens)
- POST /api/auth/logout: löscht Cookie + setzt tokenInvalidatedAt
- cookie-parser als neue Dependency
Frontend:
- api.ts: in-memory tokenStore (kein localStorage); withCredentials=true
für Cookie-Roundtrip; axios-Response-Interceptor mit
Single-Flight-Refresh-Retry bei 401 (Original-Request wird
transparent retried mit neuem Token)
- AuthContext: beim App-Start /auth/refresh aufrufen → wenn Cookie
noch gültig, ist der User automatisch eingeloggt. Tab-Reload
funktioniert weiterhin obwohl Access-Token nur in memory ist.
- 9 alte `localStorage.getItem('token')`-Stellen migriert auf
`getAccessToken()` (PDF-Preview-iframe, Audit-Log-CSV-Export,
DB-Backup-Download, File-Download-URLs, Portal-PDF-Link)
Live verifiziert:
- Login setzt Cookie (httpOnly, SameSite=Strict, Path=/api/auth) + Bearer
- API mit Bearer: 200; ohne: 401
- Refresh mit Cookie: rotiert sauber + neuer Access-Token im Body
- Refresh-Token als Bearer abgewiesen: 401 ("Falscher Token-Typ")
- Logout: Cookie gelöscht, danach /refresh → 401
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0943f11999 |
security: Audit-Log für alle Klartext-Passwort-Reads (CRITICAL)
Pentest-Finding "Klartext-Passwörter über API abrufbar (HIGH, post-auth)" adressiert: reversible Verschlüsselung der Anbieter-/Portal-Logins ist by-design (Feature "Login anzeigen" braucht sie zwingend), aber jeder einzelne Decrypt-Vorgang muss im Audit-Log nachvollziehbar sein. Bisher schrieb KEINER der 6 betroffenen Endpoints einen Eintrag. Behoben in: - getPortalPassword (Customer-Portal-Login) - getContractPassword (Anbieter-Login z.B. Vattenfall, EWE, …) - getSimCardCredentials (PIN/PUK) - getInternetCredentials (DSL-Login) - getSipCredentials (Telefon-/VoIP-Login) - getMailboxCredentials (Stressfrei-IMAP/SMTP) Alle nutzen `action: 'READ'` mit eigenem ResourceType + Sensitivity CRITICAL via determineSensitivity-Map. Label nennt explizit "Klartext … entschlüsselt" + Resource-ID, damit im AuditLog-Viewer auf einen Blick erkennbar ist, wer wann welches Passwort eingesehen hat (DSGVO + Insider-Threat-Erkennung). Live verifiziert: nach Klick auf getPortalPassword erscheint im AuditLog der Eintrag "READ PortalPassword CRITICAL – Klartext-Portal- Passwort von Kunde #1 entschlüsselt". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
185b38dc55 |
feat(email): Suchleiste + erweiterte Filter im Email-Postfach
Variante B aus der Trade-off-Diskussion: Suchleiste über der Email-Liste
plus eine ausklappbare Box mit Detail-Filtern, alle AND-verknüpft.
Backend:
- EmailListOptions um search + 9 Detail-Filter erweitert (fromFilter,
toFilter, subjectFilter, bodyFilter, attachmentNameFilter,
hasAttachments, isRead, isStarred, receivedFrom, receivedTo)
- getCachedEmails baut die where-Klausel:
* `search` → OR über Subject/From-Address/From-Name/Body (Volltext-
Quicksearch)
* Feldspezifische Filter werden AND-verknüpft an die where gehängt;
From-/Body-Filter intern als kleine OR-Subqueries (Match in
Adresse ODER Name; Match in textBody ODER htmlBody)
- Controller-Parser akzeptiert die Filter als Query-Parameter
(parseBoolParam/parseDateParam tolerieren leere/invalide Werte)
Frontend:
- Suchleiste mit X-Button zum Leeren + Filter-Toggle mit Badge (zeigt
Anzahl aktiver Filter)
- Ausklappbare Filter-Box: Von, An, Betreff, Inhalt, Datum von/bis,
Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status
- Filter-State fließt via useMemo + queryKey in den useQuery → React
Query macht automatisch ein Re-Fetch bei jeder Änderung
- "Alle zurücksetzen"-Button räumt komplett auf
- Nicht für TRASH-Folder eingeblendet (eigener Pfad ohne Filter-API)
Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung. Reale Such-Use-Cases im Email-Kontext sind
quasi immer AND-verknüpft; Bool-Builder bringt mehr Bedienprobleme
als Mehrwert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c2ebc7cf1e |
fix(stressfrei): sync-forwarding sichtbar + Passwort-Push + Toast-Meldungen
Drei Verbesserungen am gestrigen Sync-Feature:
1. Bug-Fix: isProvisioned wurde nie auf true gesetzt
`createEmail` mit `provisionAtProvider: true` hat das Flag
`isProvisioned` nie gesetzt → blieb auf @default(false). Damit
blieb der Refresh-Button in der UI unsichtbar (Bedingung
`emailItem.isProvisioned`). Jetzt:
- createEmail setzt isProvisioned + provisionedAt korrekt
- Self-Healing: syncForwardingForEmail setzt das Flag nachträglich
auf true sobald der Provider-Aufruf erfolgreich war (Backfill
für historisch falsch markierte Einträge)
- UI-Sichtbarkeit: Bedingung entfernt – der Button erscheint jetzt
immer; ein Klick auf eine nicht-provisionierte Adresse liefert
eine sprechende Fehlermeldung statt stiller Verstecken
2. Passwort-Push bei hasMailbox: true
Bisher wurden nur die Forwards aktualisiert. Jetzt entschlüsselt
syncForwardingForEmail bei Mailbox-Adressen zusätzlich das im CRM
gespeicherte Passwort und setzt es am Provider neu – Self-Healing
für IMAP/SMTP-Logins falls jemand im Plesk-UI manuell ein anderes
Passwort gesetzt hat. Response enthält `passwordReset: true` als
Marker.
3. react-hot-toast statt alert()
Erfolgs-Toast listet die neu gesetzten Forward-Targets + Hinweis
ob Passwort-Reset durchgeführt wurde. Fehler-Toast zeigt die
Backend-Fehlermeldung (z.B. „E-Mail-Adresse beim Provider nicht
gefunden – wurde sie dort gelöscht?").
Audit-Log-Label enthält jetzt sowohl Forwards als auch Passwort-Reset-
Marker, damit der Vorgang im AuditLog nachvollziehbar bleibt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b4be3cebfb |
feat(stressfrei): Weiterleitungen manuell synchronisieren
Nach Änderung der Kunden-Stamm-E-Mail (oder der defaultForwardEmail in den Provider-Settings) müssen die Plesk-Forwards der Stressfrei-Adressen des Kunden auf den neuen Wert umgestellt werden. Bisher ging das nur manuell pro Adresse im Plesk-UI – jetzt mit einem Klick pro Adresse im CRM. Backend: - emailProviderService.setEmailForwardTargets(localPart, targets[]): dünner Wrapper um die schon vorhandene IEmailProvider-Methode updateForwardTargets (`set:email1,email2` ersetzt komplett, idempotent) - stressfreiEmail.service.syncForwardingForEmail(id): lädt Kunde + Provider-Config, baut [customer.email, defaultForwardEmail] und ruft den Provider auf - POST /api/stressfrei-emails/:id/sync-forwarding, customers:update, Audit-Log mit den neuen Forward-Targets im Label Frontend: - Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse, sichtbar nur wenn isProvisioned (sonst sinnlos). Confirm-Dialog zeigt die Ziele, Tooltip erklärt den Vorgang. - ExternalLink-Icon neben der E-Mail in der Kundenakte (Stammdaten → Kontakt) öffnet den Stressfrei-Tab des Kunden in neuem Tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2c7a87ccd3 |
factory-defaults: HTML-Templates + Import über UI
Erweitert das bestehende Factory-Defaults-Bundle um vier HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz) und ergänzt den bisherigen CLI-Only-Import um einen Upload-Pfad in der UI. Backend: - collectFactoryDefaults() zieht jetzt auch die Whitelist-AppSettings - exportFactoryDefaults() legt sie als app-settings/app-settings.json ins ZIP - importFactoryDefaults(buffer) liest die ZIP idempotent ein – upserts pro Kategorie, Whitelist-Filter für AppSettings, Anti-Zip-Slip durch basename beim PDF-Lookup - POST /api/factory-defaults/import (multer memoryStorage, max 50 MB, settings:update) - seed-factory-defaults.ts (CLI) gleichermaßen um seedAppSettings() erweitert Frontend: - Import-Card in FactoryDefaults.tsx: Datei-Upload statt CLI-Anleitung - Erfolgs-Box mit Counts pro Kategorie + Warnings (z.B. fehlende PDFs im ZIP) - Preview zeigt jetzt auch die Anzahl HTML-Templates Live verifiziert: Round-Trip Export → DELETE privacyPolicyHtml → Import → Wert (13.6 KB) wieder vollständig hergestellt, Audit-Log zeigt EXPORT + UPDATE-Eintrag mit Detail-Counts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2d3ca28691 |
fix(users): DSGVO-/Entwickler-Zugriff über User-Update durchreichen
`pickUserUpdate`-Whitelist enthielt `hasGdprAccess` und `hasDeveloperAccess` nicht – sie wurden vom Mass-Assignment-Schutz aus dem Request entfernt, bevor sie den Service erreichen konnten. Damit lief `setUserGdprAccess` / `setUserDeveloperAccess` nie und die zwei versteckten Rollen blieben unzuweisbar (UI-Checkbox hatte keine Wirkung). Fix: Beide Felder zur Whitelist hinzugefügt – sie sind keine User-Spalten, der Service mappt sie auf die DSGVO-/Developer-Rollen. Bonus: Audit-Log-Diff vergleicht jetzt den Pre-State korrekt (User-Rollen in `before` mitgeladen + Field-Labels), sonst hätte der jetzt durchkommende Flag immer einen False-Positive-Change "- → Ja" produziert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
77602bb4ac |
contracts: VVL (Vertragsverlängerung) als Split-Button neben Folgevertrag
VVL = Vertragsverlängerung beim selben Anbieter (vs. Folgevertrag = i.d.R. Anbieterwechsel). Im Gegensatz zu createFollowUpContract wird ALLES kopiert: - Provider, Tarif, Portal-Username/Passwort (verschlüsselt) - Preise (basePrice/unitPrice/bonus etc.) - Notes, Commission, Internet-Zugangsdaten, SIP-Daten, SIM-PINs - ContractDocuments (1:1, gleiche Datei-Referenz) - Detail-Tabellen (Energy/Internet/Mobile/TV/CarInsurance) komplett Berechnet: - newStartDate = oldStartDate + Vertragslaufzeit (Monate aus ContractDuration.code/description geparsed: "24M" / "24 Monate" / "2J") - newEndDate = newStartDate + Laufzeit - status = DRAFT (User bestätigt manuell) NICHT kopiert: - documentType "Auftragsformular" (das wird neu unterschrieben) - cancellation*-Felder (alter Cancel-Flow nicht relevant) Frontend: - Split-Button: Hauptaktion "Folgevertrag anlegen" + ChevronDown-Pfeil - Dropdown: "VVL anlegen" mit Bestätigungs-Modal - Modal zeigt Vorhersage des neuen Startdatums (alter Start + Vertragslaufzeit als Hinweis) History-Einträge wie bei Folgevertrag, mit eigenem VVL-Wording. Doppel-Schutz: maximal 1 Folge-/VVL-Vertrag pro Vorgänger. Live-verifiziert: - Contract #17 (FIBER, 2026-05-01, 24M) → VVL mit Start 2028-05-01 ✓ - Provider/Tarif/Preise/Credentials 1:1 übernommen - 2 Dokumente kopiert (außer Auftragsformular) - History-Einträge in beiden Verträgen vorhanden Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e2fdb069ac |
Monitoring UX: Log leeren + PageSize wählbar
- Backend: DELETE /api/monitoring/events (settings:update). Optional ?olderThanDays=N – nur Events älter als N Tage löschen. Hinterlässt selbst einen Audit-Eintrag "Log geleert: X Einträge" mit User-E-Mail + IP, damit der Vorgang nachvollziehbar bleibt. - Frontend: "Log leeren"-Button öffnet Bestätigungs-Modal mit optionalem "älter als X Tage"-Filter. Roter Bestätigungs-Button. - Frontend: PageSize-Selector (10/25/50/100/200) neben dem Header. Wechsel setzt automatisch zurück auf Seite 1. Live-verifiziert: Clear löscht 10 Events, schreibt 1 Audit-Event, PageSize=5 wird in pagination respektiert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0cf3dd6a7b |
Security-Hardening Runde 10: Security-Monitoring + Alerting
Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code verhindert wurde: zumindest gesehen + alarmiert werden. 📊 SecurityEvent-Tabelle (Prisma) - Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection - Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet, SecurityEvent ist optimiert für Realtime-Alerting + Aggregation. 🪝 Hooks an kritischen Stellen - Login (Success/Failed) – auth.controller - Logout, Password-Reset (Request + Confirm) – auth.controller - Rate-Limit-Hit – middleware/rateLimit - IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract) - SSRF-Block – emailProvider.controller (test-connection + test-mail-access) - JWT-Reject (alg=none, expired, manipuliert) – middleware/auth 🚨 Threshold-Detection + Alerting (securityAlert.service.ts) - Cron jede Minute: prüft Brute-Force-Patterns je IP - 10× LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht - 5× ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht - 3× SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing - 3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation - CRITICAL-Events: Sofort-Alert per E-Mail (debounced) - Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert) - Sofort-Alert + Digest laufen über System-E-Mail-Provider (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset) 🖥 Frontend: Settings → "Sicherheits-Monitoring" - Alert-E-Mail-Adresse + Digest-Toggle - Test-Alert-Button + Digest-jetzt-Button - Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) - Filter (Type/Severity/Search/IP) + Pagination - Auto-Refresh alle 30 s - Verlinkt aus Settings-Übersicht (settings:read Permission) 🧪 Live-verifiziert - Login-Fehlversuch → LOGIN_FAILED Event - Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED - SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event - 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s - CRITICAL-Sofort-Alert binnen 30s zugestellt - Test-Alert-Button: E-Mail zugestellt - Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6b804cdc82 |
Security-Hardening Runde 8: DNS-Rebinding + Per-File-Ownership
Loose Ends aus Runde 5/7 abgearbeitet.
🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
servername=hostname an die Mail-Services. Damit kann ein zweiter
DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.
🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
invoices, cancellation-*, authorizations, business-/commercial-/
privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...
Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
DNS-Lookup geblockt; gmail.com (legitim) geht durch.
Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
invasiv für <a href>-Flows ohne JS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
df6eb9724d |
Security-Hardening Runde 7: SSRF-Schutz + Logout-Endpoint
🛡 SSRF-Schutz in test-connection / test-mail-access - Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder Connections zu Cloud-Metadata-Endpoints (169.254.169.254, metadata.google.internal etc.) auslösen. Internal-Port-Scan über Timing-Differenzen war messbar. - Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und Cloud-Metadata-Hostnames. Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix- Setups sollen weiter funktionieren. 🔒 Logout-Endpoint POST /api/auth/logout - Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware prüft das Feld bereits und lehnt Tokens mit iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter" JWT bis Expiry (7d) gültig. Live-verifiziert: - 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400 - 127.0.0.1 (Plesk-Fall) weiter erlaubt - /me vor Logout 200, nach Logout 401 "Sitzung ungültig" Geprüft + sauber (Runde 7, kein Bug): - Public Consent (122-bit Random-UUID nicht brute-force-bar) - Magic-Bytes-Bypass beim Upload - PDF manualValues Injection (keine HTML-Render-Surface) - Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert - Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0c0cecdbbd |
Security-Hardening Runde 6: Customer-Liste-Leak + XFF-Bypass + Vollmacht-Validation
Tiefer Live-Pentest deckte 3 weitere Schwachstellen: 🚨 CRITICAL: GET /api/customers leakte komplette Kundendatenbank - Stage-4 hatte canAccessCustomer auf den Single-Endpoint angewendet, der List-Endpoint hatte nur den Daten-Sanitizer (filtert Passwort-Hashes) aber keinen Portal-Filter. Folge: Portal-Kunde sah ALLE Kunden mit Namen, E-Mails, customerNumber etc. – DSGVO-relevant. - Fix: getCustomers filtert für Portal-User auf eigene + vertretene IDs. 🚨 HIGH: Rate-Limit-Bypass via X-Forwarded-For - `trust proxy = 1` hat jedem XFF-Wert vertraut. 12+ Logins mit rotierender XFF-IP gingen ohne 429 durch. - Fix: `trust proxy = 'loopback'` – XFF nur noch von 127.0.0.1 / ::1 akzeptiert (= lokaler Reverse-Proxy). - Plus: LISTEN_ADDR-Default 127.0.0.1 in Production, damit das Backend nicht von außen direkt ansprechbar ist. 🛡 MEDIUM: Self-Grant + Existence-Disclosure in toggleMyAuthorization - Portal-User konnte: a) sich selbst Vollmacht erteilen (customerId=representativeId=1) b) Authorization-Records für nicht-existierende customerIds anlegen (scheitert erst am DB-Constraint mit vollem Prisma-Stack-Leak) c) Customer-IDs durch 404-vs-403-Differenzen enumerieren. - Fix: Self-Grant 400. Existenz + aktive CustomerRepresentative-Beziehung in einem Query – Non-Existent / Non-Related geben identisch 403. Prisma-Error-Stacks generisch ersetzt. Live-verifiziert: Customer-Liste filtert, Self-Grant 400, Existence-Probing dicht. Geprüft + sauber (Runde 6, kein Bug): - Prototype Pollution Login-Body - HTTP-Method-Override-Header - Path-Traversal Backup-Name (Regex blockt) - Developer-Routes existieren nicht - Email-Endpoints mit fremder StressfreiEmail-ID → 403 - /api/customers/:id GET liefert 403 statt 404 (kein Leak) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
35745ce3bb |
Security-Hardening Runde 5: Hack-Das-Ding (DSGVO-GAU + Timing + XSS)
Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents. 🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar - express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten, Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden ohne Login geladen. - Fix: authenticate-Middleware vor static-Handler (req.query.token unterstützung war schon da, jetzt aktiv genutzt). - Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte /api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail, ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard). 🚨 HIGH: Login-Timing User-Enumeration - bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms vs 10ms Differenz, Email-Enumeration trivial messbar. - Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy- Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und Echt-Hash-Cost zusammenpassen. - Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid) – Side-Channel dicht. 🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML - 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify (PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint). Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden- Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite. - Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config. 🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints - canAccessContract jetzt in: uploadContractDocument, deleteContractDocument, handleContractDocumentUpload (Kündigungs- Letter+Confirmation), handleContractDocumentDelete, saveAttachmentAsContractDocument. - Defense-in-Depth: aktuell durch requirePermission abgesichert, schützt auch gegen künftige Staff-Scoping-Rollen. Offen für v1.1: - Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup welche Ressource zur Datei gehört) - TipTap-Link-Tool javascript:-Protokoll blockieren - Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dea2da0271 |
Vertragsstatus-Trigger: Datum beim Upload miterfassen
Beim automatischen Status-Wechsel wird jetzt auch das passende Datum gesetzt, damit Status und Datumsfeld konsistent sind (Cockpit-Warnung "Datum fehlt" verschwindet sofort nach Upload). Backend: - Upload-Handler für Kündigungsbestätigung(s-Optionen) nimmt optional `confirmationDate` aus multipart an, speichert als cancellationConfirmationDate / cancellationConfirmationOptionsDate. Fallback: heute (nur falls Feld noch leer war). - maybeActivateOnDeliveryConfirmation nimmt optional deliveryDate, setzt Contract.startDate falls leer. Fallback: heute. Frontend: - ContractDetail: neues kleines Modal beim Kündigungsbestätigungs-Upload fragt das Bestätigungs-Datum ab (Default: heute oder bereits gesetzter Wert). Der bestehende inline-Datums-Editor bleibt für spätere Korrekturen. - ContractDocumentsSection: Datums-Input erscheint conditional im Upload-Bereich, sobald Typ "Lieferbestätigung" gewählt ist. - SaveAttachmentModal (E-Mail-Anhang → Vertragsdokument): gleicher Datums-Input conditional für "Lieferbestätigung". - API-Methoden uploadCancellationConfirmation / uploadDocument / saveAttachmentAsContractDocument nehmen optional Datum entgegen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0a757d8e47 |
Auto-Vertragsstatus: Lieferbestätigung hochladen → DRAFT auf ACTIVE
Ergänzung zum Cancellation-Trigger: wenn ein ContractDocument mit documentType "Lieferbestätigung" hochgeladen wird und der Vertrag aktuell DRAFT ist, wird er automatisch auf ACTIVE gesetzt (+ Audit-Log). Greift an beiden Upload-Pfaden: - POST /api/contracts/:id/documents (Direkt-Upload via ContractDetail) - POST /api/emails/:id/attachments/:filename/save-as-contract-document (Email-Anhang als Vertragsdokument speichern) Vergleich case-insensitive + getrimmt auf "lieferbestätigung". Andere Typen (Auftragsformular etc.) lösen keinen Wechsel aus. Nicht-DRAFT- Verträge (ACTIVE/CANCELLED/EXPIRED/DEACTIVATED) bleiben unverändert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4ca91eb710 |
Security-Hardening Runde 4: 9 Live-IDORs + Error-Handler
Live-Pentest gegen Dev-Server mit Portal-Token deckte auf, dass customer.* und gdpr.* Endpoints nur den Data-Sanitizer, aber KEINEN canAccessCustomer-Check hatten. Ein Portal-Kunde mit customers:read konnte per ID-Manipulation komplette Fremddatensätze auslesen. - customer.controller.getCustomer + getAddresses + getBankCards + getDocuments + getMeters + getRepresentatives + getPortalSettings: canAccessCustomer - gdpr.controller.getCustomerConsents + getAuthorizations + checkConsentStatus: canAccessCustomer - createAddress/createBankCard/createDocument/createMeter (customerId aus URL): canAccessCustomer (Defense-in-Depth – wird aktuell schon per Permission geblockt, aber im Controller ungeschützt) - Global Error-Handler: err.status respektieren (PayloadTooLargeError → 413 "Anfrage zu groß", SyntaxError → 400 "Ungültiges JSON" statt pauschal 500) Live-verifiziert: ✓ /api/customers/4 als Portal → 200 VORHER, 403 NACHHER ✓ 9 andere IDOR-Endpoints gleiches Muster ✓ Eigene Daten (/api/customers/1) weiter 200 ✓ 12 MB Body → 413, malformed JSON → 400 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8aead8c2f6 |
Security-Hardening Runde 3: JWT, trust-proxy, weitere IDORs, Attachment-Härtung
- JWT-Algorithmus fest auf HS256 (Defense-in-Depth gegen alg-confusion)
- app.set('trust proxy', 1) – Rate-Limiter wirkt jetzt auch hinter Reverse-Proxy
- IDOR-Fix: Invoice-ECD-Endpoints + PDF-Template-Generierung (canAccessContract/ECD)
- Email-Anhang-Download: Content-Type-Safelist, SVG nie inline, nosniff, Filename-CRLF-Sanitize
- Provider/Tariff-GET-Routen: requirePermission('providers:read') (Portal-Kunden raus)
- SMTP-Header-Injection zentral in sendEmail blockiert (schützt alle Caller)
- bcrypt-Cost 10 → 12 (OWASP 2026)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
81f0e89058 |
Security-Hardening Runde 2: Zip-Slip, Mass Assignment, weitere IDORs, Path-Traversal
Nach der ersten Runde habe ich parallel 3 Audit-Agents auf die Codebase angesetzt. Die fanden noch eine Menge: Zip-Slip, Mass Assignment inkl. Privilege Escalation, 13 weitere IDOR-Stellen, 2x Path-Traversal. Alles gefixt. Details + Angriffsvektoren in docs/SECURITY-REVIEW.md. 🔴 KRITISCH gefixt: 1. Zip-Slip im Backup-Upload: extractAllTo() entpackte bösartige ZIPs ohne Pfad-Validierung. Ein Angreifer mit Admin-Zugang hätte mit einem ZIP mit Entries wie ../../etc/crontab das ganze Filesystem überschreiben können. Jetzt wird jeder ZIP-Entry einzeln validiert (path.resolve, starts-with-Check). Absolute Pfade + Null-Bytes werden abgelehnt. 2. Mass Assignment bei Customer/User Controllers: - updateCustomer/createCustomer: req.body ging komplett an Prisma. Angreifer konnte portalPasswordHash, portalPasswordResetToken, consentHash, customerNumber direkt setzen. - updateUser/createUser: roleIds und isActive waren übernehmbar. **Privilege Escalation**: normaler Mitarbeiter konnte sich Admin-Rechte durch PUT /users/:id mit {"roleIds":[1]} geben, oder andere User deaktivieren. Fix: Neue Whitelist-Helper pickCustomerCreate/Update, pickUserCreate/Update in utils/sanitize.ts. Nur erlaubte Felder werden durchgelassen. 3. IDOR bei 13 weiteren Endpoints (neben denen aus Runde 1): - GET /meters/:meterId/readings - GET /emails/:emailId/attachments/:filename - GET /emails/:emailId/attachments (Liste) - GET /customers/:customerId/emails - GET /contracts/:contractId/emails - GET /emails/:id (einzelne Email) - GET /stressfrei-emails/:id (leakte emailPasswordEncrypted) - weitere… Fix: accessControl.ts ausgebaut um canAccessAddress, canAccessBankCard, canAccessIdentityDocument, canAccessMeter, canAccessStressfreiEmail, canAccessCachedEmail. In allen betroffenen Endpoints angewendet. 🟡 WICHTIG gefixt: 4. Path-Traversal bei Backup-Name (GET /settings/backup/:name/*): req.params.name wurde ohne Filter in path.join. Neuer isValidBackupName() erlaubt nur [A-Za-z0-9_-]+ ohne "..". 5. Path-Traversal bei GDPR-Proof-Download: proofDocument-Pfad aus DB wurde ohne Validation gejoined. Jetzt path.resolve + starts-with-uploads-Check. Neue/erweiterte Files: - backend/src/utils/accessControl.ts - 6 neue can-Access-Helper - backend/src/utils/sanitize.ts - 4 neue Whitelist-pick-Helper - docs/SECURITY-REVIEW.md - Runde 2 dokumentiert Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
1c46d7345c |
Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure
Umfassender Security-Review vor öffentlichem Deployment. Detaillierter Report in docs/SECURITY-REVIEW.md. 🔴 KRITISCHE FIXES: 1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env), in Production per default komplett aus (gleiche Origin erzwingt Browser). 2. Keine Security-Headers → helmet-Middleware hinzugefügt. X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP. 3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind, sonst Fail-Fast mit klarer Fehlermeldung. 4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read' konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK, Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen). Neuer Helper canAccessContract() in utils/accessControl.ts in allen betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten. 5. XSS via Email-Body. email.htmlBody wurde ungefiltert via dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script> schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify sanitized: verbietet script/iframe/form/inline-handler, erlaubt normale Formatierung + Bilder. 6. Customer-API leakte sensible Felder: - portalPasswordHash (bcrypt-Hash) - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar) - portalPasswordResetToken (gültig 2h) Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers. Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige), alle anderen Rollen nicht. 🟡 WICHTIGE FIXES: 7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt. Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden dadurch invalidiert. 8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar). Neue Files: - backend/src/utils/accessControl.ts - IDOR-Schutz - backend/src/utils/sanitize.ts - Response-Sanitizer - docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |