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>
`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>
`upgrade-insecure-requests` (CSP) + HSTS sperrten den Browser bei direktem
http://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR auf den Vite-Assets,
weil Browser sie via https laden wollte).
Beide Header sind jetzt default OFF und werden nur gesetzt, wenn
HTTPS_ENABLED=true – also sobald ein TLS-Reverse-Proxy (Caddy/Traefik/Nginx)
vor OpenCRM steht. Lokale + non-TLS-Deployments laufen damit ohne Stolperfalle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-Seed im Container scheiterte mit `ENOENT: tsx prisma/seed.ts`. Zwei
Bugs zusammen:
1. `tsx` war devDependency – durch `npm ci --omit=dev` im Runtime weg.
2. `prisma db seed` spawnt den Befehl über System-PATH; node_modules/.bin
ist dort nicht enthalten, also war auch das wieder einkopierte tsx
nicht auffindbar.
Fix: tsx in `dependencies` + Seed-Command auf `npx tsx prisma/seed.ts`
(npx löst lokale .bin-Binaries auf, unabhängig vom Aufrufer-PATH).
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>
Der entrypoint prüft jetzt nach prisma db push, ob die User-Tabelle
leer ist – wenn ja, wird automatisch geseeded. Damit muss man bei
Erstinstallation nicht mehr daran denken, RUN_SEED=true zu setzen.
Logik:
RUN_SEED=true → Force-Seed (auch bei nicht-leerer DB; für Reset)
User-Count = 0 → Auto-Seed (Default-Verhalten bei leerer DB)
User-Count > 0 → kein Seed (DB schon initialisiert)
Implementiert via "node -e" mit @prisma/client – kein extra Tool nötig.
Fallback bei Fehlern: User-Count = -1, dann kein Seed.
.env.example aktualisiert: RUN_SEED bleibt 'false' als Default und ist
nur noch für Force-Reseed-Szenarien gedacht.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug auf Prod-System (frische Installation): MariaDB legte 'opencrm'-User
korrekt an, aber Backend bekam "Access denied for user 'opencrm'@...".
Ursache: docker-compose substituierte ${DB_PASSWORD} naiv in
"mysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}". Wenn das
Passwort Sonderzeichen wie $, !, #, @, :, / enthielt, brach das die
URL-Authority-Syntax → Backend connectete mit kaputtem Passwort.
Fix:
- docker-compose.yml: DATABASE_URL aus environment ENTFERNT.
Stattdessen DB_HOST=db, DB_PORT=3306, DB_NAME, DB_USER, DB_PASSWORD
als plain env-vars an den Container.
- backend/docker-entrypoint.sh: baut DATABASE_URL beim Start mit
encodeURIComponent für User+Passwort (via node -e, kein extra Tool
wie jq nötig). Funktioniert für beliebige Sonderzeichen.
Live-verifiziert:
- 'secret$1!#with@special' → 'secret%241!%23with%40special' (encoded)
- Backend connectet sauber, Login funktioniert
- entrypoint loggt: "[entrypoint] DATABASE_URL aus DB_*-Komponenten
gebaut (host=db)"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher: Backend connectete als root (mit DB_ROOT_PASSWORD) – zu viele
Privilegien (GRANT ALL ON *.*).
Jetzt: Backend nutzt den App-User ${DB_USER}, den MariaDB beim ersten
Container-Start automatisch über MARIADB_USER/MARIADB_PASSWORD anlegt.
Dieser User bekommt von MariaDB direkt GRANT ALL PRIVILEGES auf
${DB_NAME}.* (= nur die OpenCRM-Datenbank, keine anderen Schemas).
Ausreichend für Prisma db push (DDL+DML auf opencrm.*),
nicht ausreichend für Schema-übergreifende Operationen oder
mysql.user-Manipulation – wie es sein soll.
DB_ROOT_PASSWORD bleibt für Adminer / Notfall-Wartung.
.env.example dokumentiert den Mechanismus.
Live-verifiziert:
- Container läuft mit DATABASE_URL=mysql://opencrm:***@db:3306/opencrm
- Prisma db push synced Schema
- Login + alle CRUD-Operationen funktionieren
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher: DATABASE_URL und die DB_USER/PASSWORD/etc. mussten parallel
gepflegt werden – Werte konnten auseinanderlaufen.
Fix:
- dotenv-expand installiert (löst ${VAR}-Substitution in .env)
- .env.example: DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
- DB_HOST als neue Variable (Default localhost; Container überschreibt zu "db")
- Backend index.ts: dotenvExpand.expand() statt nur dotenv.config()
- Plus Fallback im Code: wenn DATABASE_URL leer aber DB_*-Werte vorhanden,
baut der Backend-Code die URL selbst zusammen (encodeURIComponent für
Sonderzeichen im Passwort).
docker-compose.yml setzt DATABASE_URL weiterhin explizit (Container-
internal Hostname "db") und überschreibt damit die Dev-Variante.
Live-verifiziert:
- Dev-Modus: mysql://root:***@localhost:3306/opencrm (substituiert)
- Container: mysql://root:***@db:3306/opencrm (compose explizit)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: ADMINER_DESIGN=dracula (oder adminer-dark) zeigte das Default-
Theme. Das offizielle Adminer-Image symverlinkt nur designs/.../adminer.css,
aber manche Designs haben adminer-dark.css, sodass der Symlink ins Leere
lief.
Fix: eigener entrypoint, der das erste .css im gewählten Design verlinkt
(unabhängig vom Filename). Anschließend wird der Original-entrypoint.sh
ausgeführt.
Live-verifiziert: dracula → adminer-dark.css symlink ok, HTML lädt
adminer.css mit 13 KB Theme-CSS.
Plus: .env.example listet alle ~28 verfügbaren Designs als Kommentar
und schlägt 'dracula' als Default vor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: Die in Runde 10 hinzugefügte SecurityEvent-Tabelle (Monitoring) war
nicht im Backup-Service erfasst – beim Backup wurden 43 von 44 Tabellen
gesichert, beim Restore die SecurityEvent-Daten nicht zurückgespielt.
3 Stellen ergänzt:
- tables-Liste (createBackup): SecurityEvent wird jetzt mit findMany abgegriffen
- delete-Order (restoreBackup): securityEvent.deleteMany vor dem Wiederbefüllen
- restoreOrder: SecurityEvent.upsert nach AuditLog
Live-verifiziert: neues Backup enthält SecurityEvent.json mit 152 Einträgen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Big Move: vom backend-only-Setup zum vollständigen Container-Stack.
📁 Neue Struktur
- /.env (lokal, nicht getrackt) – zentrale Konfiguration für Dev + Docker
- /.env.example – Template mit allen Variablen
- /data/{db,uploads,factory-defaults,backups}/ – Bind-Mounts statt Volumes
(auf Wunsch: Daten bleiben im Projektverzeichnis)
- /backend/Dockerfile – Multi-Stage Build (Frontend + Backend)
- /backend/docker-entrypoint.sh – wartet auf DB, prisma db push, optional seed
🐳 docker-compose.yml (neu konsolidiert)
- mariadb 10.11 mit Bind-Mount ./data/db
- opencrm-app (Backend serviert Frontend statisch in production)
- adminer mit Theme pepa-linha-dark als DB-UI
- Ports + Pfade + Secrets alle aus .env
🔧 Backend
- index.ts dotenv-Loader: lädt zuerst Root /.env, dann backend/.env als
Fallback. Funktioniert nahtlos für npm run dev und für Container.
- backend/.env.example als Legacy-Fallback dokumentiert
📝 README
- Quick-Start mit Docker als empfohlener Default (3 Befehle)
- Tabelle der Daten-Verzeichnisse
- Hinweis auf RUN_SEED=true beim ersten Start
⚙ Konfigurierbar via .env
- OPENCRM_PORT (Backend extern), ADMINER_PORT (DB-UI), DB_PORT
- Daten-Pfade (DATA_DIR, DB_DATA_DIR, UPLOADS_DIR etc.)
- DB_NAME/USER/PASSWORD, JWT_SECRET, ENCRYPTION_KEY
- ADMINER_DESIGN (Theme-Auswahl)
Hinweis: Vor dem ersten `docker compose up -d` muss das laufende
`npm run dev`-Backend gestoppt werden (Port + DB-Conflict). Das alte
Volume `opencrm_mariadb_data` bleibt unangetastet als Notfall-Backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
backend/.env war seit "first commit" getrackt (mit echten Secrets:
JWT_SECRET, ENCRYPTION_KEY, DB-Password). Das Pattern .env war zwar
in .gitignore, wirkte aber nicht rückwirkend.
- git rm --cached backend/.env (Datei bleibt lokal)
- backend/.gitignore + frontend/.gitignore: explizite !.env.example
Whitelist zur Klarstellung
- Neue Root-.gitignore mit gemeinsamen Patterns (Env, OS, IDE, Logs)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: zweimal CRITICAL-Alert für dieselbe Brute-Force-Erkennung kam an.
Ursache: detectThresholds() hat als Cutoff für den "existing"-Check
floor(now, hour) genutzt. Bei Stundenwechsel resettete der Bucket
und der nächste Cron-Lauf fand nichts mehr "in der aktuellen Stunde"
→ erzeugte zweites SUSPICIOUS-Event → zweite Mail.
Fix: gleitendes 60min-Fenster (now - 60min). Pro IP gibt es jetzt
zuverlässig max. 1 CRITICAL-Alert pro Stunde, unabhängig von der
absoluten Uhrzeit.
Live-verifiziert in DB: zwei Alerts kamen um 07:41 und 08:00 –
genau das Pattern, das der Stunden-Reset erzeugt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: nur "Zurück / Weiter". Jetzt:
[«] [‹] [1] [2] [3] [4] [5*] [6] [7] [8] [9] [10] [›] [»]
10 Seitenzahlen-Buttons, current centered (clamped an Anfang/Ende).
Zusätzlich Doppelpfeile für erste/letzte Seite. Kompakt + verständlich
auch bei 50+ Seiten.
Helper paginationWindow() rechnet das Fenster aus, sodass bei
totalPages <= 10 alle gezeigt werden, sonst current ungefähr mittig
mit Clamp an die Ränder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
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>
- Neue docs/SECURITY-HARDENING.md mit der ganzen 8-Runden-Story inkl.
aller Live-Test-Tabellen (Runden 4–8 jeweils mit Vorher/Nachher),
geprüft+sauber-Liste, Trade-offs und Deployment-Checkliste.
- backend/todo.md: kompletter Hardening-Block raus, ersetzt durch
knappen Verweis (250 statt 421 Zeilen). todo.md ist jetzt wieder
echte Todo-Liste, nicht Security-Doku.
- docs/SECURITY-REVIEW.md: Banner oben, der auf HARDENING.md verweist
(REVIEW.md bleibt als ausführliche Doku der ersten 2 Runden).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
🛡 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>
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>
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>
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>
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>
- Neuer Scheduler (02:00 + Catch-up 60s nach Start): alle ACTIVE-Verträge mit
endDate < heute werden auf EXPIRED umgestellt. Audit-Log pro Vertrag.
- Upload cancellationConfirmationPath: Vertrag wechselt von ACTIVE → CANCELLED
(mit Audit-Log). "Options"-Upload triggert bewusst nicht, da für
Vertragsänderungen gedacht, nicht für echte Kündigungen.
- Keine neuen Statuswerte. "Kündigung gesendet vs. bestätigt" bleibt über die
getrennten Felder cancellationSentDate / cancellationConfirmationDate lesbar,
Status bleibt bis zur Bestätigung auf ACTIVE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Beim install-Befehl war ich versehentlich im Repo-Root statt im backend/,
wodurch helmet in einem /package.json landete (das ins Repo wollte) statt
in backend/package.json. Jetzt sauber installiert.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Das Backup- und Restore-System kannte noch nicht alle Tabellen, die im Lauf
der letzten Wochen hinzugekommen sind. Kritischer Datenverlust im Ernstfall!
Neu im Backup + Restore:
- PdfTemplate (PDF-Auftragsvorlagen + Feldzuordnungen)
- ContractMeter (Zähler-Vertrag-Zuordnungen mit Zeiträumen)
- ContractDocument (flexible Vertragsdokumente: Auftragsformular, Lieferbestätigung ...)
- RepresentativeAuthorization (Vollmachten zwischen Kunden)
- CustomerConsent (DSGVO-Einwilligungen pro Kunde)
- DataDeletionRequest (DSGVO-Löschanfragen)
- EmailLog (SMTP-Sendeprotokoll)
- AuditRetentionPolicy (Aufbewahrungsfristen pro Ressourcentyp)
- AuditLog (vollständiges Änderungsprotokoll)
Außerdem:
- prisma/backup-data.ts: komplett neu strukturiert, korrekte Level-Hierarchie,
nutzt aktuelles Schema (Provider statt EnergyProvider/TelecomProvider,
InternetContractDetails statt TelecomContractDetails etc.)
- prisma/restore-data.ts: Boilerplate durch generische restoreTable()-Helper
ersetzt – von 487 auf ~240 Zeilen
- backup.service.ts: neue Tabellen in createBackup, restoreOrder und
deleteMany-Liste nachgetragen (Service bleibt sonst wie er ist)
Test-Backup erfolgreich: 4420 Datensätze in 37 aktiven Tabellen gesichert.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
-ip-address 0.0.0.0 bei plesk bin secret_key --create funktioniert NICHT,
um alle IPs zu erlauben. Stattdessen muss der Parameter komplett weggelassen
werden. Warnhinweis in der README ergänzt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Neuer Abschnitt erklärt Schritt-für-Schritt wie man den API-Key in Plesk anlegt:
- Variante 1: Über die Plesk-Oberfläche (Mein Profil → API-Token)
- Variante 2: Über SSH (plesk bin secret_key --create)
- Hinweise zur REST-API-Extension (falls API-Key-Button fehlt)
- Firewall-Konfiguration für Port 8443
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detaillierter Plan für späteren SaaS-Umbau festgehalten, damit wir beim
nächsten Mal nicht neu planen müssen:
- Architektur: Instance-per-Customer (Weg C)
→ keine Multi-Tenancy im Code, pro Kunde eigene Docker-Instanz + DB
→ Isolation statt tenantId-Filter, DSGVO-freundlich
- Admin-Portal (separate App) für Provisioning, Kundenverwaltung, Billing
- Abrechnung über GoCardless (SEPA + Kreditkarte), 30-Tage-Trial
- Plesk-Integration nutzen, KEIN eigener Mailserver
- Technische Bausteine, Provisioning-Flow, Zeitschätzung (~3-4 Wochen)
Status: erstmal nur auf der Todo, nicht angefangen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>