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>
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>
Zwei Aktionen, die der existierende Reply-Pfad bisher nicht abdeckte:
1. Weiterleiten (Compose-Modal-Forward-Modus):
- Neuer Button im EmailDetail, neben "Antworten"
- ComposeEmailModal akzeptiert jetzt einen `forwardOf` prop und
füllt das Formular im Forward-Stil vor:
* To leer (User trägt selbst ein)
* Subject mit "Fwd:"-Prefix
* Body mit zitierten Headern (Von, An, Datum, Betreff) +
Original-Text
- Titel des Modals reagiert ("Antworten" / "Weiterleiten" /
"Neue E-Mail")
2. Erneut senden (One-Click-Resend):
- Neuer Button im EmailDetail; schickt die Mail nochmal an die
ursprüngliche toAddresses (= die Stressfrei-Adresse selbst).
Plesk routet dann gemäß der HEUTE hinterlegten Forwards –
Use-Case: die Stressfrei-Forward-Adresse wurde nach Empfang
umgestellt, der Empfang soll beim neuen Forward-Empfänger
landen.
- Confirm-Dialog erklärt den Vorgang und warnt explizit, dass
Anhänge nicht erneut mit gesendet werden (Anhänge wären
IMAP-Refetch, dafür "Weiterleiten" nutzen).
- Toast-Feedback für Erfolg/Fehler.
- Im TRASH-Folder wird der Resend-Button bewusst nicht
eingeblendet (kein sinnvoller Use-Case dort).
Backend braucht keine neuen Endpoints – beide Aktionen nutzen die
bestehenden `stressfreiEmailApi.sendEmail` + `cachedEmailApi.getById`
(letztere für den Body, der ohnehin schon im Detail-View geladen ist).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Behebt die drei behebbaren Klassen aus dem ZAP-/Nikto-Audit vom 2026-05-16:
1. HSTS-Doppel-Header (18 Findings):
Helmet's strictTransportSecurity komplett deaktiviert. Der Nginx Proxy
Manager vor der CRM-VM setzt HSTS bereits (Force SSL + HSTS Enabled +
HSTS Sub-domains via UI). Doppelter Header verletzt RFC 6797.
2. Cache-Control (~10 Findings):
- /api/* → 'no-store' (sensible JSON-Daten)
- SPA-HTML (/, /robots.txt, /sitemap.xml, /vite.svg) → 'no-store,
must-revalidate' (sonst hängt Browser nach Deploy an alter index.html
mit alten Asset-Hashes fest)
- /assets/*.{js,css} (Vite-Build mit Content-Hash) → 'public,
max-age=31536000, immutable'
3. CSP No-Fallback-Direktiven (2 Findings):
worker-src, manifest-src, media-src jetzt explizit auf 'self'. ZAP
meckert sonst "Failure to Define Directive with No Fallback".
Bewusst NICHT gefixt: style-src 'unsafe-inline' (11 Findings). Tailwind +
React (style={{…}}) erzeugen viele inline-styles; nonce-/hash-basierte CSP
wäre ein größerer Build- und Code-Refactor mit eher kosmetischem Gewinn,
da der primäre XSS-Schutz weiterhin via script-src 'self' und Input-
Sanitization greift.
Live verifiziert (Headers via curl gegen HTTPS_ENABLED=true Container):
- / → 'no-store, must-revalidate', kein HSTS
- /assets/index-*.js → 'public, max-age=31536000, immutable', kein HSTS
- /api/health → 'no-store', kein HSTS
- SPA-Fallback (/sitemap.xml, /robots.txt) → 'no-store, must-revalidate'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Das CSP `frame-ancestors 'none'` blockte ALLE iframe-Embeddings, auch
same-origin – damit ließ sich die annotierte PDF-Vorschau im Editor für
PDF-Auftragsvorlagen nicht laden. Browser zeigten je nach Variante
"Verbindung abgelehnt" oder einen CSP-Violation-Fehler.
CSP überschreibt X-Frame-Options, der alte SAMEORIGIN-Header reichte also
nicht aus. Auf 'self' wechseln: eigene App darf eigene Resourcen embeden,
externe Sites weiterhin gesperrt (was X-Frame-Options bereits regelt).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schließt die Lücke „nach Import landet die ZIP nicht im Image-Default":
./factory-import.sh --save-as-builtin
→ entpackt die ZIP nach erfolgreichem DB-Import zusätzlich in
backend/factory-defaults/ (alter Inhalt vorher aufgeräumt, README.md
und .gitkeep bleiben). Beim nächsten Image-Build sind die Defaults
drin und seeden frische VMs automatisch.
README-Abschnitt „Factory-Defaults" komplett überarbeitet:
- Drei Transport-Pfade explizit erklärt (laufende DB / Drop-Box / Image)
- HTML-Standardtexte + AppSetting-Whitelist dokumentiert
- Auto-Seed-Verhalten + Berechtigungen aktualisiert
- Typische Workflows als End-zu-End-Sequenz inkl. scp-Sync
Live verifiziert: STALE_FILE.txt im backend/factory-defaults/ wurde beim
--save-as-builtin sauber entfernt, README.md blieb erhalten, Subfolder neu
befüllt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zwei kleine Bash-Wrapper im Repo-Root, die den vorhandenen Export- und
Import-Endpoint per curl ansteuern und damit den Hin- und Her-Transfer von
Stammdaten + HTML-Templates zwischen Instanzen ohne Browser ermöglichen.
./factory-export.sh # ZIP nach factory-exports/
./factory-import.sh # nimmt jüngste ZIP automatisch
./factory-import.sh path/zur.zip # explizit
Konfigurierbar via OPENCRM_URL / OPENCRM_EMAIL / OPENCRM_PASSWORD;
ohne PASSWORD wird interaktiv abgefragt.
Workflow: prod erweitert Anbieter → ./factory-export.sh → scp → dev
./factory-import.sh – funktioniert in beide Richtungen.
`factory-exports/` ist gitignored (nur .gitkeep getrackt).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neue VMs sollen direkt mit den im Repo abgelegten Stammdaten +
Auftragsvorlagen + HTML-Templates hochkommen, ohne dass man jedes Mal
manuell ein ZIP hochlädt.
- Dockerfile: kopiert backend/factory-defaults nach
/app/factory-defaults-builtin und backend/scripts nach /app/scripts
- seed-factory-defaults.ts: ROOT-Pfad über FACTORY_DEFAULTS_DIR überschreibbar
- entrypoint.sh: nach erfolgreichem Auto-Seed läuft `tsx
scripts/seed-factory-defaults.ts` mit FACTORY_DEFAULTS_DIR auf den
builtin-Pfad. Trigger NUR bei frischer DB (RAN_SEED=true), bestehende
Installs werden nie nachträglich überschrieben.
`backend/factory-defaults/*` bleibt gitignored – Inhalte legt jeder
Operator-User selbst lokal ab (z.B. via Export-ZIP entpacken), sie landen
beim nächsten Container-Build im Image.
Live verifiziert: frischer Container mit RUN_SEED=true zieht 10 Anbieter,
4 Tarife, 18 Kündigungsfristen, 18 Laufzeiten, 8 Kategorien, 2 PDF-Vorlagen
und 2 HTML-Templates ein; PDFs landen mit eindeutigem Suffix in uploads/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
`db push --accept-data-loss` konnte bei Schema-Änderungen still Daten verlieren
(Renames, Type-Changes, NOT NULL ohne Default). Umstellung auf versionierte
Migrations:
- 0_init aus aktuellem Schema generiert (alte gedriftete Migrations entfernt)
- entrypoint: Auto-Baseline für bestehende DBs ohne `_prisma_migrations`,
dann `migrate deploy` (idempotent, kein Daten-Loss)
- npm run schema:sync: legt automatisch eine Migration mit Zeitstempel an
(`prisma migrate dev --name auto_<ts>`)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Letzte Runde – nichts Kritisches mehr gefunden, was den Aufwand wert
wäre. Diminishing returns sind erreicht.
🔧 npm audit fix
- 9 Vulnerabilities → 1 (lodash, path-to-regexp, undici, minimatch
transitiv geupdatet via package-lock.json).
- Verbliebene nodemailer-Vuln braucht Major-Update v6→v8 (breaking).
Wir setzen die betroffenen Felder (envelope.size, transport name)
nicht aus User-Input – als v1.1-Item dokumentiert.
🔍 Audit-Log-Hash-Chain
- War vor Runde 9 invalid (~350 Einträge) durch frühere Schema-
Migrationen, nicht durch Manipulation.
- rehashAll repariert; integrity-check verifiziert die Chain wieder.
Verfahren funktioniert – wäre eine echte Manipulation, würde sie
auffallen.
🟢 Geprüft + sauber (kein Bug)
- From-Header-Injection in smtpService (Stage 3 deckt das schon ab).
- Concurrent Password-Reset Token-Reuse (atomares Delete).
- Frontend localStorage Token-Pattern (Standard-SPA, XSS-resistent durch
DOMPurify in allen Render-Stellen).
📋 Bewusst NICHT gemacht (in HARDENING.md dokumentiert)
- Authenticated Rate-Limit (Aufgabe vom Reverse-Proxy).
- JWT in HttpOnly-Cookie statt localStorage (CSRF-Token-System nötig).
- nodemailer Major-Update.
Der Block "Wann ist dicht dicht?" in SECURITY-HARDENING.md formuliert
die Endkriterien: 5 Punkte erfüllt, was bleibt sind zero-days +
Server-Misconfig in Production – beides nicht durch Code-Änderung
lösbar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
todo.md gehört thematisch zur Doku, nicht zum Backend-Code.
Interne Pfade (TESTING.md, SECURITY-*.md) auf relative ./-Pfade angepasst.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>