Commit Graph
22 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 41671cbb96 Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als
Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf
der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf,
CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe
Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt.

Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am
Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen
weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als
Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'.
Alle anderen Anmeldungen bleiben CRITICAL.

Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der
AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie
Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen
Zeitgeber und ist noch nicht gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:54 +02:00
duffyduckandClaude Opus 5 791711ca58 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01)
Jede Kulanz-Wiedervorlage rotierte auf einen frischen Token mit eigenem,
zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token konnte damit aus
dem erkennbaren Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung
entkommen, die nie wieder mit der des Opfers kollidiert - dauerhaft unsichtbar,
kein einziges CRITICAL. Das hebelte die Kern-Garantie von R164-02 aus:
Diebstahl faellt bei der naechsten Nutzung auf.

Fix: Kulanz idempotent. Die jti des Nachfolgers wird bereits beim Einloesen im
selben bedingten UPDATE reserviert (replacedByJti). Eine Wiedervorlage im
Fenster gibt denselben bereits ausgestellten Nachfolger zurueck, statt neu zu
rotieren - ohne neuen Datensatz. Parallele Tabs laufen dadurch auf eine Linie
zusammen; wer den Token spaeter vorlegt, kollidiert zwangslaeufig und loest den
Familien-Widerruf aus. Fehlt der Nachfolger, wird bewusst nicht ersatzweise
rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet.

Verifiziert (PoC nachgebaut): T0 legit -> TA, T0 replayt -> TC, TA.jti ==
TC.jti - kein Fork mehr. Ueber HTTP: zwei Tabs beide erfolgreich auf derselben
Linie; gestohlener Token nach Fensterablauf -> 401, Familie widerrufen,
Angreifer-Linie tot, SUSPICIOUS/CRITICAL gemeldet. Regression: 40 parallel ->
4 erfolgreich auf einer Linie, seriell 1-4 ok und 5. Replay, Token ohne jti
fail-closed, Logout widerruft. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:17:51 +02:00
duffyduckandClaude Opus 5 9fcab6f17e Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)
Die Rotation war wirkungslos: Der alte Refresh-Token blieb bis exp gueltig, ein
gestohlener Token also bis zu 7 Tage parallel zum legitimen nutzbar - der
Pentester trug mit einem Token 90 Parallel-Requests.

Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine jti und
gehoert zu einer Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.

Kulanzfenster fuer parallele Tabs: 15 s und hoechstens 3 Wiederverwendungen.
Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge
Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen.

Das Einloesen ist atomar (bedingtes UPDATE statt Lesen-dann-Schreiben) -
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
gleichzeitige Requests ausnahmslos durch, weil alle den Token als unbenutzt
lasen.

Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.

Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:02:53 +02:00
duffyduckandClaude Opus 4.8 d599eb3702 Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.

Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:

- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
  bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
  wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
  ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
  err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
  bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
  + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
  (semantisch ein Refresh, kein Login).

Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 18:20:21 +02:00
duffyduckandClaude Opus 4.8 bbe1aed230 Portal-Passwort: encrypted ohne Hash gilt jetzt als desync (R143)
Pentester R143 (Robustheit): getCustomerPortalPassword lieferte 'ok',
wenn portalPasswordEncrypted gesetzt, aber portalPasswordHash null ist.
Ohne Hash ist ein Login unmoeglich -> ein revealtes Passwort waere
irrefuehrend. Jetzt -> desync (Reveal/Send blocken mit 409). Aktuell
ueber die API nicht erreichbar, nur als DB-Altlast; jetzt sauber
abgefangen.

Verifiziert: encrypted+hash=null -> desync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:06:00 +02:00
duffyduckandClaude Opus 4.8 79f6f3e629 Portal-Passwort: Reveal/Send prueft Konsistenz gegen Login-Hash
Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld
koennen out-of-sync sein -> Support liest ein Passwort vor, das beim
Login scheitert.

Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder
zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der
Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff.

Fix (defensiv, unabhaengig von der Ursache):
- getCustomerPortalPassword liefert {status: ok|none|desync} und prueft
  den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash.
- Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH
  Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte
  neu setzen'. Reveal-Read wird mit Status auditiert.
- Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt
  alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer
  Prod, da der Pentester keinen FS-Zugriff hat.

Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW ->
none. Scan laeuft (0 Desync auf Dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 22:46:53 +02:00
duffyduckandClaude Opus 4.7 f4ac1c29db Pentest 59.4 HIGH: IPv4-mapped IPv6 in SSRF-Guard blocken (alle Schreibweisen)
Node's URL-Parser normalisiert IPv4-mapped IPv6 von Dotted- in
Hex-Form: `::ffff:127.0.0.1` → `::ffff:7f00:1`,
`::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS-Metadata!),
`::ffff:10.0.0.1` → `::ffff:a00:1`.

Die bisherigen Patterns (`::ffff:127\.` etc.) matchten nur die
Dotted-Form. Sobald die URL durch `new URL()` lief, wurde der Host
in Hex-Form herausgereicht und kam an der Blocklist vorbei – live
verifiziert auf test-mail-access mit allen drei Payloads.

Fix in ssrfGuard.ts:
- Neuer extractMappedIPv4-Helper: erkennt Compact-Dotted,
  Compact-Hex, Expanded-Dotted, Expanded-Hex – konvertiert auf
  Dotted-IPv4.
- Neuer checkIPv4-Helper: läuft die IPv4 durch BLOCKED_PATTERNS
  und (optional) PRIVATE_IP_PATTERNS, mit BLOCKED/PRIVATE_HOSTNAMES.
- isBlockedSsrfHost + isPrivateOrBlockedHost rufen den IPv4-Check
  bei Mapped-IPv6 zusätzlich auf. Plain IPv4 und Hex-Form werden
  damit gleich behandelt.

Verifiziert mit 15-Tests: ::ffff:7f00:1, ::ffff:a9fe:a9fe,
0:0:0:0:0:ffff:7f00:1 etc. werden alle geblockt; legitime IPs
(8.8.8.8, ::ffff:8.8.8.8) bleiben durchlässig.

Nebenbefund (Consent-URL = localhost):
- getPublicUrl in auth.service jetzt EXPORT (vorher private).
- gdpr.controller (sendConsentLink + send-privacy-link) nutzt
  jetzt getPublicUrl statt direkt PUBLIC_URL/origin/localhost-
  Kette. Damit greift die admin-konfigurierte
  AppSetting `portalLoginUrl` auch hier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:16:19 +02:00
duffyduckandClaude Opus 4.7 2d4e4cdcc7 Portal-Login-URL als App-Setting (statt nur PUBLIC_URL-Env)
Bugfix: in der "Zugangsdaten versenden"-Mail stand bisher
http://localhost:5173/portal/login als Login-Link, wenn die
PUBLIC_URL-Env nicht gesetzt war – Kunden klickten auf einen
toten Link.

Neue Einstellung "portalLoginUrl" unter Einstellungen → Kundenportal.
Wenn gepflegt, wird sie als Basis-URL für:
  - Portal-Zugangsdaten-Mail (Login-Link)
  - Passwort-Reset-Link
verwendet. Reihenfolge: AppSetting → PUBLIC_URL-Env → localhost-Default.

Backend: getPublicUrl() jetzt async, liest erst aus AppSetting,
fällt auf Env zurück. Trailing-Slash-Bereinigung im Backend
(damit Links nicht doppelt-Slash bekommen) und im Frontend
(damit der gespeicherte Wert sauber ist).

Frontend: neue Card "Portal-Login-URL" oberhalb der Support-
Anfragen-Card in PortalSettings.tsx. Input + Save-Button +
http(s)://-Schema-Validierung + Erfolgs-Toast.

Live-verifiziert: PUT setzt 'https://crm.beispiel.de', Backend-
getPublicUrl liefert 'https://crm.beispiel.de/portal/login'
statt localhost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:55 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-18 15:19:58 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-17 21:47:20 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-17 00:40:00 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-16 23:47:17 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-16 18:48:13 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-16 18:26:11 +02:00
duffyduckandClaude Opus 4.7 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>
2026-05-16 16:06:17 +02:00
duffyduckandClaude Opus 4.7 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>
2026-04-25 00:21:37 +02:00
duffyduckandClaude Opus 4.7 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>
2026-04-24 09:38:25 +02:00
duffyduckandClaude Opus 4.6 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>
2026-04-23 22:06:16 +02:00
duffyduckandClaude Opus 4.6 0764bc6ddf Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße
Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0:

## 1. Passwort vergessen-Flow

Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch
für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht
mehr manuell eingreifen.

- Neues Link "Passwort vergessen?" auf Login-Seite
- PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal)
- PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen)
- Token ist 2 Stunden gültig, dann muss neu angefordert werden
- Token ist kryptografisch sicher (crypto.randomBytes(32))
- User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert
- Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt
  (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war

DB:
- User.passwordResetToken + passwordResetExpiresAt
- Customer.portalPasswordResetToken + portalPasswordResetExpiresAt

## 2. Rate-Limiting gegen Brute-Force

Mit express-rate-limit:
- Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit.
- Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern)

Sowohl Mitarbeiter-Login als auch Portal-Login geschützt.

## 3. Auto-Geburtstagsgrüße per Cron

Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der
ihn wirklich abschickt. Jetzt:

- Läuft täglich um 08:00 Uhr
- Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Nur Email-Kanal (Messenger brauchen Browser-Klick)
- Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird
  beim nächsten Boot nachgeholt
- lastBirthdayGreetingYear verhindert Doppelversand

Dependencies: node-cron, @types/node-cron, express-rate-limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:27 +02:00
duffyduck fd55742c57 complete new audit system 2026-03-21 18:23:54 +01:00
duffyduck c3edb8ad2e gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery 2026-03-21 11:59:53 +01:00
Stefan Hacker e209e9bbca first commit 2026-01-29 01:16:54 +01:00