Compare commits

..

169 Commits

Author SHA1 Message Date
duffyduck 92c3b0dc95 docker-compose: SSRF_BLOCK_PRIVATE_IPS-Env durchreichen
Damit der Flag aus der .env auch im Container ankommt – Default
false (on-prem-kompatibel), Cloud-Deploys setzen in der .env
SSRF_BLOCK_PRIVATE_IPS=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:18:55 +02:00
duffyduck 83cd737e81 README: SSRF_BLOCK_PRIVATE_IPS dokumentiert (Cloud-Deploy)
Neue Section "Deployment-Modus: On-Prem vs. Cloud" im Production-
Deployment-Block. Erklaert, warum On-Prem-Default private IPs
erlaubt (Plesk/Dovecot lokal) und wann der Flag fuer Cloud-Deploys
auf true gesetzt werden soll. Cloud-Metadata-Endpoints sind
unabhaengig vom Flag immer geblockt.

.env.example: SSRF_BLOCK_PRIVATE_IPS=false als Default mit Block-
Kommentar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:17:05 +02:00
duffyduck 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>
2026-05-20 20:14:59 +02:00
duffyduck 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:
"j​av​ascript:" 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>
2026-05-20 18:47:44 +02:00
duffyduck 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, "&#106;avascript:",
"&#x3C;script&#x3E;" und "&lt;script&gt;" 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>
2026-05-20 17:46:15 +02:00
duffyduck 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>
2026-05-20 07:49:06 +02:00
duffyduck 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>
2026-05-20 01:13:19 +02:00
duffyduck 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>
2026-05-20 01:05:00 +02:00
duffyduck 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>
2026-05-19 12:49:19 +02:00
duffyduck 8ee5c9b07a Rollen+Permissions-Sync beim Container-Start
Folge-Fix für die DSGVO-Menü-Sache. Settings.tsx hatte ich auf
audit:read || gdpr:admin erweitert, aber auf bestehenden
Installationen läuft der prisma-Seed nicht (nur auf leeren DBs).
Wer das System früher installiert hat, hat die DSGVO-Rolle ohne
audit:read in der DB – das JWT enthielt die Perm dann nie, und der
neue Settings.tsx-Check blieb wirkungslos.

Neues Skript prisma/sync-roles.ts läuft idempotent bei jedem
Container-Start: upserts Permissions-Katalog + syncRolePermissions
für Admin, Developer, DSGVO, Mitarbeiter (R/W + R/O), Kunde.
Stammdaten, User und Verträge werden NICHT angefasst – sicher auf
prod.

Live-verifiziert: nach `DELETE audit:read FROM RolePermission`
liefert der nächste Lauf "+1 Permissions an Rolle #27", DSGVO ist
wieder komplett.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:41:39 +02:00
duffyduck 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>
2026-05-19 11:53:04 +02:00
duffyduck 95541e8ac4 fix: DSGVO-Rolle sieht Menüpunkte in Einstellungen wieder
System-Block in Settings.tsx war komplett in
hasPermission('settings:update') gewickelt. DSGVO-User haben aber nur
audit:* und gdpr:* Perms – kein settings:update – und sahen damit
weder DSGVO-Dashboard, Datenschutzerklärung, Vollmacht-Vorlage,
Impressum, Website-Datenschutz, E-Mail-Versandlog noch Audit-Log.

Outer-Check auf (settings:update || audit:read || gdpr:admin)
erweitert. Innere Per-Card-Checks bleiben unverändert, sodass jeder
User nur das sieht, wofür er Perms hat.

Backend-API mit reinem DSGVO-Token gegengetestet: alle 9 Endpoints
liefern 200 – Routes hatten kein Permission-Problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:44:48 +02:00
duffyduck 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>
2026-05-19 08:30:13 +02:00
duffyduck 06c427ee39 backup-restore: Toast bei Erfolg, ausführliche Fehlermeldung im Dialog
Vorher: nach Klick auf "Ja, wiederherstellen" passierte UI-seitig
einfach … nichts Sichtbares außer dass der Dialog (irgendwann) zuging.
Bei einem 500er-Fehler blieb der Dialog offen ohne erkennbare
Begründung – der User dachte, die Aktion sei nicht durchgelaufen,
und klickte teils nochmal.

Jetzt:
- Erfolg → Dialog zu, grüne Toast-Meldung mit der Backend-Response
  ("X Datensätze und Y Dateien wiederhergestellt"), 6s sichtbar.
- Fehler → Dialog bleibt offen mit roter Detail-Box drinnen,
  Backend-Felder error + details zusammengefügt, plus
  Toast-Notification 8s. Button-Label wird zu "Erneut versuchen",
  Sekundär-Button zu "Schließen".
- Beim Schließen wird mutation.reset() aufgerufen, damit beim
  nächsten Öffnen keine alten Fehler dranhängen.

extractError-Helper ist allgemein – kann später für andere
Backup-Aktionen wiederverwendet werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:16 +02:00
duffyduck 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>
2026-05-18 21:18:59 +02:00
duffyduck 0f2dc44e45 cleanup: hacker@-Marker raus (legitime Nachnamen "Hacker")
Familie Hacker / Kunden mit "Hacker" als Nachnamen nutzen reichlich
hacker@familie-hacker.de & Co. Das `^hacker@`-Pattern hätte alle
fälschlich als Pentest-Marker erkannt. Raus damit.

Verbleibende Marker reichen aus:
- ^attacker@, ^pentest@, @evil.
- <script, onerror=, javascript:
- SQL-Injection-Pattern, Path-Traversal

Verifiziert: hacker@familie-hacker.de geht durch, attacker@evil.de
wird weiterhin erkannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:36 +02:00
duffyduck b87a2a3d08 fix: Cleanup im echten Entrypoint + idempotente Migration
Beim Audit der Container-Pipeline zwei Bugs gefunden:

1) backend/docker-entrypoint.sh (= der wirklich ausgeführte Entrypoint
   laut Dockerfile) ruft jetzt das Cleanup-Script auf. Der Cleanup-
   Aufruf hing bisher fälschlich in docker/entrypoint.sh – ein
   alternatives Setup, das von der Standard-Compose-Konfiguration
   NICHT genutzt wird. Folge: das Cleanup ist auf prod nie gelaufen.

2) Migration 20260516173552_portal_password_must_change nutzt jetzt
   `ADD COLUMN IF NOT EXISTS`. Auf prod-DBs, die zwischen den Runden
   per `prisma db push` updated wurden (z.B. weil der erste Build
   mit `db push` provisioniert war), existiert die Spalte bereits.
   Ohne IF NOT EXISTS würde migrate deploy beim Hochziehen einer
   neueren Version mit "Duplicate column" abbrechen.

MariaDB ≥ 10.0.2 + MySQL ≥ 8.0.27 unterstützen IF NOT EXISTS für
ALTER TABLE ADD COLUMN – beides ist in unserer Compose-Konfig
abgedeckt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:24:33 +02:00
duffyduck 48fe69cdab Security-Hardening Runde 17: JWT-TTL + Pentest-Marker-Detection
Pentest Runde 17:

21.1 Access-Token TTL war 7 Tage statt 15min:
docker-compose.yml und .env.example standen schon richtig auf 15m
als Default. Die alten Beispiel-.env-Files (backend/.env.example,
docker/.env.example) hatten noch die alte Konvention "7d". Beide
auf 15m korrigiert + explizites JWT_REFRESH_EXPIRES_IN=7d ergänzt.
Auf prod muss die echte .env entsprechend angepasst werden.

17.5 Alte Pentest-Daten in DB:
Cleanup-Script erweitert um Pentest-Marker-Erkennung:
- Email-Pattern: ^hacker@, ^attacker@, ^pentest@, @evil\.
- XSS-Marker: <script, onerror=, javascript:
- Sonstige: SQL-Injection, Path-Traversal

Bewusst eng gefasst (Marker MUSS am Email-Anfang stehen), damit
legitime Kunden wie "stefanhacker@gmx.de" nicht als Pentest-Daten
durchgehen.

Default: nur warnen + Records auflisten. Opt-In via
CLEANUP_PURGE_PENTEST=true löscht die markierten Customer/User.

Live-verifiziert:
- stefanhacker@gmx.de (echt) → durchgelassen
- hacker@evil.de (Pentest) → erkannt + Warnung
- Mit Purge-Env → gelöscht

18.4 Klartext-Portal-PW-Abruf:
Bewusst drin gelassen (Admin-UI-Komfort). Endpoint ist mit
customers:update-Permission gated + Audit-Log (READ →
PortalPassword) – kein Bypass-Risiko, nur explizite Audit-Pflicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:06:03 +02:00
duffyduck 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>
2026-05-18 18:43:45 +02:00
duffyduck 5ffd1a4d2c fix: prisma.ts baut DATABASE_URL aus DB_*-Vars (für docker exec)
docker-compose reicht DB_USER/DB_PASSWORD/DB_HOST/DB_NAME an den
Container weiter, aber DATABASE_URL wird erst beim Container-Start
im entrypoint.sh aus diesen Komponenten zusammengebaut und exportiert.
`docker exec` startet eine neue Shell, die das exportierte
DATABASE_URL nicht erbt → ./scripts/admin-rescue.sh brach mit
"Environment variable not found: DATABASE_URL" ab.

src/lib/prisma.ts macht jetzt dieselbe URL-Konstruktion einmal
zentral. Damit funktionieren alle Wartungsskripte (reset-admin-
password, cleanup-xss-and-mass-assignment) bei docker exec ohne
Wrapper-Hack. Server-Start ist unbeeinflusst (DATABASE_URL ist da
schon gesetzt).

Live-verifiziert lokal: env -u DATABASE_URL DB_USER=... npx tsx
prisma/reset-admin-password.ts admin@admin.com → success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:15 +02:00
duffyduck 7b9a1981a7 fix: src/ ins Runtime-Image, damit prisma/*.ts-Wartungsskripte laufen
reset-admin-password.ts und cleanup-xss-and-mass-assignment.ts
importieren über '../src/lib/prisma.js' den shared Prisma-Client
+ Helper. Im Production-Container lag src/ aber nicht – nur dist/
für die Server-Runtime. Folge: admin-rescue.sh password ... brach
mit ERR_MODULE_NOT_FOUND ab.

Dockerfile kopiert jetzt zusätzlich src/ + tsconfig.json aus dem
backend-builder Stage ins Runtime-Image. tsx ist eh in dependencies
(nicht devDependencies) – läuft also auch unter --omit=dev. Server
selbst nutzt weiter dist/ via "node dist/index.js"; src/ ist nur
für ad-hoc Wartungsskripte da.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:53:52 +02:00
duffyduck 3dda83314a Admin-Rescue: PW-Reset direkt in DB + Rate-Limit-Reset
Use Case: Admin sperrt sich aus (admin@admin.com ist keine echte
Mailadresse, Passwort-vergessen-Flow kann keine Mail liefern) oder
Brute-Force-Lockout will sich nicht von selbst auflösen.

backend/prisma/reset-admin-password.ts:
- Findet User per Email, hasht neues PW mit bcrypt cost 12
- Schreibt direkt in user.password, setzt tokenInvalidatedAt=now()
  (kickt alle bestehenden Sessions), löscht Reset-Tokens
- Eigenes PW: Komplexitäts-Check 25 Zeichen
- Kein PW-Argument: 28-char Zufallspasswort (alle 4 Klassen
  garantiert), wird einmal in stdout ausgegeben

scripts/admin-rescue.sh:
- password <email> [pw]  → docker exec npx tsx … reset-admin-password
- unlock                  → docker restart opencrm-app (leert
                            In-Memory-Rate-Limit-Store)
- all <email> [pw]        → beides

Live-verifiziert: random-Modus, schwaches PW → klare Fehlerliste,
langes eigenes PW → akzeptiert, unbekannter User → exit 2, bash -n
syntax-check ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:02 +02:00
duffyduck 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
duffyduck 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>
2026-05-18 15:09:13 +02:00
duffyduck 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>
2026-05-18 05:23:12 +02:00
duffyduck 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
duffyduck 7b6b586033 fix: PortalPrivacy weiße Seite – Hooks-Reihenfolge nach early-return
useState + useEffect für den Download-Token standen nach dem
`if (isLoading) return <Laden />` early-return. Beim ersten Render
gab es 2 Hooks, beim zweiten 4 → React-Hook-Order-Mismatch → Crash →
weiße Seite. Mein Fehler aus der Download-Token-Migration (Runde 11).

Hooks vor den early-return verschoben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:33:40 +02:00
duffyduck 3dea381983 fix: UUID-Hashes (Datenschutz-Link) nicht als truncated-ID blocken
Der Integer-Truncation-Filter aus Runde 12 war zu breit: er hat jedes
Pfad-Segment geblockt, das mit einer Ziffer beginnt und nicht aus
reinen Ziffern besteht – also auch UUIDs wie
"3018c9b9-b337-4c9a-a402-b47872f8ddae". Damit warf der Datenschutz-
Link beim ersten Aufruf "Ungültiger Link" (in Wahrheit 400 vom Filter).

Engere Heuristik: ^\d+[a-zA-Z]+$ – reine Ziffern gefolgt von reinen
Buchstaben (`6abc`, `12foo`). UUIDs haben Bindestriche und Hex-
Buchstaben gemischt, werden korrekt durchgelassen.

Live-verifiziert: Datenschutz-Link mit UUID → 200; /customers/6abc →
weiterhin 400; /customers/3 → 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:21:52 +02:00
duffyduck 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>
2026-05-17 08:51:52 +02:00
duffyduck 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>
2026-05-17 01:26:12 +02:00
duffyduck 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>
2026-05-17 01:20:43 +02:00
duffyduck 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
duffyduck 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
duffyduck 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>
2026-05-16 22:57:09 +02:00
duffyduck 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>
2026-05-16 19:55:38 +02:00
duffyduck 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>
2026-05-16 19:39:02 +02:00
duffyduck 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
duffyduck f0c97cd46d todo.md: Passwort-Komplexität + Real-IP-Fix dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:52 +02:00
duffyduck 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
duffyduck 6af1a4bbd4 fix(security): trust proxy = 1 bei HTTPS_ENABLED – echte Client-IP statt Proxy-IP
Wenn der TLS-Reverse-Proxy (Nginx Proxy Manager) auf einer SEPARATEN Box
läuft, kommt nicht von 127.0.0.1 → `trust proxy = 'loopback'` greift
nicht → req.ip bleibt die NPM-IP statt der echten Client-IP. Folgen:

- Rate-Limiter sieht alle Angriffe als von "einem" Client (= NPM)
- Security-Monitor loggt Proxy-IP statt Angreifer-IP (Beweis im
  Audit-Log: "ACCESS_DENIED ... 172.0.2.12" für alle Versuche)
- IDOR-Threshold-Detection (>5 in 5 min pro IP) triggert auf der NPM-IP
  und blockt damit alle legitimen User durch denselben Proxy

Fix: bei HTTPS_ENABLED=true `trust proxy = 1` (vertraue genau einem Hop –
den vorgelagerten TLS-Proxy). Bei HTTPS_ENABLED=false bleibt es bei
`loopback` (keine Proxy-Annahme bei direkter http://ip:port-Nutzung).

Voraussetzung für HTTPS_ENABLED=true: Backend ist nicht direkt aus
dem Internet erreichbar, sonst könnte ein direkter Connect ein
X-Forwarded-For faken und den Limiter umgehen. Bei NPM-Setup
gewährleistet durch Docker-Network + nicht-veröffentlichten
Backend-Port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:24:21 +02:00
duffyduck 92d2e62e79 security: portalPasswordHash + Encrypted aus embedded customer in /contracts/* entfernen
Folgefix zum CRITICAL-IDOR auf Stressfrei-Sub-Routes: der separate
/customers/:id-Endpoint sanitizt seinen Output schon, aber GET /contracts/:id
embeddete weiterhin das volle Customer-Objekt inkl.
- portalPasswordHash (bcrypt-Hash des Portal-Login-Passworts)
- portalPasswordEncrypted (AES-256-GCM des Klartext-Passworts)
- portalPasswordResetToken (langlebiger 1-time-Token)

Zwei Lecks im contract.service:
- getContractById hatte `customer: true` ohne Sanitize
- createContract hatte dasselbe Muster

Beide jetzt mit sanitizeCustomerStrict() nach dem Load. Der Helper war schon
im utils/sanitize.ts vorhanden – wurde nur nicht aufgerufen.

Live-verifiziert: GET /api/contracts/1 → embedded customer enthält 30 saubere
Felder, KEIN portalPasswordHash/Encrypted/ResetToken mehr.

Weitere `customer: true`-Stellen geprüft und freigegeben:
- pdfTemplate.service.generateFilledPdf: nur internal, gibt PDF-Buffer zurück
- cachedEmail.controller.saveEmailAsPdf: nur internal für File-Ops
- getAllContracts: schon mit explizitem Select (5 sichere Felder)
- updateContract: kein customer-Include

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:01 +02:00
duffyduck 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>
2026-05-16 17:47:54 +02:00
duffyduck 72f7a9dbdb docs: BREACH-/-Marker konkret entfernen – exact-match-Location erklärt
Folge zur User-Frage: Snippet auch für / anwenden. Wichtiger Punkt
dokumentiert: NPM-Custom-Location mit prefix-`/` würde ALLE Pfade
außer /api/* fangen (auch /assets/*.js) → JS-Bundle unkomprimiert
~500 KB statt 150 KB. Stattdessen exact-match `location = /` nutzen,
das fängt nur die Root-URL ohne weitere Pfad-Komponente.

Zwei Varianten dokumentiert:
- Variante A: Custom Location im NPM-UI mit „= /" (falls Feld das
  akzeptiert)
- Variante B: server-level snippet im Advanced-Tab des Proxy-Hosts

Plus Verifikations-Befehle für „/" ohne gzip + „/assets/*.js" weiter
mit gzip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:35:48 +02:00
duffyduck c5dc271759 docs: BREACH-Marker auf SPA-Root / als bewusst akzeptiert dokumentieren
Pentest-Tools (testssl) melden BREACH weiter für die Root-URL, weil
die SPA-index.html bewusst weiter gzip-komprimiert ausgeliefert wird
(Performance: 50 KB → ~10 KB). Das ist nicht ausnutzbar, weil keine
Secrets/Reflektionen im HTML-Body sind. README erklärt jetzt explizit
warum + wie man es trotzdem loswerden kann (zusätzliche NPM-Custom-
Location für /, Trade-off: 40 KB extra pro Tab-Reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:59:49 +02:00
duffyduck 1451e362ff chore(env): JWT_EXPIRES_IN 15m + JWT_REFRESH_EXPIRES_IN dokumentieren
Folge-Aufräumen zur Refresh-Cookie-Migration:
- .env.example: JWT_EXPIRES_IN von 7d auf 15m (Access-Token-Lifetime),
  neue JWT_REFRESH_EXPIRES_IN=7d. Kommentar erklärt das Access-/Refresh-
  Pattern (Memory vs. httpOnly-Cookie, transparenter Refresh).
- docker-compose.yml: durchreichen + Default mit 15m statt 7d, plus
  JWT_REFRESH_EXPIRES_IN als neue Variable.

Bestandsinstallationen mit altem JWT_EXPIRES_IN=7d in der .env
funktionieren weiter (die Variable überschreibt den Default), aber bei
neuen Setups ist sofort der Branchenstandard aktiv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:53:40 +02:00
duffyduck 8188d17c87 fix(gdpr): processedBy aus useAuth statt totem localStorage('user')
localStorage('user') wird seit dem AuthContext-Umbau (Refresh-Cookie-
Pattern) nirgendwo mehr gesetzt → liefert immer null → der Fallback
ließ den `processedBy` in der GDPR-Verarbeitungs-Spur immer auf
'System' fallen, auch wenn ein echter User die Aktion ausgelöst hat.

Subtiler Audit-Trail-Bug, kein Sicherheitsproblem (User-Identitätsdaten
sind kein Geheimnis und waren im React-State eh sichtbar). Aber
funktional jetzt korrekt: useAuth().user.email landet als
`processedBy` im Backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:51:02 +02:00
duffyduck c4e62f0f50 docs: Pentest-Runden 11 + 12 in SECURITY-HARDENING + README aktualisieren
SECURITY-HARDENING.md:
- Runde 11 "Externer Pentest-Folge: Header-Hygiene + Klartext-Audit":
  HSTS-Doppel-Header weg, Cache-Control je nach Pfad differenziert,
  CSP No-Fallback-Direktiven + frame-ancestors auf 'self', BREACH-
  Mitigation via gzip off im Reverse-Proxy für /api/*, Server-/
  X-Served-By-Banner entfernt, Audit-Log für die 6 Klartext-Passwort-
  Read-Endpoints (CRITICAL).
- Runde 12 "JWT raus aus localStorage": Branchenstandard-Refresh-Cookie-
  Pattern für die SPA. Access-Token (15 min) nur in JS-Memory,
  Refresh-Token (7d) im httpOnly-Cookie. Auth-Middleware verweigert
  Refresh-Tokens als Bearer (type-Claim). Axios-Interceptor mit
  Single-Flight-Refresh-Retry. Tabelle der Live-Tests.

README.md:
- Tech-Stack-Auth-Zeile beschreibt jetzt die Access/Refresh-Architektur
- .env-Beispiel: JWT_EXPIRES_IN=15m + neue JWT_REFRESH_EXPIRES_IN=7d
- Production-Deployment-Hinweis: Frontend und API müssen über dieselbe
  Origin laufen (SameSite=Strict-Cookie), sonst funktioniert /auth/refresh
  cross-site nicht und User wird alle 15 min ausgeloggt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:12:05 +02:00
duffyduck 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
duffyduck 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>
2026-05-16 15:33:26 +02:00
duffyduck e2cd26a29e feat(monitoring): Manueller Refresh-Button im Sicherheits-Monitoring-Log
Auto-Refresh läuft schon alle 30 s im Hintergrund (refetchInterval),
aber wer nach einem getesteten Login-Versuch sofort sehen will, ob
das Event im Log landet, will nicht 30 s warten. Refresh-Button neben
"Log leeren" + "Pro Seite"-Selector invalidiert den
monitoring-events-Query → sofortiger Refetch. Spin-Animation während
des Loads, deaktiviert wenn schon ein Load läuft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:20:00 +02:00
duffyduck f6df97226d feat(email): Weiterleiten + Erneut senden im Detail-Pane
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>
2026-05-16 14:46:44 +02:00
duffyduck 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>
2026-05-16 14:31:43 +02:00
duffyduck 51eb12b414 fix(stressfrei): Refresh-Button nur bei provisioned + Auto-Heilung im Status-Sync
User-Feedback: der Refresh-Button war auch bei nicht-provisionierten
Adressen sichtbar (die nur als DB-Eintrag ohne Plesk-Pendant existieren).
Klick darauf gab korrekt einen Fehler, war aber unschön.

Bedingung wieder auf `emailItem.isProvisioned` einschränken. Für
historische Einträge, bei denen das Flag wegen des alten Bugs nie
gesetzt wurde, gibt es jetzt einen automatischen Reconcile-Pfad:

`syncMailboxStatus` (wird beim Öffnen jedes Edit-Modals aufgerufen)
prüft nicht mehr nur `hasMailbox`, sondern auch `isProvisioned`:
- Provider antwortet "existiert" + DB sagt isProvisioned=false
  → DB-Flag auf true ziehen + provisionedAt setzen
- Provider antwortet "nicht da" + DB sagt isProvisioned=true
  → DB-Flag auf false (Adresse wurde im Plesk-UI manuell gelöscht)
- hasMailbox wird zusätzlich konsistent gehalten

Damit heilen sich falsch markierte Adressen automatisch, sobald der
User sie einmal aufmacht zum Bearbeiten – der Refresh-Button erscheint
dann beim Re-Open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:11:47 +02:00
duffyduck 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>
2026-05-16 14:06:26 +02:00
duffyduck 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>
2026-05-16 13:53:48 +02:00
duffyduck 083913cadb docs: README – more_clear_headers Server X-Served-By dazu (Banner weg)
Im selben /api/-Custom-Location-Block des BREACH-Fixes auch gleich die
Server-Banner-Hygiene ergänzt: `more_clear_headers Server X-Served-By;`
über das headers-more-Modul (bei NPM standardmäßig dabei) entfernt die
Information-Disclosure-Header, die Pentest-Tools wie Nikto sonst als
low-Finding flaggen.

Plus zusätzlicher Verifikations-curl, der prüft dass beide Header weg
sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:36:49 +02:00
duffyduck 4c0cc90734 docs: README – BREACH-Schutz via gzip off für /api/* am Reverse-Proxy
Pentest mit testssl markiert die Prod-Instanz wegen aktivierter gzip-
Komprimierung als BREACH-anfällig (CVE-2013-3587, "Ausnutzbar: Ja").
Die JWT-SPA-Architektur hält das Risiko praktisch klein, der Audit-
Marker bleibt aber medium.

README-Sektion „Production-Deployment" um expliziten Hinweis ergänzt:
gzip nur für statische Assets erlauben, für /api/* deaktivieren. Mit
Setup-Schritten für Nginx Proxy Manager (Custom Locations) und Plain
Nginx + Verifikationsbefehl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:33:54 +02:00
duffyduck 70e97d3ece security: Header-Hygiene-Runde 11 (Pentest-Cleanup)
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>
2026-05-16 11:46:49 +02:00
duffyduck 8dff0310a6 fix(csp): frame-ancestors auf 'self' – PDF-Vorschau-iframe ging nicht
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>
2026-05-07 20:10:42 +02:00
duffyduck ab971618d5 factory-import: --save-as-builtin Flag + README-Überarbeitung
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>
2026-05-07 20:04:02 +02:00
duffyduck 4407bbfbb8 factory-defaults: CLI-Sync zwischen dev und prod
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>
2026-05-07 19:51:19 +02:00
duffyduck 365c7994d5 factory-defaults: builtin-Werkseinstellungen beim Auto-Seed einspielen
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>
2026-05-07 19:41:16 +02:00
duffyduck 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>
2026-05-07 19:26:33 +02:00
duffyduck 45f63d1c48 docs: User-DSGVO-/Entwickler-Zugriff-Fix in Erledigt-Liste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:27:18 +02:00
duffyduck 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>
2026-05-07 18:27:02 +02:00
duffyduck 4201a90fd0 docs: HTTPS_ENABLED-Flag in Erledigt-Liste dokumentieren
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:00:56 +02:00
duffyduck 3fb1925a98 security: HTTPS-only-Header per HTTPS_ENABLED-Flag steuern
`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>
2026-05-07 18:00:01 +02:00
duffyduck 63ebf3e75f db: tsx in production-deps + npx-Prefix für seed-Command
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>
2026-05-07 17:23:06 +02:00
duffyduck 27a0fdbc45 db: Prisma-Migrations-System statt db push (datenerhaltend)
`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>
2026-05-07 17:02:35 +02:00
duffyduck 6f293211a4 docker: Auto-Seed bei leerer DB (kein RUN_SEED-Toggle nötig)
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>
2026-05-07 15:37:34 +02:00
duffyduck 70e5190594 docker: DATABASE_URL im entrypoint URL-encoden (Sonderzeichen-Bug auf Prod)
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>
2026-05-07 15:25:13 +02:00
duffyduck 7d07d52774 docker: App-User statt root für DB-Connection nutzen
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>
2026-05-07 15:06:43 +02:00
duffyduck 75c1f9a7bb docker-compose url 2026-05-07 14:58:13 +02:00
duffyduck 62010b05d5 .env: DATABASE_URL aus DB_*-Komponenten zusammenbauen (kein Doppel-Pflegen)
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>
2026-05-07 14:41:39 +02:00
duffyduck e401c11e40 removed docker veriosn from docker-compose.yml 2026-05-07 14:27:37 +02:00
duffyduck d206b360a6 security: Permissions-Policy-Header setzen (Pentest-Finding)
Helmet setzt Permissions-Policy nicht out-of-the-box. Eigene Middleware,
die alle nicht benötigten Browser-APIs deaktiviert:

  camera, microphone, geolocation, payment, usb, midi, hid,
  accelerometer, gyroscope, magnetometer, ambient-light-sensor,
  battery, idle-detection, encrypted-media, picture-in-picture,
  publickey-credentials-get, screen-wake-lock, xr-spatial-tracking,
  web-share, autoplay, display-capture, sync-xhr, clipboard-read,
  cross-origin-isolated  →  alle =()

Erlaubt für 'self':
  clipboard-write  (CopyButton-Komponenten)
  fullscreen       (falls Vorschau in Vollbild geöffnet wird)

Damit hat eingeschleustes JS keinen Zugriff auf sensible Browser-APIs,
selbst wenn XSS irgendwie durchrutschen sollte.

Live-verifiziert: Header gesetzt + sauber formatiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:25:27 +02:00
duffyduck 096aa63c6f security: Content-Security-Policy aktivieren (Pentest-Finding)
Bug: Stage-1-Kommentar behauptete fälschlicherweise, das Frontend setze
eine CSP via meta-Tag – passierte nie. Helmet-CSP war auf false, kein
CSP-Header im Response. Pentest-Tool hat das richtig moniert.

Fix: Helmet-CSP eingeschaltet mit SPA-tauglichen directives:
  default-src 'self'
  script-src 'self'        (Vite baut Module-Scripts zu separaten Files)
  style-src 'self' 'unsafe-inline'   (Tailwind/inline-styles)
  img-src self/data/blob   (base64-Avatare, blob-PDFs)
  font-src self/data
  connect-src 'self'       (API only)
  frame-ancestors 'none'   (Clickjacking-Schutz, ersetzt X-Frame-Options)
  object-src 'none'        (kein Flash/<object>)
  base-uri 'self'
  form-action 'self'
  upgrade-insecure-requests

Live-verifiziert:
- Frontend index.html hat keine inline-scripts und keine externen
  Resources (Vite-Production-Build) → CSP bricht nichts.
- Header gesetzt: Content-Security-Policy: default-src 'self'; script-src
  'self'; style-src 'self' 'unsafe-inline'; ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:22:01 +02:00
duffyduck 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>
2026-05-03 09:12:39 +02:00
duffyduck e763952a84 adminer: Theme-Bootstrap für Designs mit non-Standard CSS-Filenamen
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>
2026-05-02 10:26:20 +02:00
duffyduck 3823f8aa50 backup: SecurityEvent-Tabelle im Backup + Restore mit aufnehmen
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>
2026-05-02 10:19:18 +02:00
duffyduck 0671565433 docker: Runtime auf node:20-slim (Alpine→Debian) – Prisma+TLS-Kompatibilität
Bug: Im Container schlug Prisma + mariadb-Auth fehl.
- Prisma-Engine `linux-musl` braucht libssl.so.1.1 → Alpine 3.19+ hat
  nur openssl 3 → "shared library libssl.so.1.1 not found"
- mariadb-client unter Alpine warf "TLS/SSL error: SSL is required"

Fix: alle Stages (Frontend-build, Backend-build, Runtime) auf
node:20-slim (Debian-bookworm). glibc + openssl 3 ABI-kompatibel,
Prisma generiert linux-debian-Engine korrekt.

Plus: .dockerignore um data/, plesktest/, backup-Klone erweitert
(Build-Context war u.a. wegen MariaDB-Files mit restricted Permissions
nicht lesbar).
Plus: docker-compose.yml: version: '3.8' für docker-compose v1
Kompatibilität.

Live-verifiziert: docker-compose up -d --build → alle 3 Container
healthy, Login funktioniert, alte DB-Daten (3 Kunden, 15 Verträge,
144 SecurityEvents) erhalten via Volume-zu-Bind-Mount-Migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:05:37 +02:00
duffyduck e145edaa90 docker: zentrale .env + Compose mit MariaDB+OpenCRM+Adminer + Bind-Mounts
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>
2026-05-01 18:53:19 +02:00
duffyduck 3b4a680326 chore: backend/.env aus Git entfernt + .gitignore klargestellt
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>
2026-05-01 18:35:00 +02:00
duffyduck 389b878dbd Monitoring: Threshold-Debounce auf sliding-window (statt floor-to-hour)
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>
2026-05-01 10:11:52 +02:00
duffyduck 96feb6a663 v1.1.0: Production-readiness Release
- Backend + Frontend package.json: 1.0.0 → 1.1.0
- README:
  - Version-Badge oben
  - Features-Liste erweitert (Auto-Status, Monitoring, Hardening)
  - Neue "Production-Deployment"-Sektion mit Pflicht-Env, Reverse-Proxy-
    Hinweis, Default-Passwort-Warnung und Verweisen auf TESTING.md +
    SECURITY-HARDENING.md
  - Changelog für 1.1.0 + 1.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:42:56 +02:00
duffyduck 49905aa97e Monitoring: Pagination mit Seitenzahlen + Anfang/Ende-Buttons
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>
2026-05-01 09:36:40 +02:00
duffyduck 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>
2026-05-01 09:31:53 +02:00
duffyduck 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>
2026-05-01 09:25:47 +02:00
duffyduck 45fe270a38 Security-Hardening Runde 9: Diminishing returns
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>
2026-05-01 08:47:20 +02:00
duffyduck 73f271ae03 docs: todo.md von backend/ nach docs/ verschoben
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>
2026-05-01 08:23:27 +02:00
duffyduck 4385ae575d docs: Security-Hardening in eigene MD ausgelagert + Live-Tabellen
- 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>
2026-05-01 08:15:38 +02:00
duffyduck 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>
2026-05-01 07:59:19 +02:00
duffyduck 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>
2026-05-01 07:47:26 +02:00
duffyduck 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>
2026-04-26 20:14:20 +02:00
duffyduck 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
duffyduck 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>
2026-04-24 13:40:04 +02:00
duffyduck 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>
2026-04-24 10:20:30 +02:00
duffyduck 4e680a36e7 Auto-Vertragsstatus: nightly EXPIRED + Kündigungsbestätigung → CANCELLED
- 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>
2026-04-24 10:08:58 +02:00
duffyduck a129781035 docs(todo): Live-verifiziert-Tabelle für Security-Runde 4 ergänzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:01:26 +02:00
duffyduck 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>
2026-04-24 09:59:37 +02:00
duffyduck 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
duffyduck 301aafffd1 chore: helmet korrekt in backend/package.json statt Root
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>
2026-04-23 23:02:12 +02:00
duffyduck 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>
2026-04-23 22:59:28 +02:00
duffyduck 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
duffyduck 8fc050a282 docs: TESTING.md mit Check-Listen für Security + Email-Log-System
Strukturierter Test-Katalog für manuelle Abnahmetests vor einem Release.

Security-System (10 Abschnitte):
- Login + Rate-Limiting
- Passwort-Reset-Flow (Mitarbeiter + Portal)
- Rate-Limiting Passwort-Reset
- Berechtigungen (RBAC)
- Portal-Isolation (DSGVO-kritisch)
- Session-Invalidation
- Audit-Log
- DSGVO-Features (Export, Löschanfragen, Einwilligungen)
- Verschlüsselte Credentials
- DSGVO-Einwilligung sperrt Tabs für Mitarbeiter

Email-Log-System (5 Abschnitte):
- Email-Log-Seite (UI, Filter, Suche, Pagination)
- Alle 6 Kontexte durchspielen:
  consent-link, authorization-request, customer-email,
  birthday-greeting, birthday-greeting-auto, password-reset
- Fehlgeschlagener Versand wird geloggt (Test mit falschem SMTP-Passwort)
- Details-Modal mit SMTP-Details
- Automatisches Logging (Kontext, triggeredBy)

Außerdem: Neue Kontexte in EmailLogs.tsx CONTEXT_LABELS ergänzt
(birthday-greeting, birthday-greeting-auto, password-reset).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:34 +02:00
duffyduck 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 8d113f4c6b Backup/Restore: alle neuen Tabellen erfasst (43 Tabellen insgesamt)
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>
2026-04-23 16:53:26 +02:00
duffyduck fd480113d0 docs: Plesk API-Key - Hinweis dass 0.0.0.0 nicht funktioniert
-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>
2026-04-23 16:44:31 +02:00
duffyduck 95bf118fc2 docs: Plesk-API-Key-Anleitung in README ergänzt
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>
2026-04-23 16:27:54 +02:00
duffyduck 075c095b8e todo: SaaS-Ausbau-Plan (Instance-per-Customer + GoCardless) dokumentiert
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>
2026-04-23 16:24:37 +02:00
duffyduck 3fa1dce2dc UX: Label-Feld aus Provider-Formular entfernen
Problem: Das 'Bezeichnung für Kunden-E-Mails'-Feld (UI-Label) war irreführend.
Ein User hat dort die Domain 'stressfrei-meyer.xyz' eingetragen statt ins
Domain-Feld – das eigentliche Domain-Feld blieb unverändert, und das Label
zeigte dann unpassend die Domain-Schreibweise.

Fix: Das Label-Feld ist in 99% aller Fälle nicht nötig, weil es automatisch
aus der Domain abgeleitet wird (stressfrei-wechseln.de → Stressfrei-Wechseln).
Der Edge-Case 'komplett anderer Anzeigetext als aus Domain ableitbar' kommt
selten vor und kann später bei Bedarf über direkten DB-Zugriff/Developer-Panel
gesetzt werden.

Das Schema-Feld bleibt erhalten (für zukünftige Erweiterungen), nur das
Formular-Feld ist weg. Stattdessen Hinweistext unter dem Domain-Feld:
'Wird auch für die Kunden-E-Mail-Adressen genutzt (z.B. name@...)'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:00:41 +02:00
duffyduck b7d3654b72 Fix: Provider-Domain greift sofort + Domain-Validierung
Problem: Nach dem Ändern der Provider-Domain blieb die alte Domain
(stressfrei-wechseln.de) im Adress-Hinzufügen-Dialog bestehen, weil der
Frontend-Hook useProviderSettings() einen 5-Minuten staleTime hat und
nicht invalidiert wurde.

Fix:
- In allen Provider-Mutations (create/update/delete) wird jetzt auch
  'email-provider-public-settings' invalidiert → Domain & Label greifen
  sofort in allen Komponenten

Zusätzlich Domain-Validierung eingebaut:
- Frontend: pattern am Input + Live-Fehlermeldung
  Format: name.tld (mit Subdomains erlaubt, z.B. mail.meine-firma.de)
  Input auto-lowercase + trim
- Backend: validateDomain() in createProviderConfig/updateProviderConfig
  Wirft Error mit sprechender Meldung bei ungültigem Format
- Schützt vor Versehen im UI + direkten API-Aufrufen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:51:16 +02:00
duffyduck cdde7b4ab7 Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider
Alle hardcoded Referenzen auf 'stressfrei-wechseln.de' und 'Stressfrei-Wechseln'
durch dynamische Werte aus der EmailProviderConfig ersetzt. Notwendig für
Multi-Mandanten-Betrieb, wenn das CRM an Dritte vermietet wird.

Schema:
- Neues Feld EmailProviderConfig.customerEmailLabel (String?)
- Wenn leer, wird Label aus Domain abgeleitet ('stressfrei-wechseln.de' → 'Stressfrei-Wechseln')

Backend:
- Neuer Endpoint GET /api/email-providers/public-settings liefert { domain, customerEmailLabel }
- Neue Service-Funktionen: getProviderPublicSettings(), deriveLabelFromDomain()
- create/updateProviderConfig erweitert um customerEmailLabel

Frontend:
- Neuer Hook useProviderSettings() mit Auto-Caching
- Neues Eingabefeld 'Bezeichnung für Kunden-E-Mails' im Provider-Modal
- Dynamische Domain-Suffix im Adress-Hinzufügen-Dialog (@<domain>)
- Tab-Label 'Stressfrei-Wechseln' im Kunden-Detail → dynamisch
- 'Stressfrei-Wechseln Adresse' in ContractForm → dynamisch
- '(Stressfrei-Wechseln)' Badge in ContractDetail → dynamisch
- 'Stressfrei-Wechseln E-Mail' im Generate-Modal → dynamisch
- Leere-Zustand-Meldungen in Tab und E-Mail-Client → dynamisch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:43:19 +02:00
duffyduck cf4370c905 Toast-Benachrichtigungen bei IMAP-Sync- und SMTP-Send-Fehlern
Bisher blieb ein fehlgeschlagener IMAP-Sync oder E-Mail-Versand still – der User
sah nur im Browser-Devtools, dass etwas schief lief. Jetzt erscheint eine rote
Toast-Benachrichtigung (8 Sekunden) mit der konkreten Fehlermeldung des Servers,
z.B. 'Sync fehlgeschlagen: IMAP-Authentifizierung fehlgeschlagen: NO [AUTHENTICATIONFAILED]'.

EmailClientTab (Synchronisieren-Button):
- toast.success bei erfolgreichem Sync
- toast.error bei Fehler + bei Backend-Response mit success=false

ComposeEmailModal (Senden):
- toast.success bei erfolgreichem Versand
- toast.error bei SMTP-Fehler mit Server-Response (zusätzlich zum Inline-Fehler)

Außerdem im imapService.testImapConnection:
- Roh-Error wird jetzt geloggt (code, response, responseStatus, authenticationFailed)
- ImapFlow-spezifische Felder werden in die Fehlermeldung übernommen, sodass
  z.B. '2 NO [AUTHENTICATIONFAILED] Authentication failed.' direkt sichtbar wird

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:16:04 +02:00
duffyduck 1de8fb9847 Fix: IMAP/SMTP mit älteren TLS-Versionen zulassen
Der Fehler 'Client network socket disconnected before secure TLS connection
was established' tritt auf, wenn der Mailserver nur alte TLS-Versionen (1.0/1.1)
oder legacy Cipher-Suites anbietet - Node.js 20+ schließt dann den Socket, noch
bevor überhaupt ein Zertifikat gesehen wird. Das Häkchen 'Selbstsignierte
Zertifikate erlauben' greift zu spät, weil der Handshake gar nicht startet.

Fix: Wenn 'Selbstsignierte Zertifikate erlauben' aktiv ist, setzen wir gleich
auch minVersion='TLSv1' und ciphers='DEFAULT:@SECLEVEL=0'. Damit akzeptiert
Node.js auch alte Cipher-Suites und TLS-Versionen des Mailservers.

Bei aktivem 'allowSelfSignedCerts' heißt das zusammen:
- rejectUnauthorized: false (Zertifikate akzeptieren auch wenn selbstsigniert)
- minVersion: 'TLSv1' (auch alte TLS-Versionen zulassen)
- ciphers: 'DEFAULT:@SECLEVEL=0' (auch schwache Ciphers zulassen)

Refactor:
- imapService: neuer Helper buildTlsOptions() – ersetzt 8 identische
  Inline-Setups, damit die Fix-Logik zentral gepflegt wird
- smtpService: tls-Type erweitert (minVersion/ciphers), gleiche Logik

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:05:04 +02:00
duffyduck fd55f3129f E-Mail-Zugang Test (IMAP + SMTP) in Provider-Einstellungen
Das bestehende „Verbindung testen" prüft nur den API-Zugang (Plesk/cPanel),
nicht den eigentlichen IMAP/SMTP-Zugang der System-E-Mail. Das führte dazu,
dass Anhang-Downloads scheiterten obwohl der API-Test grün war.

Neuer Button im EmailProviders-Modal: „E-Mail-Zugang testen (IMAP + SMTP)"
- Testet IMAP-Empfang und SMTP-Versand separat
- Zeigt pro Protokoll Erfolg oder Fehlermeldung mit Server/Port/Verschlüsselung
- Nutzt die hinterlegte System-E-Mail-Adresse + Passwort
- Funktioniert auch vor dem ersten Speichern (mit Formulardaten)

Außerdem im Anhang-Download:
- Retry-Mechanismus bei transienten TLS/Netzwerk-Fehlern (3 Versuche)
- Socket-Timeout 30s gegen hängende Verbindungen
- Sprechende Fehlermeldungen (z.B. Hinweis auf selbstsigniertes Zertifikat)
- Debug-Logging mit Host/Port/User/Folder/UID

Backend:
- Neuer Endpoint POST /api/email-providers/test-mail-access
- fetchAttachment in imapService: Retry-Wrapper + fetchAttachmentInner
- Besseres Error-Handling in downloadAttachment (Cert-Hinweis, Auth, Timeout)

Frontend:
- emailProviderApi.testMailAccess()
- EmailProviders-Modal: neuer Button + zweispaltige Ergebnis-Anzeige für IMAP+SMTP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:59:06 +02:00
duffyduck 109f774d62 docs: Factory-Defaults Import/Export-Anleitung in READMEs
Haupt-README.md: neuer Abschnitt mit Abgrenzung zu Datenbank-Backup, Schritt-
für-Schritt-Anleitung für Export und Import, Idempotenz-Hinweis, Berechtigungen.

backend/factory-defaults/README.md: ausführliche Referenz mit Struktur-Beispielen
aller JSON-Dateien (Provider, CancellationPeriod, ContractDuration,
ContractCategory, PdfTemplate), Teil-Import-Anleitung, Merge-Beispiele.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:19:02 +02:00
duffyduck 60dc98e265 Factory-Defaults: Export + Import von Stammdaten-Katalogen
Ein neues System um Stammdaten-Kataloge zwischen Installationen zu teilen –
explizit ohne Kundendaten, Verträge oder Einstellungen.

**Was wird exportiert:**
- Anbieter + zugehörige Tarife
- Kündigungsfristen
- Vertragslaufzeiten
- Vertragskategorien
- PDF-Auftragsvorlagen (JSON + PDF-Dateien + Feldzuordnungen)

**Was NICHT:**
- Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen
  → dafür gibt es den Datenbank-Backup

**Neue Einstellungsseite /settings/factory-defaults:**
- Zeigt Anzahl pro Kategorie (Anbieter, Tarife, Fristen, …)
- "Exportieren"-Button lädt ZIP herunter (manifest.json + JSONs + PDFs)
- Import-Anleitung inline

**Import-Script:**
- `npm run seed:defaults` (tsx scripts/seed-factory-defaults.ts)
- Liest alle JSON-Dateien aus backend/factory-defaults/*/*.json
- Merged mehrere Dateien automatisch pro Kategorie (unique-key gewinnt zuletzt)
- Upsertet idempotent → kann mehrfach ausgeführt werden
- Kopiert PDF-Vorlagen aus factory-defaults/pdf-templates/ nach uploads/pdf-templates/
- Alte PDF-Dateien werden beim Re-Import entsorgt

Backend:
- services/factoryDefaults.service.ts: collectFactoryDefaults() + exportFactoryDefaults()
- controllers/factoryDefaults.controller.ts: preview + export
- routes/factoryDefaults.routes.ts: GET /api/factory-defaults/preview + /export
- scripts/seed-factory-defaults.ts: CLI-Import-Script
- .gitignore: factory-defaults/* außer .gitkeep und README.md

Frontend:
- pages/settings/FactoryDefaults.tsx: Übersicht + Export-Button
- Settings-Karte „Factory-Defaults" im System-Abschnitt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:10:12 +02:00
duffyduck b78afce43c Fix: Anrede per Du/Sie wird nicht gespeichert
Das useInformalAddress-Feld war:
1. Im Frontend-Submit-Handler nicht in submitData enthalten (wurde bei jedem Update rausgefiltert)
2. Im Service-Type nicht definiert (TypeScript-mäßig unbekannt)
3. Beim Laden im Edit-Mode: Boolean aus DB matchte nicht das String-value des <select>

Fixes:
- Frontend: submitData enthält jetzt useInformalAddress (String oder Boolean → sauberes Boolean)
- Frontend: beim reset() wird Boolean zu 'true'/'false' konvertiert für <select>
- Backend: Service-Type erweitert um useInformalAddress, autoBirthdayGreeting, autoBirthdayChannel
- Backend: Audit-Feldlabels für die neuen Felder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:10:03 +02:00
duffyduck 2879bd64d6 Email-Anhänge als Vertragsdokumente + Rechnungen für alle Vertragstypen
Der SaveAttachmentModal hat jetzt drei Modi (wenn E-Mail einem Vertrag zugeordnet ist):

1. Als Dokument – in feste Slots (Kündigungsschreiben etc.), unverändert
2. Als Vertragsdokument – NEU: flexible ContractDocument-Tabelle mit Typ-Dropdown
   (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht,
   Widerrufsbelehrung, Preisblatt, Sonstiges) + optionalen Notizen
3. Als Rechnung – jetzt für ALLE Vertragstypen (vorher nur Strom/Gas)

Backend:
- Neuer Endpoint POST /api/emails/:id/attachments/:filename/save-as-contract-document
- saveAttachmentAsInvoice + saveEmailAsInvoice: ELECTRICITY/GAS-Einschränkung entfernt,
  nutzt jetzt addInvoiceByContract als Fallback für Nicht-Energie-Verträge

Frontend:
- cachedEmailApi.saveAttachmentAsContractDocument hinzugefügt
- SaveAttachmentModal: neuer Mode 'contractDocument' mit Typ+Notizen
- Mode-Toggle zeigt jetzt alle drei Optionen wenn Vertrag zugeordnet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:06:10 +02:00
duffyduck aa2b5ce785 Fix: Emoji im Plain-Text für Messenger entfernt
Das Sternchen-Emoji 🌟 im Geburtstagsgruß wurde in WhatsApp Web als Fragezeichen angezeigt
(URL-Encoding-Problem). Für Messenger-Kanäle bleibt der Text jetzt emoji-frei, per E-Mail
werden die Emojis weiterhin korrekt dargestellt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:55:06 +02:00
duffyduck 9d6bd68ddc Geburtstag-Management-Modal mit Reset + Send + Auto-Flag
Neuer Cake-Button neben dem Geburtsdatum in den Stammdaten öffnet ein Modal
mit drei Funktionen:

1. **Gruß-Marker zurücksetzen** (lastBirthdayGreetingYear → null)
   - Für Debugging oder als Fallback, wenn der Kunde den Gruß erneut sehen soll
   - Mit Bestätigungsdialog

2. **Geburtstagsgruß jetzt senden** (Email / WhatsApp / Telegram / Signal)
   - Email: direkt via System-SMTP mit HTML-Template (Du/Sie-abhängig)
   - WhatsApp/Telegram/Signal: öffnet vorbefülltes Fenster mit Gruß-Text
   - Text beachtet Du/Sie-Verhältnis (pronomen, possessiv, etc.)
   - Mit Bestätigungsdialog

3. **Automatisch senden** – neue Einstellung am Customer
   - autoBirthdayGreeting (Boolean) + autoBirthdayChannel (String)
   - Für späteren Cron-basierten Automatik-Versand vorbereitet

Backend:
- birthday.service.ts: resetBirthdayGreeting, buildBirthdayGreetingText, getBirthdayGreetingData
- birthday.controller.ts: resetBirthdayGreeting, sendBirthdayGreeting
- Routes: POST /birthdays/:customerId/reset + /send
- Audit-Log bei beiden Aktionen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:46:03 +02:00
duffyduck 2a3928d0e7 Anrede-Verhältnis Du/Sie pro Kunde + Geburtstagsgruß respektiert Anrede
Schema:
- Customer.useInformalAddress: Boolean (Default: false = Sie)
- Auch bei Firmenkunden verfügbar (Chef kann man auch duzen)

Frontend:
- Neues Pflichtfeld "Anrede per" (Du/Sie) im Kunden-Formular
- Anzeige als Badge in CustomerDetail-Stammdaten

Geburtstagsgruß im Portal:
- Bei Du: "Herzlichen Glückwunsch, Max! Alles Gute zu deinem 42. Geburtstag!"
- Bei Sie: "Herzlichen Glückwunsch, Herr Müller! Alles Gute zu Ihrem 42. Geburtstag!"
- Konsistent auch bei nachträglichen Glückwünschen (hattest/hatten, bist/sind etc.)
- Backend liefert firstName, lastName, salutation und useInformalAddress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:27:23 +02:00
duffyduck ba29711ee7 Geburtsdatum + Geburtsort auch bei Firmenkunden anzeigen/bearbeiten
Bisher wurden die Felder nur bei Privatkunden angezeigt. Jetzt sind sie
unabhängig vom Kundentyp verfügbar, z.B. für Ansprechpartner bei Firmen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:05:44 +02:00
duffyduck 888c75bb41 todo: Factory-Defaults Abgrenzung zu Backup klarstellen
Explizit aufgenommen: KEINE Kundendaten, Dokumente, Emails oder Einstellungen.
Nur reine Stammdaten-Kataloge. Für vollständige Backups gibts den separaten Export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:56 +02:00
duffyduck 5adc71e52c todo: Factory-Defaults Export/Import als neuer Punkt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:59:24 +02:00
duffyduck c93086059d chore: Build-Artefakte und node_modules aus Tracking entfernen
- backend/dist, backend/node_modules aus Git-Tracking entfernt (waren bereits in .gitignore)
- frontend/dist, frontend/node_modules ebenfalls entfernt
- Neue frontend/.gitignore erstellt (fehlte komplett)

Die Dateien bleiben lokal erhalten und werden durch npm install + build regeneriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:09 +02:00
duffyduck b47f33aaa5 todo: Geburtstagskalender als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:48 +02:00
duffyduck 018784cca6 Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal
Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:20 +02:00
duffyduck 2775e9d4dc todo: Vertragslisten-Erweiterung als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:20:01 +02:00
duffyduck 0d58b79836 Typspezifische Zusatzinfos in Vertragslisten
Jede Vertragszeile zeigt jetzt eine kontextspezifische Zusatzinfo an:
- Strom/Gas: "Lieferadresse: Musterstr. 12, 12345 Berlin"
- DSL/Glasfaser/Kabel: "Anschlussadresse: ..."
- Mobilfunk: "Rufnummer: 0171 1234567" (Hauptkarte bevorzugt)
- KFZ: "Kennzeichen: HB-AB 123"

Sichtbar in:
- Admin-Vertragsliste (/contracts)
- Portal-Vertragsliste (Baumansicht)
- Kunden-Detail → Verträge-Tab

Backend: getAllContracts + getContractTreeForCustomer liefern
mobileDetails (mit simCards), carInsuranceDetails und address mit.

Frontend: Neuer Helper utils/contractInfo.ts mit getContractTypeInfo,
aus dem sowohl Label als auch Wert pro Typ kommt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:04 +02:00
duffyduck eaf7d1eac3 update todo.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:18:33 +02:00
duffyduck 9a84e2d3cb PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung
- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00
duffyduck 9fa1cbc591 delete test pdf 2026-03-27 12:12:22 +01:00
duffyduck a0705b1a61 email datenschztz erst alles bestätigt bei allen hebeln 2026-03-27 12:03:20 +01:00
duffyduck 0e75e6c8e5 added place to telecommunication, added contract documents, added invoice to other contracts 2026-03-25 16:55:48 +01:00
duffyduck a15772cb54 impressum datenschutz added 2026-03-25 15:25:34 +01:00
duffyduck fd55742c57 complete new audit system 2026-03-21 18:23:54 +01:00
duffyduck 38b3b7da73 Datenschutz vollmacht fixed, two time counter added 2026-03-21 16:42:31 +01:00
duffyduck eecc6cd73e fixed back button with source, and customer in customer lsit clickable 2026-03-21 12:16:04 +01:00
duffyduck d7b42f64b1 fixed all back buttons 2026-03-21 12:03:32 +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
duffyduck 09e87c951b added docker setup 2026-02-08 19:59:49 +01:00
duffyduck 468907c9c3 added recovery entires, changed recovery icon 2026-02-08 19:43:46 +01:00
duffyduck e0fc26795e added contract history 2026-02-08 19:24:37 +01:00
duffyduck 746706ef01 added date at support ticket, new order support tickets, delete edit support ticktes only from enploye and admins 2026-02-08 18:26:34 +01:00
duffyduck 4f588015a4 contractnumber provider added, old provider number field only if no previous contact exist 2026-02-08 14:34:56 +01:00
duffyduck 55f257fffd readme updated 2026-02-08 13:14:24 +01:00
duffyduck 2ab2bb7562 snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug 2026-02-08 13:08:58 +01:00
duffyduck 839bb40f5e fixed issue stressfrei adress as username not filled oin cockpit 2026-02-08 09:09:00 +01:00
duffyduck b397a974df updated readme.md 2026-02-08 01:21:00 +01:00
duffyduck ad2b8ea5b6 added invoices and status in cockpit, created info button for contract status types 2026-02-08 01:18:12 +01:00
duffyduck 89d528bb77 addes cost and usage calculation 2026-02-06 00:14:38 +01:00
duffyduck e8919cfa81 added extra field kwh at m3, expand cost field to 10 komma, added maloid,counter add dialog, auto set unit 2026-02-05 20:34:45 +01:00
duffyduck 80dd5cc157 contractmodaldetail date format and before contract and next contract question to add 2026-02-04 21:17:13 +01:00
duffyduck f33d157b9b save email as pdf likae attachment version 2 2026-02-04 19:49:09 +01:00
duffyduck 8c65fecef0 remove uploads from repo but keep empty folder 2026-02-04 19:19:18 +01:00
duffyduck 866a285037 save email as pdf like an attachment 2026-02-04 19:18:32 +01:00
duffyduck c9d1ad7796 added tree view to customer portal, in employe its uses still list 2026-02-04 16:30:49 +01:00
duffyduck f21eb20715 seperate delivery and billig adresses in contract added 2026-02-04 08:48:25 +01:00
duffyduck 7a0f4461aa fixed, bankcard, adresses, id card, tarif name dropdown menu in edit mode 2026-02-04 08:37:46 +01:00
duffyduck d8ced5cb24 added new view in contracts customer and contracts 2026-02-04 00:52:04 +01:00
duffyduck b87053760e save attachment from email in customer data and - or contracts 2026-02-03 23:58:00 +01:00
duffyduck 30103e6099 update readme.md 2026-02-03 23:08:08 +01:00
duffyduck bf068276b5 all email views the same 2026-02-03 23:04:42 +01:00
duffyduck 9256d9397b optimize password view in stressfrei addresses 2026-02-03 15:43:00 +01:00
duffyduck 8c9e61cf17 added backup and email client 2026-02-01 00:02:35 +01:00
duffyduck ef18381dd8 imapclient feature plan 2026-01-29 01:34:43 +01:00
Stefan Hacker e209e9bbca first commit 2026-01-29 01:16:54 +01:00
153 changed files with 12676 additions and 1932 deletions
+9
View File
@@ -46,6 +46,15 @@ backups
backend/uploads backend/uploads
backend/backups backend/backups
# Daten-Verzeichnis (Bind-Mounts zur Laufzeit, nicht im Build-Context)
data/
# Plesk-Test (nicht für Container)
plesktest/
# Backup-Klone des Repos
opencrm-backup-*/
# Prisma migrations (included, but not dev db) # Prisma migrations (included, but not dev db)
*.db *.db
*.db-journal *.db-journal
+94
View File
@@ -0,0 +1,94 @@
# OpenCRM zentrale Konfiguration
# ==================================
# Kopiere diese Datei zu .env und passe die Werte an.
# Diese .env wird sowohl vom Backend (npm run dev) als auch von Docker
# Compose verwendet.
# ============== PORTS (extern erreichbar auf dem Host) ==============
OPENCRM_PORT=3010 # Backend + Frontend (alles unter einer URL)
ADMINER_PORT=8090 # Adminer (Datenbank-UI). 8081 ist häufig schon belegt.
DB_PORT=3306 # MariaDB extern (für lokale Tools/Dev). 0 = nicht freigeben.
# ============== DATEN-PFADE (Bind-Mounts) ==============
# Relativ zum Projektverzeichnis. Werden zur Laufzeit angelegt.
DATA_DIR=./data
DB_DATA_DIR=./data/db
UPLOADS_DIR=./data/uploads
FACTORY_DEFAULTS_DIR=./data/factory-defaults
BACKUPS_DIR=./data/backups
# ============== DATENBANK ==============
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
# angelegt (über MARIADB_USER/MARIADB_PASSWORD im docker-compose) mit
# GRANT ALL PRIVILEGES auf ${DB_NAME}.*. Damit nutzt das Backend NICHT root.
# DB_ROOT_PASSWORD ist nur für Adminer / Notfall-Wartung.
DB_HOST=localhost # Im Container überschreibt docker-compose das auf "db"
DB_NAME=opencrm
DB_USER=opencrm
DB_PASSWORD=change-this-password
DB_ROOT_PASSWORD=change-this-root-password
# Connection-String wird aus den DB_*-Komponenten zusammengebaut (dotenv-expand).
# Manuell überschreiben nur wenn Sonderfälle (z.B. extra Query-Parameter).
# Hinweis: für lokales Dev mit MariaDB im Container nutze DB_HOST=localhost,
# weil docker-compose den DB-Port auf 127.0.0.1:DB_PORT mappt.
DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
# ============== SECURITY ==============
# JWT-Secret: min. 32 Zeichen. Generieren: openssl rand -hex 64
# Wird sowohl für Access- als auch Refresh-Token verwendet.
JWT_SECRET=change-this-to-a-very-long-random-secret-please-rotate-before-production
# Access-/Refresh-Token-Lifetimes
# - Access-Token: kurzlebig, lebt nur im Browser-Memory (XSS-Schutz)
# - Refresh-Token: lang, im httpOnly-Cookie (JS-unzugänglich)
# Wenn der Access abläuft, holt das Frontend transparent einen neuen über
# /api/auth/refresh User merkt nichts. Logout invalidiert beide sofort.
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Encryption-Key für Portal-Credentials: GENAU 64 Hex-Zeichen.
# Generieren: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
# Server
NODE_ENV=development
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127.0.0.1
# CORS nur in Production setzen, wenn Frontend auf separater Domain läuft.
# Beispiel: CORS_ORIGINS=https://crm.deine-domain.de
# CORS_ORIGINS=
# HTTPS-only-Header (HSTS + upgrade-insecure-requests) NUR aktivieren, wenn
# wirklich ein TLS-Proxy (Caddy/Traefik/Nginx) vor OpenCRM steht. Sonst sperrt
# sich der Browser bei direktem http://ip:port-Zugriff selbst aus
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
HTTPS_ENABLED=false
# SSRF-Schutz: private IP-Ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost) bei Provider/SMTP-Test-Connection
# blockieren. Default `false` damit On-Prem-Setups Plesk/Dovecot/Postfix auf
# 127.0.0.1 oder im internen Netz nutzen können. Für Cloud-Deployments
# (öffentlich erreichbares Backend) auf `true` setzen, sonst kann ein
# eingeloggter Admin via /email-providers/test-connection interne Services
# anpingen. Cloud-Metadata-Endpoints (169.254.169.254 etc.) sind UNABHÄNGIG
# vom Flag immer geblockt.
SSRF_BLOCK_PRIVATE_IPS=false
# ============== ADMINER (DB-UI) ==============
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
# haeckel, hever, konya, lavender-light, lucas-sandery, mancave,
# mvt, nette, ng9, nicu, pappu687, paranoiq, pepa-linha, pokorny,
# price, rmsoft, rmsoft_blue, rmsoft_blue-dark, win98
# Empfehlung: dracula (dark) oder adminer-dark beide modern.
ADMINER_DESIGN=dracula
# ============== SEED ==============
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
# + Stammdaten an) nichts zu konfigurieren.
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
# wieder zurück.
RUN_SEED=false
+41
View File
@@ -0,0 +1,41 @@
# Root-Gitignore: gemeinsame Patterns für Repo-Root + nested Verzeichnisse
# (backend/, frontend/, docker/ haben zusätzlich eigene .gitignore-Files)
# Environment echte Secrets blocken, .env.example weiter mittracken
.env
.env.local
.env.*.local
!.env.example
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Temp
tmp/
*.tmp
*.bak
# Docker-Bind-Mounts: Inhalt nicht tracken, Verzeichnisstruktur via .gitkeep behalten
data/db/*
!data/db/.gitkeep
data/uploads/*
!data/uploads/.gitkeep
data/factory-defaults/*
!data/factory-defaults/.gitkeep
data/backups/*
!data/backups/.gitkeep
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
factory-exports/*
!factory-exports/.gitkeep
+437 -66
View File
@@ -2,6 +2,8 @@
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung). Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
**Version: 1.1.0** ([Changelog](#changelog))
## Features ## Features
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten - **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
@@ -11,6 +13,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie - **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload - **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen) - **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT``ACTIVE` (mit Vertragsbeginn),
Kündigungsbestätigung-Upload setzt `ACTIVE``CANCELLED` (mit Datum),
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
- **Verträge**: - **Verträge**:
- Energie (Strom, Gas) - Energie (Strom, Gas)
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV) - Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
@@ -20,7 +25,14 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin - **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal - **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt - **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen - **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
Sofort-E-Mail-Alerts und stündlichem Digest siehe Einstellungen → Monitoring
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm - **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
## Tech Stack ## Tech Stack
@@ -29,38 +41,70 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Backend**: Node.js, Express 4.x, TypeScript - **Backend**: Node.js, Express 4.x, TypeScript
- **Datenbank**: MariaDB - **Datenbank**: MariaDB
- **ORM**: Prisma - **ORM**: Prisma
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle - **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt: > **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
> - Express 4.x → `@types/express@^4.17.x` > - Express 4.x → `@types/express@^4.17.x`
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen) > - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
## Quick-Start mit Docker (empfohlen)
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Werte anpassen, Secrets rotieren!
docker compose up -d
```
Browser:
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
Alle persistenten Daten liegen in `./data/`:
| Pfad | Inhalt |
|------|--------|
| `./data/db/` | MariaDB-Datafiles |
| `./data/uploads/` | User-Uploads (PDFs, Bilder) |
| `./data/factory-defaults/` | Stammdaten-Kataloge |
| `./data/backups/` | DB-Backups (`npm run db:backup`) |
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
> der initiale Admin-User `admin@admin.com` / `admin`.
## Voraussetzungen ## Voraussetzungen
- Node.js 18+ (empfohlen: 20+) - Docker & Docker Compose v2
- Docker & Docker Compose - Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
- npm
## Installation ## Installation für Entwicklung (ohne Container)
### 1. Repository klonen ### 1. Repository klonen
```bash ```bash
git clone <repository-url> git clone <repository-url>
cd opencrm cd opencrm
cp .env.example .env # Konfiguration anpassen
``` ```
### 2. MariaDB-Datenbank starten ### 2. MariaDB-Container starten
```bash ```bash
docker-compose up -d docker compose up -d db
``` ```
Dies startet einen MariaDB-Container mit: Das startet nur die Datenbank (mit Daten in `./data/db/`).
- **Port:** 3306 Konfiguration kommt aus `./.env`:
- **Datenbank:** opencrm
- **Root-Passwort:** rootpassword - **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
- **Benutzer:** opencrm / opencrm123 - **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
Warte ca. 10 Sekunden bis die Datenbank bereit ist. Warte ca. 10 Sekunden bis die Datenbank bereit ist.
@@ -82,9 +126,14 @@ Die `.env`-Datei sollte folgende Werte enthalten:
# Database # Database
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm" DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT # JWT Access-/Refresh-Token-Pattern (SPA-Standard)
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
# Refresh-Token (httpOnly-Cookie, lang)
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production" JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d" JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
# Encryption (for portal credentials) - generate with: openssl rand -hex 32 # Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -140,6 +189,197 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** admin@admin.com - **E-Mail:** admin@admin.com
- **Passwort:** admin - **Passwort:** admin
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
> ändern und Secrets rotieren siehe [Production-Deployment](#production-deployment).
## Production-Deployment
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
```env
NODE_ENV=production
# Pflicht-Rotation per `openssl rand` neu generieren!
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
```
### Deployment-Modus: On-Prem vs. Cloud
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
SSRF-Schalter:
```env
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
SSRF_BLOCK_PRIVATE_IPS=true
```
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` wenn Frontend
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
auf derselben Domain via Proxy-Path.
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
hinterlegen, Test-Alert senden, Digest aktivieren.
- Vollständige Hardening-Story + restliche Trade-offs:
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
### ⚠️ Wichtig: gzip für `/api/*` am Reverse-Proxy deaktivieren (BREACH-Schutz)
Wenn ein TLS-Reverse-Proxy (Nginx Proxy Manager, Caddy, eigener Nginx, …) HTTPS
terminiert und Antworten gzip-komprimiert, ist die **BREACH-Attacke** (CVE-2013-3587)
theoretisch möglich: aus der gzip-komprimierten Response-Größe könnten unter
ungünstigen Umständen Secrets erraten werden. Auch wenn unsere JWT-basierte SPA
das Risiko praktisch klein hält (keine reflektierten Secrets im Response-Body),
geht ein Penetration-Test mit testssl trotzdem auf „medium Ausnutzbar: Ja".
**Lösung:** gzip-Komprimierung nur für statische Frontend-Assets erlauben, für
`/api/*` deaktivieren. Statische Bundles bleiben damit performant ausgeliefert,
JSON-API-Responses werden ohne Kompression gesendet → BREACH ist dort kein
Einfallstor mehr.
**Nginx Proxy Manager (NPM):**
1. Proxy-Hosts → den CRM-Host → **Edit**
2. Tab **Custom Locations****„Add location"**
3. **Define location:** `/api/`
4. **Scheme:** `http`, **Forward Hostname/IP:** wie im Haupt-Host
(z.B. `172.0.2.39`), **Forward Port:** `3010`
5. Zahnrad rechts an der Location → erweiterte Config eintragen:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
6. **Save** (Location), **Save** (Proxy-Host)
> Der `more_clear_headers`-Befehl kommt aus dem `headers-more`-Modul, das
> bei NPM standardmäßig dabei ist. Damit verschwinden die Banner
> `Server: openresty` und `x-served-by: …` aus den Responses Pentest-
> Tools können den eingesetzten Webserver nicht mehr direkt aus dem Header
> ablesen. Wer das auch auf der Hauptlocation will, kann denselben Eintrag
> zusätzlich im **Advanced**-Tab des Proxy-Hosts setzen.
**Plain Nginx** (falls eigener Nginx statt NPM):
```nginx
location /api/ {
gzip off;
proxy_pass http://backend:3010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By; # braucht headers-more-Modul
}
# Optional global im server { … }-Block:
server_tokens off;
```
**Verifikation:**
```bash
# 1) gzip ist für /api/ deaktiviert (sollte leer sein)
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/api/health \
| grep -i content-encoding
# 2) Server-/x-served-by-Banner sind weg (sollte leer sein)
curl -sI https://kundencenter.deine-domain.de/api/health \
| grep -iE '^(server|x-served-by):'
```
#### Was mit gzip auf `/` (SPA-HTML) ist
Pentest-Tools wie `testssl` melden BREACH **trotzdem weiter** für die
Root-URL `/`, weil die SPA-`index.html` bewusst weiter gzip-komprimiert
ausgeliefert wird (Performance: 50 KB → ~10 KB). Bei OpenCRM ist der
Angriff dort nicht ausnutzbar:
- Die `/`-Response ist die statische `index.html` aus dem Vite-Build
- Sie reflektiert **keinen user-controlled Input**
- Sie enthält **keine Secrets** (JWT-Access ist im `Authorization`-Header,
Refresh-Token im httpOnly-Cookie beides nicht im HTML-Body)
Ohne Secret-im-Body und ohne Input-Reflektion hat BREACH keinen Hebel.
##### Wer den Audit-Marker trotzdem weg haben will
Wichtig: nicht einfach eine Custom-Location für `/` mit `gzip off`
anlegen das wäre ein **prefix-Match** und würde **alle** Pfade
außer `/api/*` betreffen, also auch `/assets/*.{js,css}`. Das JS-Bundle
käme dann unkomprimiert (~500 KB statt ~150 KB) → spürbarer
Performance-Verlust für nichts.
Sauber ist eine **exact-Match-Location** (`location = /`) die fängt
nur die Root-URL ohne weitere Pfad-Komponente:
**Variante A** Custom Location im NPM-UI (falls `= /` im
„Define location"-Feld akzeptiert wird):
| Feld | Wert |
|---|---|
| Define location | `= /` |
| Scheme | `http` |
| Forward Hostname/IP | wie im Haupt-Host |
| Forward Port | `3010` |
Im Zahnrad-Edit der Location:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
**Variante B** wenn das NPM-UI das `=` nicht akzeptiert, dieselbe
Logik im **Advanced**-Tab des Proxy-Hosts:
```nginx
location = / {
gzip off;
proxy_pass $forward_scheme://$server:$port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By;
}
```
Verifikation `/` ohne gzip, `/assets/*` aber weiter mit:
```bash
# Root: kein Content-Encoding mehr
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/ \
| grep -i content-encoding
# /assets/<file>.js: weiterhin gzip (Performance bleibt erhalten)
JS=$(curl -s https://kundencenter.deine-domain.de/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
curl -sI -H 'Accept-Encoding: gzip' "https://kundencenter.deine-domain.de/$JS" \
| grep -i content-encoding
```
Kostet 40 KB extra pro Tab-Reload aber dafür ist auch der letzte
BREACH-Marker weg und Pentest-Reports landen auf 0×MEDIUM.
## Developer-Tools aktivieren ## Developer-Tools aktivieren
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint: Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
@@ -991,8 +1231,9 @@ Folgende Felder werden in Audit-Logs gefiltert:
## Factory-Defaults: Stammdaten-Kataloge teilen ## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups: zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
zu Datenbank-Backups:
### Abgrenzung ### Abgrenzung
@@ -1000,64 +1241,117 @@ OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backup
|---|---|---| |---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ | | Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ | | PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ | | **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ | | **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ | | **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) | | Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
> **Kurz:** Factory-Defaults = reine Kataloge, Backup = alles. > **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
> Backup = die komplette Instanz.
### Export (Installation A → ZIP) ### Drei Wege, eine ZIP zu transportieren
Es gibt drei Pfade, je nachdem wo die ZIP gerade liegen soll:
| Wo | Pfad | Wann |
|---|---|---|
| **Laufende DB einer Instanz** | UI-Upload oder `./factory-import.sh` | Bestehende Live-Instanz updaten |
| **Drop-Box im Repo** (`factory-exports/`) | `./factory-export.sh` legt ab, `./factory-import.sh` liest | Transfer zwischen dev und prod via `scp` |
| **Werkseinstellung im Image** (`backend/factory-defaults/`) | `./factory-import.sh --save-as-builtin` oder manuell entpacken | Neue VMs sollen die Defaults beim allerersten Start mitbringen |
Alle drei sind unabhängig, **alle drei zusammen** decken den typischen Workflow ab.
### Export
**Variante A UI:**
1. **Einstellungen** → **Factory-Defaults** öffnen 1. **Einstellungen** → **Factory-Defaults** öffnen
2. Übersicht prüfen (Anzahl pro Kategorie) 2. Button **„Factory-Defaults exportieren"** klicken
3. Button **„Factory-Defaults exportieren"** klicken 3. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
**Variante B CLI (für scp-Transfers):**
```bash
./factory-export.sh # → factory-exports/factory-defaults-…zip
OPENCRM_URL=https://crm.prod.example.de \
OPENCRM_EMAIL=admin@example.de ./factory-export.sh # gegen Prod-Instanz
```
Ohne `OPENCRM_PASSWORD` wird das Passwort interaktiv abgefragt. Der Zielordner
`factory-exports/` ist gitignored die ZIPs landen also nicht ins Repo.
**ZIP-Struktur:** **ZIP-Struktur:**
``` ```
factory-defaults-2026-04-23.zip factory-defaults-2026-05-07-1949.zip
├── manifest.json # Version + Datum + Counts ├── manifest.json # Version + Datum + Counts
├── providers/ ├── providers/providers.json
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
├── contract-meta/ ├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung) │ ├── cancellation-periods.json
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung) │ ├── contract-durations.json
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...) │ └── contract-categories.json
── pdf-templates/ ── pdf-templates/
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen ├── pdf-templates.json
└── *.pdf # Die eigentlichen PDF-Dateien └── *.pdf # Die eigentlichen PDF-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates (Whitelist-only)
``` ```
Die ZIP kann an andere Installationen weitergegeben werden ### Import
(Partner, Test-System, neue Installation).
### Import (ZIP → Installation B) **Variante A UI:**
1. **Einstellungen** → **Factory-Defaults**
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
3. Erfolgs-Box zeigt Counts pro Kategorie
1. ZIP herunterladen bzw. erhalten **Variante B CLI:**
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
3. Im Backend-Verzeichnis ausführen:
```bash ```bash
npm run seed:defaults ./factory-import.sh # nimmt jüngste ZIP aus factory-exports/
./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
./factory-import.sh --save-as-builtin # zusätzlich ins Image-Default
./factory-import.sh --save-as-builtin ./foo.zip # entpacken (siehe unten)
```
Konfigurierbar per ENV: `OPENCRM_URL`, `OPENCRM_EMAIL`, `OPENCRM_PASSWORD`.
**Variante C Container-Bare-Metal (für Migration / mehrere ZIPs zusammenführen):**
```bash
# Inhalt der ZIP nach backend/factory-defaults/ entpacken (Unterordner beibehalten)
cd backend && npm run seed:defaults
``` ```
**Beispiel-Output:** **Beispiel-Output:**
``` ```
📦 Factory-Defaults werden eingespielt... ✓ Anbieter: 10
✓ Tarife: 4
✓ Anbieter: 7, Tarife: 12 ✓ Kündigungsfristen: 18
✓ Kündigungsfristen: 5 ✓ Laufzeiten: 18
✓ Laufzeiten: 4
✓ Vertragskategorien: 8 ✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3 ✓ PDF-Vorlagen: 2
✓ HTML-Templates: 2
✅ Factory-Defaults erfolgreich eingespielt.
``` ```
### Mehrere ZIPs kombinieren ### `--save-as-builtin`: ZIP zur Werkseinstellung machen
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
JSON-Dateien werden automatisch gemerged: DB-Import** zusätzlich in `backend/factory-defaults/`. Beim nächsten
`docker-compose up --build` landen die Defaults im Image. Frisch hochgezogene
VMs bringen sie dann beim ersten Start automatisch mit (Auto-Seed-Pfad im
Container-Entrypoint).
```bash
# typischer Sync prod → dev → Image-Default
ssh prod './factory-export.sh'
scp prod:opencrm/factory-exports/factory-defaults-*.zip factory-exports/
./factory-import.sh --save-as-builtin
docker-compose up -d --build # neuer Build, neue VMs starten direkt mit Defaults
```
Der Inhalt von `backend/factory-defaults/` wird beim `--save-as-builtin` vorher
geleert (außer `README.md` und `.gitkeep`), damit nichts Veraltetes liegen
bleibt.
### Mehrere ZIPs kombinieren (CLI-only, Variante C)
`backend/factory-defaults/` darf mehrere `*.json` pro Unterordner haben
`npm run seed:defaults` merged sie automatisch:
``` ```
backend/factory-defaults/ backend/factory-defaults/
@@ -1067,44 +1361,121 @@ backend/factory-defaults/
eigene.json # 5 eigene Anbieter eigene.json # 5 eigene Anbieter
``` ```
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch. nimmt nur eine ZIP entgegen für Merges nutze `npm run seed:defaults`.
### Idempotenz ### Idempotenz
Das Script nutzt ausschließlich Prisma `upsert`: Alle Pfade nutzen Prisma `upsert`:
- **Neue Einträge** werden angelegt - **Neue Einträge** werden angelegt
- **Bestehende Einträge** (per unique Key: `name`, `code`) werden aktualisiert - **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
- Nichts wird gelöscht - Nichts wird gelöscht
Du kannst `npm run seed:defaults` also beliebig oft ausführen, ohne Datenverlust Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
oder Duplikate. oder Duplikate.
### PDF-Dateien beim Import ### PDF-Dateien
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt. kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
ersetzt. die neue ersetzt.
### AppSettings-Whitelist
Beim Import werden nur die Keys mit AppSetting-Schreibzugriff gewährt, die auch
exportiert werden aktuell:
- `privacyPolicyHtml`
- `imprintHtml`
- `authorizationTemplateHtml`
- `websitePrivacyPolicyHtml`
Andere Keys (SMTP, JWT, etc.) werden mit einer Warnung ignoriert. Whitelist ist
in [`backend/src/services/factoryDefaults.service.ts`](backend/src/services/factoryDefaults.service.ts)
zentral gepflegt.
### Auto-Seed beim Erst-Deploy
Bei einer **frischen** Installation (leere DB) spielt der Container-Entrypoint
nach dem Prisma-Seed automatisch das Built-in-Verzeichnis ein:
```
[entrypoint] DB ist leer (User-Count=0) Auto-Seed wird ausgeführt
[entrypoint] Spiele eingebaute Factory-Defaults ein…
✓ Anbieter: 10, Tarife: 4
```
Bei bestehenden Installs passiert das **nicht** nur frische DBs.
### Berechtigungen ### Berechtigungen
| Aktion | Berechtigung | | Aktion | Berechtigung |
|--------|--------------| |--------|--------------|
| Factory-Defaults Vorschau | `settings:read` | | Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export | `settings:update` | | Factory-Defaults Export (UI/CLI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) | | Factory-Defaults Import (UI/CLI) | `settings:update` |
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
### Typischer Einsatzzweck ### Typische Einsatzzwecke
- **Neue Installation aufsetzen**: Eine Kollegen-ZIP importieren und sofort mit - **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
gepflegtem Anbieter- und Vorlagenkatalog loslegen (oder per `--save-as-builtin`), dann `docker-compose up --build` die
Werkseinstellungen sind beim ersten Start automatisch drin.
- **Prod-Stand zurück nach dev synchronisieren**: `./factory-export.sh` auf prod,
`scp` ins dev, `./factory-import.sh --save-as-builtin` lokal damit ist
sowohl die dev-DB aktuell als auch der nächste Image-Build.
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben - **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
(die anderen Ordner einfach aus der ZIP entfernen vor dem Entpacken) (andere Ordner aus der ZIP entfernen vor dem Entpacken).
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben - **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle - **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist) stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
## Changelog
### 1.1.0 (2026-05-01)
**Production-readiness** die Version, die wirklich öffentlich gehen darf.
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
- 🔄 **Auto-Vertragsstatus**:
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
(mit Datums-Modal beim Upload)
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
Login transparent auf Cost 12 geupgradet.
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
über `tokenInvalidatedAt`.
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
path-to-regexp, undici, minimatch).
### 1.0.0
Erste Release-Version.
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
- Vertrags-Cockpit mit Rechnungsprüfung
- E-Mail-Client mit Anhang-Verwaltung
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
- Factory-Defaults für Stammdaten-Kataloge
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
## Lizenz ## Lizenz
MIT MIT
-13
View File
@@ -1,13 +0,0 @@
# Database - Root für Migrationen, opencrm-User für Runtime
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d"
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# Server
PORT=3001
NODE_ENV=development
+9 -1
View File
@@ -1,9 +1,17 @@
# Backend nutzt seit v1.1 die zentrale Root-.env im Projektverzeichnis.
# → siehe ../.env.example für alle Variablen
#
# Diese Datei bleibt als Legacy-Fallback: wenn /.env nicht existiert,
# liest das Backend backend/.env (z.B. für isolierte Backend-Tests).
# Database # Database
DATABASE_URL="mysql://user:password@localhost:3306/opencrm" DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
# JWT # JWT
JWT_SECRET="your-super-secret-jwt-key-change-in-production" JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_EXPIRES_IN="7d" # Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# Encryption (for portal credentials) # Encryption (for portal credentials)
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm" ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
+2 -1
View File
@@ -4,10 +4,11 @@ node_modules/
# Build # Build
dist/ dist/
# Environment # Environment echte Secrets blocken, .env.example weiter mittracken
.env .env
.env.local .env.local
.env.*.local .env.*.local
!.env.example
# Database Backups (can be large, keep folder structure) # Database Backups (can be large, keep folder structure)
prisma/backups/* prisma/backups/*
+77
View File
@@ -0,0 +1,77 @@
# Multi-Stage Build: Frontend bauen, dann Backend bauen, dann schlankes Runtime-Image
# ---------------------------------------------------------------------------------
# Alle Stages auf node:20-slim (Debian-basiert) dann passt die Prisma-Query-
# Engine (glibc + openssl) zur Runtime.
# ============== STAGE 1: Frontend bauen ==============
FROM node:20-slim AS frontend-builder
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund --prefer-offline
COPY frontend/ ./
RUN npm run build
# Output: /build/frontend/dist/
# ============== STAGE 2: Backend bauen (TS → JS) ==============
FROM node:20-slim AS backend-builder
WORKDIR /build/backend
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --no-audit --no-fund --prefer-offline
COPY backend/prisma ./prisma
RUN npx prisma generate
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npx tsc
# Output: /build/backend/dist/
# ============== STAGE 3: Runtime ==============
# Debian-slim statt Alpine: Prisma-Engines erwarten libssl 1.1, das in Alpine 3.19+
# nicht mehr verfügbar ist. Slim hat openssl 3 ABI-kompatibel + native binaries.
FROM node:20-slim
WORKDIR /app
# OpenSSL für Prisma-Query-Engine + wget für Healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget \
&& rm -rf /var/lib/apt/lists/*
# Nur Production-Dependencies + Prisma-Client
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund --prefer-offline && npm cache clean --force
# Build-Artefakte aus Stage 2
COPY --from=backend-builder /build/backend/dist ./dist
COPY --from=backend-builder /build/backend/node_modules/.prisma ./node_modules/.prisma
COPY --from=backend-builder /build/backend/node_modules/@prisma ./node_modules/@prisma
COPY backend/prisma ./prisma
# src/ mitkopieren, damit prisma/*.ts-Wartungsskripte (cleanup, reset-admin-
# password etc.) auch im Production-Container via `npx tsx` laufen können
# die importieren über '../src/lib/prisma.js' den shared Prisma-Client.
# Server selbst läuft weiter aus dist/.
COPY --from=backend-builder /build/backend/src ./src
COPY backend/tsconfig.json ./tsconfig.json
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
COPY --from=frontend-builder /build/frontend/dist ./public
# Eingebaute Werkseinstellungen ins Image: bei Erstinstallation (leerer DB) zieht
# der Entrypoint sie via tsx scripts/seed-factory-defaults.ts ein. Liegt in einem
# eigenen Pfad `factory-defaults/` selbst kann über Bind-Mount überlagert werden.
COPY backend/factory-defaults /app/factory-defaults-builtin
COPY backend/scripts /app/scripts
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
RUN mkdir -p uploads factory-defaults prisma/backups
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --tries=1 --spider "http://localhost:${PORT:-3001}/api/health" || exit 1
# Beim Start: prisma db push (idempotent), dann node
COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
# Container-Start:
# 1) Auf DB warten
# 2) Auto-Baseline für bestehende DBs (db-push-Ära ohne _prisma_migrations)
# 3) `prisma migrate deploy` (idempotent, datenerhaltend)
# 4) Auto-Seed bei leerer User-Tabelle (oder RUN_SEED=true)
# Neue Schema-Änderung anlegen (lokal, im Dev): npm run schema:sync
set -e
# DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
# Wichtig: encodeURIComponent für DB_USER + DB_PASSWORD, damit Sonderzeichen
# wie $, !, #, @, :, / etc. nicht die URL-Authority-Syntax brechen.
# Wir nutzen node-eval (ist eh installiert), kein extra-Tool wie jq nötig.
if [ -z "$DATABASE_URL" ] && [ -n "$DB_USER" ] && [ -n "$DB_PASSWORD" ] && [ -n "$DB_NAME" ]; then
DATABASE_URL=$(node -e "
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'db';
const port = process.env.DB_PORT || '3306';
const n = process.env.DB_NAME;
process.stdout.write(\`mysql://\${u}:\${p}@\${h}:\${port}/\${n}\`);
")
export DATABASE_URL
echo "[entrypoint] DATABASE_URL aus DB_*-Komponenten gebaut (host=${DB_HOST:-db})"
fi
echo "[entrypoint] Warte auf Datenbank…"
# Erst auf DB-Verfügbarkeit warten via einfachem Connect-Check.
# Wir nutzen Prisma's interne Engine, kein extra mysql-client nötig.
TRIES=30
until node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.\$queryRaw\`SELECT 1\`
.then(() => p.\$disconnect().then(() => process.exit(0)))
.catch(() => process.exit(1));
" 2>/dev/null; do
TRIES=$((TRIES - 1))
if [ "$TRIES" -le 0 ]; then
echo "[entrypoint] DB nicht erreichbar Abbruch"
exit 1
fi
echo "[entrypoint] DB noch nicht bereit retry in 2s ($TRIES Versuche übrig)"
sleep 2
done
echo "[entrypoint] DB erreichbar"
# Auto-Baseline: Wenn die DB Anwendungs-Tabellen enthält (z.B. User), aber noch
# keine _prisma_migrations-Tabelle, dann ist es eine "alte" DB, die früher mit
# `prisma db push` synced wurde. Wir markieren 0_init als bereits angewendet,
# damit `migrate deploy` nicht versucht, alle Tabellen nochmal anzulegen.
NEEDS_BASELINE=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
(async () => {
try {
const dbName = process.env.DB_NAME;
const tables = await p.\$queryRawUnsafe(
\`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?\`,
dbName
);
const names = tables.map(t => t.TABLE_NAME);
const hasMigrations = names.includes('_prisma_migrations');
const hasUserTable = names.includes('User');
// Existing DB (User da) ohne Migrations-Tracking => Baseline nötig
if (hasUserTable && !hasMigrations) process.stdout.write('yes');
else process.stdout.write('no');
} catch (e) {
process.stdout.write('no');
} finally {
await p.\$disconnect();
}
})();
" 2>/dev/null)
if [ "$NEEDS_BASELINE" = "yes" ]; then
echo "[entrypoint] Bestehende DB ohne Migrations-Tracking erkannt markiere 0_init als angewendet (Baseline)"
npx prisma migrate resolve --applied 0_init || echo "[entrypoint] Baseline fehlgeschlagen fahre trotzdem fort"
fi
# Migrations anwenden (idempotent: bereits angewendete werden übersprungen).
# Im Gegensatz zu `db push` löscht `migrate deploy` keine Daten — Schema-
# Änderungen werden über versionierte Migrations-Files unter prisma/migrations/
# eingespielt. Neue Migration anlegen mit: npm run schema:sync (lokal, dev).
echo "[entrypoint] Wende Migrations an…"
if ! npx prisma migrate deploy; then
echo "[entrypoint] migrate deploy fehlgeschlagen Abbruch"
exit 1
fi
echo "[entrypoint] DB-Schema aktuell"
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.user.count()
.then((n) => { process.stdout.write(String(n)); process.exit(0); })
.catch(() => { process.stdout.write('-1'); process.exit(0); });
" 2>/dev/null)
RAN_SEED=false
if [ "${RUN_SEED:-false}" = "true" ]; then
echo "[entrypoint] RUN_SEED=true seede DB (Force)"
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Seed fehlgeschlagen oder schon gelaufen ignoriert"; fi
elif [ "$USER_COUNT" = "0" ]; then
echo "[entrypoint] DB ist leer (User-Count=0) Auto-Seed wird ausgeführt"
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Auto-Seed fehlgeschlagen ignoriert"; fi
else
echo "[entrypoint] DB enthält $USER_COUNT User kein Seed nötig"
fi
# Eingebaute Factory-Defaults nach Erstinstallation einspielen.
# Das ist die Werkseinstellung für neue VMs: PDF-Vorlagen, Anbieter, Tarife,
# HTML-Templates alles aus /app/factory-defaults-builtin/. Erfolgt nur wenn
# der Auto-Seed gerade lief (= frische DB), sonst werden Updates auf
# bestehenden Installationen nicht ungewollt überschrieben.
if [ "$RAN_SEED" = "true" ] && [ -d /app/factory-defaults-builtin ] \
&& [ -n "$(ls -A /app/factory-defaults-builtin 2>/dev/null | grep -v -E '^(README\.md|\.gitkeep)$')" ]; then
echo "[entrypoint] Spiele eingebaute Factory-Defaults ein…"
FACTORY_DEFAULTS_DIR=/app/factory-defaults-builtin npx tsx scripts/seed-factory-defaults.ts \
|| echo "[entrypoint] Factory-Defaults-Seed fehlgeschlagen ignoriert"
fi
# Permissions + Rollen-Sync: Stellt sicher, dass nachträglich hinzugefügte
# Permissions (z.B. audit:read auf der DSGVO-Rolle) auch auf bestehenden
# DBs ankommen. Seed läuft NICHT auf nicht-leeren DBs, daher würden alte
# Installationen sonst mit unvollständigen Role-Perms laufen. Idempotent,
# fasst keine Stammdaten / User / Verträge an.
echo "[entrypoint] Rollen + Permissions synchronisieren…"
npx tsx prisma/sync-roles.ts \
|| echo "[entrypoint] Role-Sync fehlgeschlagen nicht kritisch"
# Datenbereinigung: XSS-Strings aus Customer/User-Stringfeldern strippen,
# nicht-whitelisted AppSettings entfernen, Pentest-Marker melden (Default
# nur warnen; CLEANUP_PURGE_PENTEST=true löscht markierte Records).
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "[entrypoint] Datenbereinigung läuft…"
npx tsx prisma/cleanup-xss-and-mass-assignment.ts \
|| echo "[entrypoint] Cleanup übersprungen / fehlgeschlagen nicht kritisch"
echo "[entrypoint] Starte Backend…"
exec "$@"
+40 -6
View File
@@ -18,15 +18,21 @@ backend/factory-defaults/
│ ├── cancellation-periods.json # Kündigungsfristen │ ├── cancellation-periods.json # Kündigungsfristen
│ ├── contract-durations.json # Vertragslaufzeiten │ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...) │ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
── pdf-templates/ ── pdf-templates/
├── pdf-templates.json # Metadaten + Feldzuordnungen ├── pdf-templates.json # Metadaten + Feldzuordnungen
└── *.pdf # PDF-Vorlagen-Dateien └── *.pdf # PDF-Vorlagen-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
# Vollmacht / Website-Datenschutz
``` ```
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen, **Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen). **Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
Standardpflichten) andere Keys werden beim Import ignoriert.
--- ---
## Export (aus einer bestehenden Installation) ## Export (aus einer bestehenden Installation)
@@ -46,7 +52,8 @@ factory-defaults-2026-04-23.zip
├── contract-meta/contract-durations.json ├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.json ├── contract-meta/contract-categories.json
├── pdf-templates/pdf-templates.json ├── pdf-templates/pdf-templates.json
── pdf-templates/*.pdf ── pdf-templates/*.pdf
└── app-settings/app-settings.json
``` ```
Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme, Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme,
@@ -56,7 +63,15 @@ neue Installationen oder Partner-Setups.
## Import (in eine andere Installation) ## Import (in eine andere Installation)
### Schritt-für-Schritt ### Variante A: Über die UI (empfohlen)
1. Im Ziel-CRM als Admin einloggen
2. **Einstellungen → Factory-Defaults**
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
4. Die exportierte ZIP wählen der Import läuft direkt
5. Erfolgsmeldung zeigt Counts pro Kategorie an
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage) 1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`), 2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
@@ -234,6 +249,24 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
**Unique Key:** `name` **Unique Key:** `name`
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen. **Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
### `app-settings/app-settings.json`
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv andere Keys
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
```json
[
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
]
```
**Unique Key:** `key`
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
`websitePrivacyPolicyHtml`.
--- ---
## Berechtigungen ## Berechtigungen
@@ -242,6 +275,7 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|--------|--------------| |--------|--------------|
| Factory-Defaults Vorschau | `settings:read` | | Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` | | Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) | | Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
--- ---
+201 -101
View File
@@ -1,19 +1,22 @@
{ {
"name": "opencrm-backend", "name": "opencrm-backend",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "opencrm-backend", "name": "opencrm-backend",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^8.4.0", "express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
@@ -26,6 +29,7 @@
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0" "undici": "^6.23.0"
}, },
"devDependencies": { "devDependencies": {
@@ -42,7 +46,6 @@
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4", "@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
}, },
@@ -53,7 +56,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@@ -69,7 +71,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -85,7 +86,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -101,7 +101,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -117,7 +116,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -133,7 +131,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -149,7 +146,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -165,7 +161,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -181,7 +176,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -197,7 +191,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -213,7 +206,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -229,7 +221,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -245,7 +236,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -261,7 +251,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -277,7 +266,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -293,7 +281,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -309,7 +296,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -325,7 +311,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -341,7 +326,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -357,7 +341,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -373,7 +356,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -389,7 +371,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -405,7 +386,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@@ -421,7 +401,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -437,7 +416,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -453,7 +431,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -511,7 +488,8 @@
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
}, },
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
@@ -632,7 +610,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@@ -642,11 +619,19 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -660,7 +645,6 @@
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33", "@types/express-serve-static-core": "^4.17.33",
@@ -672,7 +656,6 @@
"version": "4.19.8", "version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"@types/qs": "*", "@types/qs": "*",
@@ -683,8 +666,7 @@
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
"dev": true
}, },
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
@@ -721,8 +703,7 @@
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
"dev": true
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
@@ -743,7 +724,6 @@
"version": "22.19.7", "version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -776,14 +756,12 @@
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
"dev": true
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"dev": true
}, },
"node_modules/@types/readdir-glob": { "node_modules/@types/readdir-glob": {
"version": "1.1.5", "version": "1.1.5",
@@ -798,7 +776,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -807,7 +784,6 @@
"version": "1.15.10", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
@@ -818,7 +794,6 @@
"version": "0.17.6", "version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
@@ -986,6 +961,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -1069,9 +1045,10 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@@ -1274,6 +1251,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -1460,6 +1456,33 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dotenv-expand": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz",
"integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^17.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand/node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1554,7 +1577,6 @@
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
@@ -1781,7 +1803,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@@ -1838,7 +1859,6 @@
"version": "4.13.0", "version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"dependencies": { "dependencies": {
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
}, },
@@ -2003,19 +2023,31 @@
] ]
}, },
"node_modules/imapflow": { "node_modules/imapflow": {
"version": "1.2.8", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==", "integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@zone-eu/mailsplit": "5.4.8", "@zone-eu/mailsplit": "5.4.9",
"encoding-japanese": "2.2.0", "encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2", "iconv-lite": "0.7.2",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.3.7", "libmime": "5.3.8",
"libqp": "2.1.1", "libqp": "2.1.1",
"nodemailer": "7.0.13", "nodemailer": "8.0.7",
"pino": "10.3.0", "pino": "10.3.1",
"socks": "2.8.7" "socks": "2.8.8"
}
},
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
"version": "5.4.9",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1"
} }
}, },
"node_modules/imapflow/node_modules/iconv-lite": { "node_modules/imapflow/node_modules/iconv-lite": {
@@ -2033,6 +2065,27 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2225,9 +2278,10 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
}, },
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
@@ -2270,18 +2324,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
}, },
"node_modules/mailparser": { "node_modules/mailparser": {
"version": "3.9.3", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz", "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==", "integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"dependencies": { "dependencies": {
"@zone-eu/mailsplit": "5.4.8", "@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0", "encoding-japanese": "2.2.0",
"he": "1.2.0", "he": "1.2.0",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"iconv-lite": "0.7.2", "iconv-lite": "0.7.2",
"libmime": "5.3.7", "libmime": "5.3.8",
"linkify-it": "5.0.0", "linkify-it": "5.0.0",
"nodemailer": "7.0.13", "nodemailer": "8.0.5",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"tlds": "1.261.0" "tlds": "1.261.0"
} }
@@ -2301,6 +2356,27 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/mailparser/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2364,11 +2440,12 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -2483,6 +2560,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@@ -2552,9 +2630,10 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
}, },
"node_modules/pdf-lib": { "node_modules/pdf-lib": {
"version": "1.17.1", "version": "1.17.1",
@@ -2601,9 +2680,10 @@
} }
}, },
"node_modules/pino": { "node_modules/pino": {
"version": "10.3.0", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@pinojs/redact": "^0.4.0", "@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0", "atomic-sleep": "^1.0.0",
@@ -2625,6 +2705,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": { "dependencies": {
"split2": "^4.0.0" "split2": "^4.0.0"
} }
@@ -2632,7 +2713,8 @@
"node_modules/pino-std-serializers": { "node_modules/pino-std-serializers": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
}, },
"node_modules/png-js": { "node_modules/png-js": {
"version": "1.0.0", "version": "1.0.0",
@@ -2684,7 +2766,8 @@
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/fastify" "url": "https://opencollective.com/fastify"
} }
] ],
"license": "MIT"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
@@ -2707,9 +2790,10 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
}, },
@@ -2723,7 +2807,8 @@
"node_modules/quick-format-unescaped": { "node_modules/quick-format-unescaped": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
}, },
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
@@ -2775,9 +2860,10 @@
} }
}, },
"node_modules/readdir-glob/node_modules/minimatch": { "node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
}, },
@@ -2789,6 +2875,7 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 12.13.0" "node": ">= 12.13.0"
} }
@@ -2797,7 +2884,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": { "funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
@@ -2830,6 +2916,7 @@
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -3010,17 +3097,19 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 6.0.0", "node": ">= 6.0.0",
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
} }
}, },
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.7", "version": "2.8.8",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
"license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "^10.0.1", "ip-address": "^10.1.1",
"smart-buffer": "^4.2.0" "smart-buffer": "^4.2.0"
}, },
"engines": { "engines": {
@@ -3028,10 +3117,20 @@
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
} }
}, },
"node_modules/socks/node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/sonic-boom": { "node_modules/sonic-boom": {
"version": "4.2.0", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": { "dependencies": {
"atomic-sleep": "^1.0.0" "atomic-sleep": "^1.0.0"
} }
@@ -3040,6 +3139,7 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": { "engines": {
"node": ">= 10.x" "node": ">= 10.x"
} }
@@ -3193,6 +3293,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": { "dependencies": {
"real-require": "^0.2.0" "real-require": "^0.2.0"
}, },
@@ -3230,7 +3331,6 @@
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -3281,9 +3381,10 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.23.0", "version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
} }
@@ -3291,8 +3392,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
"dev": true
}, },
"node_modules/unicode-properties": { "node_modules/unicode-properties": {
"version": "1.4.1", "version": "1.4.1",
+7 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "opencrm-backend", "name": "opencrm-backend",
"version": "1.0.0", "version": "1.1.0",
"description": "OpenCRM Backend API", "description": "OpenCRM Backend API",
"main": "dist/index.js", "main": "dist/index.js",
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "npx tsx prisma/seed.ts"
}, },
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
@@ -12,6 +12,7 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"schema:sync": "prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts", "db:backup": "tsx prisma/backup-data.ts",
@@ -20,11 +21,14 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^8.4.0", "express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
@@ -37,6 +41,7 @@
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0" "undici": "^6.23.0"
}, },
"devDependencies": { "devDependencies": {
@@ -53,7 +58,6 @@
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4", "@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }
@@ -0,0 +1,351 @@
/**
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
*
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
*
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
* mehrfach aufrufbar.
*/
import prisma from '../src/lib/prisma.js';
import { stripHtml } from '../src/utils/sanitize.js';
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
const CUSTOMER_STRING_FIELDS = [
'salutation', 'firstName', 'lastName', 'companyName',
'birthPlace', 'email', 'phone', 'mobile',
'taxNumber', 'commercialRegisterNumber', 'notes',
];
const USER_STRING_FIELDS = [
'firstName', 'lastName', 'email',
'whatsappNumber', 'telegramUsername', 'signalNumber',
];
async function cleanupXss() {
const customers = await prisma.customer.findMany();
let touched = 0;
for (const c of customers) {
const updates: Record<string, string> = {};
for (const field of CUSTOMER_STRING_FIELDS) {
const v = (c as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.customer.update({ where: { id: c.id }, data: updates });
touched++;
}
}
console.log(` → Customer bereinigt: ${touched}`);
const users = await prisma.user.findMany();
let userTouched = 0;
for (const u of users) {
const updates: Record<string, string> = {};
for (const field of USER_STRING_FIELDS) {
const v = (u as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.user.update({ where: { id: u.id }, data: updates });
userTouched++;
}
}
console.log(` → User bereinigt: ${userTouched}`);
}
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
const HTML_ALLOWED_SETTING_KEYS = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
function stripHtmlString(s: string): string {
return s
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
}
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
// auf 'unknown' normalisiert. Pentest 2026-05-20.
const ALLOWED_CONSENT_SOURCES = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
// raus (Pentest 2026-05-20 LOW 27.1).
function isValidDocumentPath(v: string | null | undefined): boolean {
if (!v) return true; // null/leer ist OK
if (v.includes('..')) return false;
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
}
async function cleanupConsents() {
// version + documentPath: HTML strippen (waren ohne Validierung).
// source: Whitelist erzwingen.
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
let versionStripped = 0;
let pathNulled = 0;
let sourceFixed = 0;
const consents = await prisma.customerConsent.findMany({
select: { id: true, source: true, documentPath: true, version: true },
});
for (const c of consents) {
const data: Record<string, string | null> = {};
if (c.version && c.version !== stripHtmlString(c.version)) {
data.version = stripHtmlString(c.version);
versionStripped++;
}
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
data.documentPath = null;
pathNulled++;
}
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
data.source = 'unknown';
sourceFixed++;
}
if (Object.keys(data).length > 0) {
await prisma.customerConsent.update({ where: { id: c.id }, data });
}
}
console.log(
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
);
}
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
// server-seitig vom multer-Upload erzeugt falls dort doch mal ein
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
// löschen aber nicht (Records müssten manuell angeschaut werden).
async function cleanupDocumentPaths() {
const findings: { table: string; id: number; value: string }[] = [];
const optional: Array<{
label: string;
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
update: (id: number) => Promise<unknown>;
}> = [
{
label: 'BankCard',
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'IdentityDocument',
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'Invoice',
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'RepresentativeAuthorization',
fetch: () => prisma.representativeAuthorization.findMany({
select: { id: true, documentPath: true },
}),
update: (id) => prisma.representativeAuthorization.update({
where: { id }, data: { documentPath: null },
}),
},
];
let nulled = 0;
for (const t of optional) {
const rows = await t.fetch();
for (const r of rows) {
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
await t.update(r.id);
nulled++;
}
}
}
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
const contractDocs = await prisma.contractDocument.findMany({
select: { id: true, documentPath: true },
});
let contractDocsDirty = 0;
for (const d of contractDocs) {
if (!isValidDocumentPath(d.documentPath)) {
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
contractDocsDirty++;
}
}
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
for (const f of findings.slice(0, 10)) {
console.log(` [${f.table}#${f.id}] "${f.value}"`);
}
}
async function reportOrphanedUsers() {
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
// Spezial-User treffen) nur warnen.
const orphans = await prisma.user.findMany({
where: { roles: { none: {} } },
select: { id: true, email: true, createdAt: true },
});
if (orphans.length === 0) {
console.log(' → Keine User ohne Rollenzuordnung.');
return;
}
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
for (const u of orphans.slice(0, 10)) {
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
}
console.log(' → Rolle zuweisen oder User löschen.');
}
async function cleanupAppSettings() {
const settings = await prisma.appSetting.findMany();
const removed: string[] = [];
let stripped = 0;
for (const s of settings) {
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
removed.push(s.key);
await prisma.appSetting.delete({ where: { key: s.key } });
continue;
}
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
const cleaned = stripHtmlString(s.value);
if (cleaned !== s.value) {
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
stripped++;
}
}
}
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
if (stripped > 0) {
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
}
}
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
// gefasst legitime Kunden mit "Hacker" als Nachnamen sollen nicht
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
// `hacker@familie-hacker.de` o.ä. fängen.
const PENTEST_MARKERS = [
/@evil\./i,
/^attacker@/i,
/^pentest@/i,
/<script\b/i, // unverwechselbarer XSS-Marker
/\bonerror\s*=/i, // <img onerror=…>
/javascript:/i, // javascript:-URL
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
/\.\.\/.*etc\/passwd/i, // Path-Traversal
];
function looksLikePentestData(value: unknown): boolean {
if (typeof value !== 'string') return false;
return PENTEST_MARKERS.some((re) => re.test(value));
}
async function findOrPurgePentestRecords() {
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
const customers = await prisma.customer.findMany();
for (const c of customers) {
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
if (looksLikePentestData((c as any)[f])) {
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
break;
}
}
}
const users = await prisma.user.findMany();
for (const u of users) {
for (const f of ['email', 'firstName', 'lastName']) {
if (looksLikePentestData((u as any)[f])) {
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
break;
}
}
}
if (suspect.length === 0) {
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
return;
}
console.log(`${suspect.length} verdächtige Records (Pentest-Marker):`);
for (const s of suspect) {
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
}
if (!purge) {
console.log(' ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
console.log(' oder Records manuell über adminer entfernen.');
return;
}
for (const s of suspect) {
if (s.kind === 'Customer') {
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
} else if (s.kind === 'User') {
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
}
}
console.log(`${suspect.length} verdächtige Records gelöscht.`);
}
async function main() {
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
await cleanupXss();
await cleanupAppSettings();
await cleanupConsents();
await cleanupDocumentPaths();
await reportOrphanedUsers();
await findOrPurgePentestRecords();
console.log('=== Fertig. ===');
}
main()
.catch((e) => {
console.error('Cleanup fehlgeschlagen:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,989 @@
-- CreateTable
CREATE TABLE `PdfTemplate` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`providerName` VARCHAR(191) NULL,
`templatePath` VARCHAR(191) NOT NULL,
`originalName` VARCHAR(191) NOT NULL,
`fieldMapping` LONGTEXT NOT NULL,
`phoneFieldPrefix` VARCHAR(191) NULL,
`maxPhoneFields` INTEGER NULL DEFAULT 8,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `PdfTemplate_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`fromAddress` VARCHAR(191) NOT NULL,
`toAddress` VARCHAR(191) NOT NULL,
`subject` VARCHAR(191) NOT NULL,
`context` VARCHAR(191) NOT NULL,
`customerId` INTEGER NULL,
`triggeredBy` VARCHAR(191) NULL,
`smtpServer` VARCHAR(191) NOT NULL,
`smtpPort` INTEGER NOT NULL,
`smtpEncryption` VARCHAR(191) NOT NULL,
`smtpUser` VARCHAR(191) NOT NULL,
`success` BOOLEAN NOT NULL,
`messageId` VARCHAR(191) NULL,
`errorMessage` TEXT NULL,
`smtpResponse` TEXT NULL,
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `EmailLog_sentAt_idx`(`sentAt`),
INDEX `EmailLog_customerId_idx`(`customerId`),
INDEX `EmailLog_success_idx`(`success`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AppSetting` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AppSetting_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`tokenInvalidatedAt` DATETIME(3) NULL,
`passwordResetToken` VARCHAR(191) NULL,
`passwordResetExpiresAt` DATETIME(3) NULL,
`whatsappNumber` VARCHAR(191) NULL,
`telegramUsername` VARCHAR(191) NULL,
`signalNumber` VARCHAR(191) NULL,
`customerId` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_passwordResetToken_key`(`passwordResetToken`),
UNIQUE INDEX `User_customerId_key`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Role_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Permission` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resource` VARCHAR(191) NOT NULL,
`action` VARCHAR(191) NOT NULL,
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`roleId` INTEGER NOT NULL,
`permissionId` INTEGER NOT NULL,
PRIMARY KEY (`roleId`, `permissionId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `UserRole` (
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,
PRIMARY KEY (`userId`, `roleId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Customer` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerNumber` VARCHAR(191) NOT NULL,
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
`salutation` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`companyName` VARCHAR(191) NULL,
`foundingDate` DATETIME(3) NULL,
`birthDate` DATETIME(3) NULL,
`birthPlace` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`mobile` VARCHAR(191) NULL,
`taxNumber` VARCHAR(191) NULL,
`businessRegistrationPath` VARCHAR(191) NULL,
`commercialRegisterPath` VARCHAR(191) NULL,
`commercialRegisterNumber` VARCHAR(191) NULL,
`privacyPolicyPath` VARCHAR(191) NULL,
`consentHash` VARCHAR(191) NULL,
`notes` TEXT NULL,
`portalEnabled` BOOLEAN NOT NULL DEFAULT false,
`portalEmail` VARCHAR(191) NULL,
`portalPasswordHash` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`portalLastLogin` DATETIME(3) NULL,
`portalPasswordResetToken` VARCHAR(191) NULL,
`portalPasswordResetExpiresAt` DATETIME(3) NULL,
`portalTokenInvalidatedAt` DATETIME(3) NULL,
`lastBirthdayGreetingYear` INTEGER NULL,
`useInformalAddress` BOOLEAN NOT NULL DEFAULT false,
`autoBirthdayGreeting` BOOLEAN NOT NULL DEFAULT false,
`autoBirthdayChannel` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
UNIQUE INDEX `Customer_consentHash_key`(`consentHash`),
UNIQUE INDEX `Customer_portalEmail_key`(`portalEmail`),
UNIQUE INDEX `Customer_portalPasswordResetToken_key`(`portalPasswordResetToken`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerRepresentative` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`notes` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RepresentativeAuthorization` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`isGranted` BOOLEAN NOT NULL DEFAULT false,
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `RepresentativeAuthorization_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Address` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
`street` VARCHAR(191) NOT NULL,
`houseNumber` VARCHAR(191) NOT NULL,
`postalCode` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`ownerCompany` VARCHAR(191) NULL,
`ownerFirstName` VARCHAR(191) NULL,
`ownerLastName` VARCHAR(191) NULL,
`ownerStreet` VARCHAR(191) NULL,
`ownerHouseNumber` VARCHAR(191) NULL,
`ownerPostalCode` VARCHAR(191) NULL,
`ownerCity` VARCHAR(191) NULL,
`ownerPhone` VARCHAR(191) NULL,
`ownerMobile` VARCHAR(191) NULL,
`ownerEmail` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `BankCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`accountHolder` VARCHAR(191) NOT NULL,
`iban` VARCHAR(191) NOT NULL,
`bic` VARCHAR(191) NULL,
`bankName` VARCHAR(191) NULL,
`expiryDate` DATETIME(3) NULL,
`documentPath` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `IdentityDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
`documentNumber` VARCHAR(191) NOT NULL,
`issuingAuthority` VARCHAR(191) NULL,
`issueDate` DATETIME(3) NULL,
`expiryDate` DATETIME(3) NULL,
`documentPath` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`licenseClasses` VARCHAR(191) NULL,
`licenseIssueDate` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailProviderConfig` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
`apiUrl` VARCHAR(191) NOT NULL,
`apiKey` VARCHAR(191) NULL,
`username` VARCHAR(191) NULL,
`passwordEncrypted` VARCHAR(191) NULL,
`domain` VARCHAR(191) NOT NULL,
`defaultForwardEmail` VARCHAR(191) NULL,
`imapServer` VARCHAR(191) NULL,
`imapPort` INTEGER NULL DEFAULT 993,
`smtpServer` VARCHAR(191) NULL,
`smtpPort` INTEGER NULL DEFAULT 465,
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
`systemEmailAddress` VARCHAR(191) NULL,
`systemEmailPasswordEncrypted` VARCHAR(191) NULL,
`customerEmailLabel` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `StressfreiEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`email` VARCHAR(191) NOT NULL,
`platform` VARCHAR(191) NULL,
`notes` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
`provisionedAt` DATETIME(3) NULL,
`provisionError` TEXT NULL,
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
`emailPasswordEncrypted` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CachedEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`stressfreiEmailId` INTEGER NOT NULL,
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
`messageId` VARCHAR(191) NOT NULL,
`uid` INTEGER NOT NULL,
`subject` VARCHAR(191) NULL,
`fromAddress` VARCHAR(191) NOT NULL,
`fromName` VARCHAR(191) NULL,
`toAddresses` TEXT NOT NULL,
`ccAddresses` TEXT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`textBody` LONGTEXT NULL,
`htmlBody` LONGTEXT NULL,
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
`attachmentNames` TEXT NULL,
`contractId` INTEGER NULL,
`assignedAt` DATETIME(3) NULL,
`assignedBy` INTEGER NULL,
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
`isRead` BOOLEAN NOT NULL DEFAULT false,
`isStarred` BOOLEAN NOT NULL DEFAULT false,
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
`deletedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CachedEmail_contractId_idx`(`contractId`),
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Meter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`meterNumber` VARCHAR(191) NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
`tariffModel` ENUM('SINGLE', 'DUAL') NOT NULL DEFAULT 'SINGLE',
`location` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MeterReading` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`meterId` INTEGER NOT NULL,
`readingDate` DATETIME(3) NOT NULL,
`value` DOUBLE NOT NULL,
`valueNt` DOUBLE NULL,
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
`notes` VARCHAR(191) NULL,
`reportedBy` VARCHAR(191) NULL,
`status` ENUM('RECORDED', 'REPORTED', 'TRANSFERRED') NOT NULL DEFAULT 'RECORDED',
`transferredAt` DATETIME(3) NULL,
`transferredBy` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SalesPlatform` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`contactInfo` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CancellationPeriod` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractDuration` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractDuration_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Provider` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`portalUrl` VARCHAR(191) NULL,
`usernameFieldName` VARCHAR(191) NULL,
`passwordFieldName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Provider_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Tariff` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`providerId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractCategory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`icon` VARCHAR(191) NULL,
`color` VARCHAR(191) NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractCategory_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Contract` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractNumber` VARCHAR(191) NOT NULL,
`customerId` INTEGER NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT',
`contractCategoryId` INTEGER NULL,
`addressId` INTEGER NULL,
`billingAddressId` INTEGER NULL,
`bankCardId` INTEGER NULL,
`identityDocumentId` INTEGER NULL,
`salesPlatformId` INTEGER NULL,
`cancellationPeriodId` INTEGER NULL,
`contractDurationId` INTEGER NULL,
`previousContractId` INTEGER NULL,
`previousProviderId` INTEGER NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
`previousContractNumber` VARCHAR(191) NULL,
`providerId` INTEGER NULL,
`tariffId` INTEGER NULL,
`providerName` VARCHAR(191) NULL,
`tariffName` VARCHAR(191) NULL,
`customerNumberAtProvider` VARCHAR(191) NULL,
`contractNumberAtProvider` VARCHAR(191) NULL,
`priceFirst12Months` VARCHAR(191) NULL,
`priceFrom13Months` VARCHAR(191) NULL,
`priceAfter24Months` VARCHAR(191) NULL,
`startDate` DATETIME(3) NULL,
`endDate` DATETIME(3) NULL,
`commission` DOUBLE NULL,
`cancellationLetterPath` VARCHAR(191) NULL,
`cancellationConfirmationPath` VARCHAR(191) NULL,
`cancellationLetterOptionsPath` VARCHAR(191) NULL,
`cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
`cancellationConfirmationDate` DATETIME(3) NULL,
`cancellationConfirmationOptionsDate` DATETIME(3) NULL,
`wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false,
`portalUsername` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`stressfreiEmailId` INTEGER NULL,
`nextReviewDate` DATETIME(3) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`documentType` VARCHAR(191) NOT NULL,
`documentPath` VARCHAR(191) NOT NULL,
`originalName` VARCHAR(191) NOT NULL,
`notes` TEXT NULL,
`uploadedBy` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ContractDocument_contractId_idx`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractHistoryEntry` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTaskSubtask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EnergyContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`meterId` INTEGER NULL,
`maloId` VARCHAR(191) NULL,
`annualConsumption` DOUBLE NULL,
`annualConsumptionKwh` DOUBLE NULL,
`basePrice` DOUBLE NULL,
`unitPrice` DOUBLE NULL,
`unitPriceNt` DOUBLE NULL,
`bonus` DOUBLE NULL,
`previousProviderName` VARCHAR(191) NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractMeter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NOT NULL,
`meterId` INTEGER NOT NULL,
`position` INTEGER NOT NULL DEFAULT 0,
`installedAt` DATETIME(3) NULL,
`removedAt` DATETIME(3) NULL,
`finalReading` DOUBLE NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ContractMeter_energyContractDetailsId_idx`(`energyContractDetailsId`),
UNIQUE INDEX `ContractMeter_energyContractDetailsId_meterId_key`(`energyContractDetailsId`, `meterId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Invoice` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NULL,
`contractId` INTEGER NULL,
`invoiceDate` DATETIME(3) NOT NULL,
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
`documentPath` VARCHAR(191) NULL,
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
INDEX `Invoice_contractId_idx`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `InternetContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`downloadSpeed` INTEGER NULL,
`uploadSpeed` INTEGER NULL,
`routerModel` VARCHAR(191) NULL,
`routerSerialNumber` VARCHAR(191) NULL,
`installationDate` DATETIME(3) NULL,
`internetUsername` VARCHAR(191) NULL,
`internetPasswordEncrypted` VARCHAR(191) NULL,
`propertyType` VARCHAR(191) NULL,
`propertyLocation` VARCHAR(191) NULL,
`connectionLocation` VARCHAR(191) NULL,
`homeId` VARCHAR(191) NULL,
`activationCode` VARCHAR(191) NULL,
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PhoneNumber` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`internetContractDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NOT NULL,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`sipUsername` VARCHAR(191) NULL,
`sipPasswordEncrypted` VARCHAR(191) NULL,
`sipServer` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MobileContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`requiresMultisim` BOOLEAN NOT NULL DEFAULT false,
`dataVolume` DOUBLE NULL,
`includedMinutes` INTEGER NULL,
`includedSMS` INTEGER NULL,
`deviceModel` VARCHAR(191) NULL,
`deviceImei` VARCHAR(191) NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SimCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`mobileDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`pin` VARCHAR(191) NULL,
`puk` VARCHAR(191) NULL,
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TvContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`receiverModel` VARCHAR(191) NULL,
`smartcardNumber` VARCHAR(191) NULL,
`package` VARCHAR(191) NULL,
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CarInsuranceDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`licensePlate` VARCHAR(191) NULL,
`hsn` VARCHAR(191) NULL,
`tsn` VARCHAR(191) NULL,
`vin` VARCHAR(191) NULL,
`vehicleType` VARCHAR(191) NULL,
`firstRegistration` DATETIME(3) NULL,
`noClaimsClass` VARCHAR(191) NULL,
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
`deductiblePartial` DOUBLE NULL,
`deductibleFull` DOUBLE NULL,
`policyNumber` VARCHAR(191) NULL,
`previousInsurer` VARCHAR(191) NULL,
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NOT NULL,
`userRole` TEXT NULL,
`customerId` INTEGER NULL,
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
`resourceType` VARCHAR(191) NOT NULL,
`resourceId` VARCHAR(191) NULL,
`resourceLabel` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NOT NULL,
`httpMethod` VARCHAR(191) NOT NULL,
`ipAddress` VARCHAR(191) NOT NULL,
`userAgent` TEXT NULL,
`changesBefore` LONGTEXT NULL,
`changesAfter` LONGTEXT NULL,
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
`dataSubjectId` INTEGER NULL,
`legalBasis` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL DEFAULT true,
`errorMessage` TEXT NULL,
`durationMs` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`hash` VARCHAR(191) NULL,
`previousHash` VARCHAR(191) NULL,
INDEX `AuditLog_userId_idx`(`userId`),
INDEX `AuditLog_customerId_idx`(`customerId`),
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
INDEX `AuditLog_action_idx`(`action`),
INDEX `AuditLog_createdAt_idx`(`createdAt`),
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerConsent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`version` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerConsent_customerId_idx`(`customerId`),
INDEX `CustomerConsent_consentType_idx`(`consentType`),
INDEX `CustomerConsent_status_idx`(`status`),
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `DataDeletionRequest` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`requestSource` VARCHAR(191) NOT NULL,
`requestedBy` VARCHAR(191) NOT NULL,
`processedAt` DATETIME(3) NULL,
`processedBy` VARCHAR(191) NULL,
`deletedData` LONGTEXT NULL,
`retainedData` LONGTEXT NULL,
`retentionReason` TEXT NULL,
`proofDocument` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
INDEX `DataDeletionRequest_status_idx`(`status`),
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditRetentionPolicy` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resourceType` VARCHAR(191) NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
`retentionDays` INTEGER NOT NULL,
`description` VARCHAR(191) NULL,
`legalBasis` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SecurityEvent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`type` ENUM('LOGIN_FAILED', 'LOGIN_SUCCESS', 'RATE_LIMIT_HIT', 'ACCESS_DENIED', 'SSRF_BLOCKED', 'PASSWORD_RESET_REQUEST', 'PASSWORD_RESET_CONFIRM', 'LOGOUT', 'TOKEN_REJECTED', 'PERMISSION_CHANGED', 'SUSPICIOUS') NOT NULL,
`severity` ENUM('INFO', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL,
`message` TEXT NOT NULL,
`ipAddress` VARCHAR(191) NULL,
`userId` INTEGER NULL,
`customerId` INTEGER NULL,
`userEmail` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NULL,
`details` JSON NULL,
`alerted` BOOLEAN NOT NULL DEFAULT false,
`alertedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `SecurityEvent_type_createdAt_idx`(`type`, `createdAt`),
INDEX `SecurityEvent_severity_createdAt_idx`(`severity`, `createdAt`),
INDEX `SecurityEvent_ipAddress_createdAt_idx`(`ipAddress`, `createdAt`),
INDEX `SecurityEvent_alerted_severity_idx`(`alerted`, `severity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractDocument` ADD CONSTRAINT `ContractDocument_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,354 +0,0 @@
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`customerId` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_customerId_key`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Role_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Permission` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resource` VARCHAR(191) NOT NULL,
`action` VARCHAR(191) NOT NULL,
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`roleId` INTEGER NOT NULL,
`permissionId` INTEGER NOT NULL,
PRIMARY KEY (`roleId`, `permissionId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `UserRole` (
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,
PRIMARY KEY (`userId`, `roleId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Customer` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerNumber` VARCHAR(191) NOT NULL,
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
`salutation` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`companyName` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`mobile` VARCHAR(191) NULL,
`taxNumber` VARCHAR(191) NULL,
`businessRegistration` TEXT NULL,
`commercialRegister` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Address` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
`street` VARCHAR(191) NOT NULL,
`houseNumber` VARCHAR(191) NOT NULL,
`postalCode` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `BankCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`accountHolder` VARCHAR(191) NOT NULL,
`iban` VARCHAR(191) NOT NULL,
`bic` VARCHAR(191) NULL,
`bankName` VARCHAR(191) NULL,
`expiryDate` DATETIME(3) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `IdentityDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
`documentNumber` VARCHAR(191) NOT NULL,
`issuingAuthority` VARCHAR(191) NULL,
`issueDate` DATETIME(3) NULL,
`expiryDate` DATETIME(3) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Meter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`meterNumber` VARCHAR(191) NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
`location` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MeterReading` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`meterId` INTEGER NOT NULL,
`readingDate` DATETIME(3) NOT NULL,
`value` DOUBLE NOT NULL,
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SalesPlatform` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`contactInfo` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Contract` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractNumber` VARCHAR(191) NOT NULL,
`customerId` INTEGER NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED') NOT NULL DEFAULT 'DRAFT',
`addressId` INTEGER NULL,
`bankCardId` INTEGER NULL,
`identityDocumentId` INTEGER NULL,
`salesPlatformId` INTEGER NULL,
`previousContractId` INTEGER NULL,
`providerName` VARCHAR(191) NULL,
`tariffName` VARCHAR(191) NULL,
`customerNumberAtProvider` VARCHAR(191) NULL,
`startDate` DATETIME(3) NULL,
`endDate` DATETIME(3) NULL,
`cancellationPeriod` INTEGER NULL,
`commission` DOUBLE NULL,
`portalUsername` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EnergyContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`meterId` INTEGER NULL,
`annualConsumption` DOUBLE NULL,
`basePrice` DOUBLE NULL,
`unitPrice` DOUBLE NULL,
`bonus` DOUBLE NULL,
`previousProviderName` VARCHAR(191) NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `InternetContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`downloadSpeed` INTEGER NULL,
`uploadSpeed` INTEGER NULL,
`routerModel` VARCHAR(191) NULL,
`routerSerialNumber` VARCHAR(191) NULL,
`installationDate` DATETIME(3) NULL,
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PhoneNumber` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`internetContractDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NOT NULL,
`isMain` BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MobileContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`dataVolume` DOUBLE NULL,
`includedMinutes` INTEGER NULL,
`includedSMS` INTEGER NULL,
`deviceModel` VARCHAR(191) NULL,
`deviceImei` VARCHAR(191) NULL,
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TvContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`receiverModel` VARCHAR(191) NULL,
`smartcardNumber` VARCHAR(191) NULL,
`package` VARCHAR(191) NULL,
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CarInsuranceDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`licensePlate` VARCHAR(191) NULL,
`hsn` VARCHAR(191) NULL,
`tsn` VARCHAR(191) NULL,
`vin` VARCHAR(191) NULL,
`vehicleType` VARCHAR(191) NULL,
`firstRegistration` DATETIME(3) NULL,
`noClaimsClass` VARCHAR(191) NULL,
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
`deductiblePartial` DOUBLE NULL,
`deductibleFull` DOUBLE NULL,
`policyNumber` VARCHAR(191) NULL,
`previousInsurer` VARCHAR(191) NULL,
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE `BankCard` ADD COLUMN `documentPath` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `IdentityDocument` ADD COLUMN `documentPath` VARCHAR(191) NULL;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `birthDate` DATETIME(3) NULL,
ADD COLUMN `birthPlace` VARCHAR(191) NULL;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE `IdentityDocument` ADD COLUMN `licenseClasses` VARCHAR(191) NULL,
ADD COLUMN `licenseIssueDate` DATETIME(3) NULL;
@@ -1,14 +0,0 @@
/*
Warnings:
- You are about to drop the column `businessRegistration` on the `Customer` table. All the data in the column will be lost.
- You are about to drop the column `commercialRegister` on the `Customer` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Customer` DROP COLUMN `businessRegistration`,
DROP COLUMN `commercialRegister`,
ADD COLUMN `businessRegistrationPath` VARCHAR(191) NULL,
ADD COLUMN `commercialRegisterNumber` VARCHAR(191) NULL,
ADD COLUMN `commercialRegisterPath` VARCHAR(191) NULL,
ADD COLUMN `foundingDate` DATETIME(3) NULL;
@@ -1,31 +0,0 @@
/*
Warnings:
- You are about to drop the column `cancellationPeriod` on the `Contract` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Contract` DROP COLUMN `cancellationPeriod`,
ADD COLUMN `cancellationPeriodId` INTEGER NULL,
ADD COLUMN `priceAfter24Months` VARCHAR(191) NULL,
ADD COLUMN `priceFirst12Months` VARCHAR(191) NULL,
ADD COLUMN `priceFrom13Months` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `privacyPolicyPath` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `CancellationPeriod` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,18 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractDurationId` INTEGER NULL;
-- CreateTable
CREATE TABLE `ContractDuration` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractDuration_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `cancellationConfirmationDate` DATETIME(3) NULL,
ADD COLUMN `cancellationConfirmationOptionsDate` DATETIME(3) NULL,
ADD COLUMN `cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationConfirmationPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationLetterOptionsPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationLetterPath` VARCHAR(191) NULL,
ADD COLUMN `wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false;
@@ -1,40 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `providerId` INTEGER NULL,
ADD COLUMN `tariffId` INTEGER NULL;
-- CreateTable
CREATE TABLE `Provider` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`portalUrl` VARCHAR(191) NULL,
`usernameFieldName` VARCHAR(191) NULL,
`passwordFieldName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Provider_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Tariff` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`providerId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,21 +0,0 @@
-- AlterTable
ALTER TABLE `MobileContractDetails` ADD COLUMN `requiresMultisim` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `SimCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`mobileDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`pin` VARCHAR(191) NULL,
`puk` VARCHAR(191) NULL,
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,21 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractCategoryId` INTEGER NULL;
-- CreateTable
CREATE TABLE `ContractCategory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`icon` VARCHAR(191) NULL,
`color` VARCHAR(191) NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractCategory_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,13 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` MODIFY `type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL;
-- AlterTable
ALTER TABLE `InternetContractDetails` ADD COLUMN `activationCode` VARCHAR(191) NULL,
ADD COLUMN `homeId` VARCHAR(191) NULL,
ADD COLUMN `internetPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `internetUsername` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `PhoneNumber` ADD COLUMN `sipPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `sipServer` VARCHAR(191) NULL,
ADD COLUMN `sipUsername` VARCHAR(191) NULL;
@@ -1,180 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[portalEmail]` on the table `Customer` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `stressfreiEmailId` INTEGER NULL,
MODIFY `status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT';
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `portalEmail` VARCHAR(191) NULL,
ADD COLUMN `portalEnabled` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `portalLastLogin` DATETIME(3) NULL,
ADD COLUMN `portalPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `portalPasswordHash` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `AppSetting` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AppSetting_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerRepresentative` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`notes` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailProviderConfig` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
`apiUrl` VARCHAR(191) NOT NULL,
`apiKey` VARCHAR(191) NULL,
`username` VARCHAR(191) NULL,
`passwordEncrypted` VARCHAR(191) NULL,
`domain` VARCHAR(191) NOT NULL,
`defaultForwardEmail` VARCHAR(191) NULL,
`imapServer` VARCHAR(191) NULL,
`imapPort` INTEGER NULL DEFAULT 993,
`smtpServer` VARCHAR(191) NULL,
`smtpPort` INTEGER NULL DEFAULT 465,
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `StressfreiEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`email` VARCHAR(191) NOT NULL,
`platform` VARCHAR(191) NULL,
`notes` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
`provisionedAt` DATETIME(3) NULL,
`provisionError` TEXT NULL,
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
`emailPasswordEncrypted` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CachedEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`stressfreiEmailId` INTEGER NOT NULL,
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
`messageId` VARCHAR(191) NOT NULL,
`uid` INTEGER NOT NULL,
`subject` VARCHAR(191) NULL,
`fromAddress` VARCHAR(191) NOT NULL,
`fromName` VARCHAR(191) NULL,
`toAddresses` TEXT NOT NULL,
`ccAddresses` TEXT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`textBody` LONGTEXT NULL,
`htmlBody` LONGTEXT NULL,
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
`attachmentNames` TEXT NULL,
`contractId` INTEGER NULL,
`assignedAt` DATETIME(3) NULL,
`assignedBy` INTEGER NULL,
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
`isRead` BOOLEAN NOT NULL DEFAULT false,
`isStarred` BOOLEAN NOT NULL DEFAULT false,
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
`deletedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CachedEmail_contractId_idx`(`contractId`),
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTaskSubtask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `Customer_portalEmail_key` ON `Customer`(`portalEmail`);
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `tokenInvalidatedAt` DATETIME(3) NULL;
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `billingAddressId` INTEGER NULL;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `EnergyContractDetails` ADD COLUMN `annualConsumptionKwh` DOUBLE NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `EnergyContractDetails` ADD COLUMN `maloId` VARCHAR(191) NULL;
@@ -1,17 +0,0 @@
-- CreateTable
CREATE TABLE `Invoice` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NOT NULL,
`invoiceDate` DATETIME(3) NOT NULL,
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
`documentPath` VARCHAR(191) NULL,
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `nextReviewDate` DATETIME(3) NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractNumberAtProvider` VARCHAR(191) NULL;
@@ -1,7 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `previousContractNumber` VARCHAR(191) NULL,
ADD COLUMN `previousCustomerNumber` VARCHAR(191) NULL,
ADD COLUMN `previousProviderId` INTEGER NULL;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE `ContractHistoryEntry` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,103 +0,0 @@
-- CreateTable
CREATE TABLE `AuditLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NOT NULL,
`userRole` VARCHAR(191) NULL,
`customerId` INTEGER NULL,
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
`resourceType` VARCHAR(191) NOT NULL,
`resourceId` VARCHAR(191) NULL,
`resourceLabel` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NOT NULL,
`httpMethod` VARCHAR(191) NOT NULL,
`ipAddress` VARCHAR(191) NOT NULL,
`userAgent` TEXT NULL,
`changesBefore` LONGTEXT NULL,
`changesAfter` LONGTEXT NULL,
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
`dataSubjectId` INTEGER NULL,
`legalBasis` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL DEFAULT true,
`errorMessage` TEXT NULL,
`durationMs` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`hash` VARCHAR(191) NULL,
`previousHash` VARCHAR(191) NULL,
INDEX `AuditLog_userId_idx`(`userId`),
INDEX `AuditLog_customerId_idx`(`customerId`),
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
INDEX `AuditLog_action_idx`(`action`),
INDEX `AuditLog_createdAt_idx`(`createdAt`),
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerConsent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`version` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerConsent_customerId_idx`(`customerId`),
INDEX `CustomerConsent_consentType_idx`(`consentType`),
INDEX `CustomerConsent_status_idx`(`status`),
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `DataDeletionRequest` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`requestSource` VARCHAR(191) NOT NULL,
`requestedBy` VARCHAR(191) NOT NULL,
`processedAt` DATETIME(3) NULL,
`processedBy` VARCHAR(191) NULL,
`deletedData` LONGTEXT NULL,
`retainedData` LONGTEXT NULL,
`retentionReason` TEXT NULL,
`proofDocument` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
INDEX `DataDeletionRequest_status_idx`(`status`),
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditRetentionPolicy` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resourceType` VARCHAR(191) NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
`retentionDays` INTEGER NOT NULL,
`description` VARCHAR(191) NULL,
`legalBasis` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,10 +0,0 @@
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `consentHash` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `User` ADD COLUMN `whatsappNumber` VARCHAR(191) NULL,
ADD COLUMN `telegramUsername` VARCHAR(191) NULL,
ADD COLUMN `signalNumber` VARCHAR(191) NULL;
-- CreateIndex
CREATE UNIQUE INDEX `Customer_consentHash_key` ON `Customer`(`consentHash`);
@@ -0,0 +1,5 @@
-- AlterTable
-- IF NOT EXISTS: macht das Hochziehen auf prod-DBs sicher, die das Feld
-- über `prisma db push` schon erhalten haben (vor dem Migrations-Workflow).
-- MariaDB unterstützt das seit 10.0.2, MySQL 8 ebenfalls.
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,23 @@
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
--
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
-- jemand vorher manuell `prisma db push` gefahren hat.
CREATE TABLE IF NOT EXISTS `BackupLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
`backupName` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL,
`durationMs` INTEGER NOT NULL DEFAULT 0,
`summary` TEXT NOT NULL,
`fullLog` LONGTEXT NOT NULL,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
INDEX `BackupLog_createdAt_idx`(`createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+94
View File
@@ -0,0 +1,94 @@
/**
* Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB.
* Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein
* Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte
* E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift).
*
* Aufruf:
* npx tsx prisma/reset-admin-password.ts <email> # generiert PW
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # eigenes PW
*
* Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions
* dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung
* gestohlener Tokens).
*/
import bcrypt from 'bcryptjs';
import prisma from '../src/lib/prisma.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js';
const BCRYPT_COST = 12;
function generateRescuePassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
async function main() {
const email = process.argv[2];
const providedPw = process.argv[3];
if (!email) {
console.error('Aufruf: npx tsx prisma/reset-admin-password.ts <email> [passwort]');
process.exit(1);
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, firstName: true, lastName: true },
});
if (!user) {
console.error(`User "${email}" nicht gefunden.`);
process.exit(2);
}
let plain: string;
if (providedPw) {
const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:');
for (const e of c.errors) console.error(' - ' + e);
process.exit(3);
}
plain = providedPw;
} else {
plain = generateRescuePassword();
}
const hash = await bcrypt.hash(plain, BCRYPT_COST);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
passwordResetToken: null,
passwordResetExpiresAt: null,
tokenInvalidatedAt: new Date(),
},
});
console.log('========================================================');
console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`);
console.log(` Neues Passwort: ${plain}`);
console.log(' ⚠️ Wird hier EINMAL ausgegeben sofort kopieren!');
console.log(' Alle bestehenden Sessions wurden invalidiert.');
console.log('========================================================');
}
main()
.catch((e) => {
console.error('Reset fehlgeschlagen:', e);
process.exit(99);
})
.finally(async () => {
await prisma.$disconnect();
});
+81
View File
@@ -172,6 +172,10 @@ model Customer {
portalPasswordResetExpiresAt DateTime? portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung) // Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime? portalTokenInvalidatedAt DateTime?
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
// Frontend in Force-Change-Password-Flow geleitet.
portalPasswordMustChange Boolean @default(false)
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen) // Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int? lastBirthdayGreetingYear Int?
@@ -1113,3 +1117,80 @@ model AuditRetentionPolicy {
@@unique([resourceType, sensitivity]) @@unique([resourceType, sensitivity])
} }
// ==================== SECURITY MONITORING ====================
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
// effizient querybar). Threshold-Detection läuft per Cron.
enum SecurityEventType {
LOGIN_FAILED // falsches Passwort / unbekannter User
LOGIN_SUCCESS // erfolgreicher Login (informativ)
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
SUSPICIOUS // generischer Catch-All
}
enum SecuritySeverity {
INFO // Login-Success, Logout
LOW // Einzelner failed Login, einzelner 403
MEDIUM // Rate-Limit-Hit, mehrere 403er
HIGH // SSRF-Block, JWT-Manipulation
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
enum BackupOperation {
CREATE
RESTORE
}
// Persistiertes Log für Backup-Vorgänge.
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
// geschrieben.
model BackupLog {
id Int @id @default(autoincrement())
operation BackupOperation
backupName String?
success Boolean
durationMs Int @default(0)
summary String @db.Text
fullLog String @db.LongText
userId Int?
userEmail String?
ipAddress String?
createdAt DateTime @default(now())
@@index([operation, createdAt])
@@index([createdAt])
}
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
severity SecuritySeverity
message String @db.Text
ipAddress String?
userId Int? // Mitarbeiter (falls eingeloggt)
customerId Int? // Portal-Kunde (falls eingeloggt)
userEmail String? // beste Schätzung auch bei nicht eingeloggt
endpoint String? // betroffener Endpoint
details Json? // strukturierte Zusatzinfo
alerted Boolean @default(false) // schon per Email versendet?
alertedAt DateTime?
createdAt DateTime @default(now())
@@index([type, createdAt])
@@index([severity, createdAt])
@@index([ipAddress, createdAt])
@@index([alerted, severity])
}
+48 -3
View File
@@ -221,8 +221,41 @@ async function main() {
console.log('Roles created'); console.log('Roles created');
// Create admin user // Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
const hashedPassword = await bcrypt.hash('admin', 10); // landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
// Komplexitätspolicy). Stattdessen:
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
// Passwort-vergessen-Flow nutzen.
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
function generateInitialPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
// Kryptografisch sichere Auswahl Math.random() ist vorhersagbar
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
// mind. einen aus jeder Klasse + Rest zufällig
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
// Mitarbeiter-Schwellwert (Pentest Runde 13).
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
for (let i = chars.length - 1; i > 0; i--) {
const j = crypto.randomInt(0, i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
const envPassword = process.env.SEED_ADMIN_PASSWORD;
const adminPlainPassword = envPassword && envPassword.length >= 25
? envPassword
: generateInitialPassword();
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.upsert({ const adminUser = await prisma.user.upsert({
where: { email: 'admin@admin.com' }, where: { email: 'admin@admin.com' },
@@ -238,7 +271,19 @@ async function main() {
}, },
}); });
console.log('Admin user created: admin@admin.com / admin'); console.log('========================================================');
console.log(' Admin-User: admin@admin.com');
if (envPassword && envPassword.length >= 25) {
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
} else {
if (envPassword && envPassword.length < 25) {
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
}
console.log(` Initial-Passwort: ${adminPlainPassword}`);
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
console.log(' Bitte sofort nach dem ersten Login ändern.');
}
console.log('========================================================');
// Create some sales platforms // Create some sales platforms
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung']; const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
+162
View File
@@ -0,0 +1,162 @@
/**
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
*
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
customers: ['create', 'read', 'update', 'delete'],
contracts: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
platforms: ['create', 'read', 'update', 'delete'],
providers: ['create', 'read', 'update', 'delete'],
tariffs: ['create', 'read', 'update', 'delete'],
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
developer: ['access'],
emails: ['delete'],
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
const existing = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const existingIds = new Set(existing.map((e) => e.permissionId));
const targetIds = new Set(permissionIds);
const missing = permissionIds.filter((id) => !existingIds.has(id));
if (missing.length > 0) {
await prisma.rolePermission.createMany({
data: missing.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
}
const excess = existing
.filter((e) => !targetIds.has(e.permissionId))
.map((e) => e.permissionId);
if (excess.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: excess } },
});
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
}
}
async function main() {
console.log('[sync-roles] Permissions-Katalog upserten…');
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
for (const action of actions) {
await prisma.permission.upsert({
where: { resource_action: { resource, action } },
update: {},
create: { resource, action },
});
}
}
const allPermissions = await prisma.permission.findMany();
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
// sind separate hidden roles, über Checkboxen zugewiesen)
const adminPermIds = allPermissions
.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
)
.map((p) => p.id);
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// DSGVO: audit + gdpr komplett
const gdprPermIds = allPermissions
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' &&
[
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
const readOnlyResources = [
'customers',
'contracts',
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
];
const readOnlyPermIds = allPermissions
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
.map((p) => p.id);
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
for (const r of rolesSpec) {
const role = await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: { name: r.name, description: r.description },
});
await syncRolePermissions(role.id, r.permIds);
}
console.log('[sync-roles] fertig.');
}
main()
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+44 -1
View File
@@ -15,7 +15,11 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const ROOT = path.join(process.cwd(), 'factory-defaults'); // ROOT kann via FACTORY_DEFAULTS_DIR überschrieben werden (Container-Bootstrap
// mit eingebauten Defaults aus dem Image).
const ROOT = process.env.FACTORY_DEFAULTS_DIR
? path.resolve(process.env.FACTORY_DEFAULTS_DIR)
: path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads'); const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates'); const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
@@ -61,6 +65,19 @@ interface PdfTemplateDef {
pdfFilename: string; // Dateiname im pdf-templates/-Ordner pdfFilename: string; // Dateiname im pdf-templates/-Ordner
} }
interface AppSettingDef {
key: string;
value: string;
}
// Whitelist muss synchron zu factoryDefaults.service.ts sein.
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
'privacyPolicyHtml',
'authorizationTemplateHtml',
'imprintHtml',
'websitePrivacyPolicyHtml',
]);
/** /**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück. * Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/ */
@@ -299,6 +316,31 @@ async function seedPdfTemplates() {
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`); console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
} }
async function seedAppSettings() {
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
if (items.length === 0) {
console.log(' app-settings keine Einträge');
return;
}
let count = 0;
let skipped = 0;
for (const s of items) {
if (!s.key || typeof s.value !== 'string') continue;
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist übersprungen`);
skipped++;
continue;
}
await prisma.appSetting.upsert({
where: { key: s.key },
update: { value: s.value },
create: { key: s.key, value: s.value },
});
count++;
}
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() { async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n'); console.log('\n📦 Factory-Defaults werden eingespielt...\n');
@@ -313,6 +355,7 @@ async function main() {
await seedContractDurations(); await seedContractDurations();
await seedContractCategories(); await seedContractCategories();
await seedPdfTemplates(); await seedPdfTemplates();
await seedAppSettings();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n'); console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
} }
@@ -41,10 +41,22 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
return; return;
} }
// Whitelist-Check (Pentest Runde 11, M1)
if (!appSettingService.isAllowedSettingKey(key)) {
res.status(400).json({
success: false,
error: `Unbekannter Setting-Key: ${key}`,
} as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
const before = await prisma.appSetting.findUnique({ where: { key } }); const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-'; const oldValue = before?.value ?? '-';
const newValue = String(value); // HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
// sonst ungefiltert in E-Mail-Templates / PDFs.
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
await appSettingService.setSetting(key, newValue); await appSettingService.setSetting(key, newValue);
@@ -78,12 +90,24 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
return; return;
} }
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
const unknownKeys = Object.keys(settings).filter(
(k) => !appSettingService.isAllowedSettingKey(k),
);
if (unknownKeys.length > 0) {
res.status(400).json({
success: false,
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
} as ApiResponse);
return;
}
// Vorherige Werte laden für Audit // Vorherige Werte laden für Audit
const changes: Record<string, { von: unknown; nach: unknown }> = {}; const changes: Record<string, { von: unknown; nach: unknown }> = {};
for (const [key, value] of Object.entries(settings)) { for (const [key, value] of Object.entries(settings)) {
const before = await prisma.appSetting.findUnique({ where: { key } }); const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-'; const oldValue = before?.value ?? '-';
const newValue = String(value); const newValue = appSettingService.sanitizeSettingValue(key, String(value));
if (oldValue !== newValue) { if (oldValue !== newValue) {
changes[key] = { von: oldValue, nach: newValue }; changes[key] = { von: oldValue, nach: newValue };
} }
+307 -11
View File
@@ -1,12 +1,57 @@
import { Request, Response } from 'express'; import { Request, Response, CookieOptions } from 'express';
import * as authService from '../services/auth.service.js'; import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js'; import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
const REFRESH_COOKIE_NAME = 'refresh_token';
function getRefreshCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: process.env.HTTPS_ENABLED === 'true',
sameSite: 'strict',
path: '/api/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
};
}
function setRefreshCookie(res: Response, token: string): void {
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
}
function clearRefreshCookie(res: Response): void {
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
}
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
// wird als generisches "Anmeldung fehlgeschlagen" maskiert die Original-
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
const SAFE_LOGIN_ERRORS = new Set([
'Ungültige Anmeldedaten',
'E-Mail und Passwort erforderlich',
]);
function safeLoginError(err: unknown): string {
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
return err.message;
}
if (err instanceof Error) {
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
}
return 'Anmeldung fehlgeschlagen';
}
// Mitarbeiter-Login // Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> { export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try { try {
const { email, password } = req.body;
if (!email || !password) { if (!email || !password) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -16,20 +61,43 @@ export async function login(req: Request, res: Response): Promise<void> {
} }
const result = await authService.login(email, password); const result = await authService.login(email, password);
res.json({ success: true, data: result } as ApiResponse); // Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Mitarbeiter-Login: ${email}`,
ipAddress: ctx.ipAddress,
userId: result.user.id,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) { } catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen', error: safeLoginError(error),
} as ApiResponse); } as ApiResponse);
} }
} }
// Kundenportal-Login // Kundenportal-Login
export async function customerLogin(req: Request, res: Response): Promise<void> { export async function customerLogin(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try { try {
const { email, password } = req.body;
if (!email || !password) { if (!email || !password) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -39,11 +107,32 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
} }
const result = await authService.customerLogin(email, password); const result = await authService.customerLogin(email, password);
res.json({ success: true, data: result } as ApiResponse); setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Portal-Login: ${email}`,
ipAddress: ctx.ipAddress,
customerId: result.user.customerId,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) { } catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen', error: safeLoginError(error),
} as ApiResponse); } as ApiResponse);
} }
} }
@@ -114,6 +203,17 @@ export async function requestPasswordReset(req: Request, res: Response): Promise
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin'); await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_REQUEST',
severity: 'MEDIUM',
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
});
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren // IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
res.json({ res.json({
success: true, success: true,
@@ -144,21 +244,43 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
return; return;
} }
if (password.length < 6) { // Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen
// verlangt und Portal-Customer-Reset weiterhin 12 reicht.
const audience = await authService.getPasswordResetAudience(token);
const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH;
const complexity = validatePasswordComplexity(password, { minLength });
if (!complexity.ok) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein', error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse); } as ApiResponse);
return; return;
} }
await authService.confirmPasswordReset(token, password); await authService.confirmPasswordReset(token, password);
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_CONFIRM',
severity: 'HIGH',
message: 'Passwort-Reset abgeschlossen',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.json({ res.json({
success: true, success: true,
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.', message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: 'MEDIUM',
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen', error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
@@ -166,6 +288,87 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
} }
} }
/**
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
*
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
* (auch andere Geräte) akzeptabel für ein Sicherheits-Logout.
*/
export async function logout(req: AuthRequest, res: Response): Promise<void> {
try {
const user = req.user as any;
if (!user) {
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
return;
}
if (user.isCustomerPortal && user.customerId) {
await prisma.customer.update({
where: { id: user.customerId },
data: { portalTokenInvalidatedAt: new Date() },
});
} else if (user.userId) {
await prisma.user.update({
where: { id: user.userId },
data: { tokenInvalidatedAt: new Date() },
});
}
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
// aber UI würde sich verirren).
clearRefreshCookie(res);
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'LOGOUT',
severity: 'INFO',
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
customerId: ctx.customerId,
userEmail: user.email,
endpoint: ctx.endpoint,
});
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Abmelden',
} as ApiResponse);
}
}
// Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend
// (axios-Interceptor) bei 401 oder beim App-Start aufgerufen.
export async function refresh(req: Request, res: Response): Promise<void> {
try {
const cookies = (req as any).cookies || {};
const refreshToken = cookies[REFRESH_COOKIE_NAME];
if (!refreshToken) {
res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse);
return;
}
const result = await authService.refreshAccessToken(refreshToken);
// Refresh-Cookie rotieren verhindert Replay eines geklauten Refresh-Tokens
// bis zur vollen Lifetime.
setRefreshCookie(res, result.refreshToken);
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
clearRefreshCookie(res);
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
} as ApiResponse);
}
}
export async function register(req: Request, res: Response): Promise<void> { export async function register(req: Request, res: Response): Promise<void> {
try { try {
const { email, password, firstName, lastName, roleIds } = req.body; const { email, password, firstName, lastName, roleIds } = req.body;
@@ -178,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
return; return;
} }
// Mitarbeiter-Anlage: 25-Zeichen-Schwellwert
const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
const user = await authService.createUser({ const user = await authService.createUser({
email, email,
password, password,
@@ -197,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
} as ApiResponse); } as ApiResponse);
} }
} }
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const payload: any = {
email: req.user.email,
permissions: req.user.permissions,
isCustomerPortal: !!req.user.isCustomerPortal,
};
if (req.user.userId) payload.userId = req.user.userId;
if (req.user.customerId) payload.customerId = req.user.customerId;
if ((req.user as any).representedCustomerIds) {
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
}
const token = authService.signDownloadToken(payload);
res.json({ success: true, data: { token } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Erstellen des Download-Tokens',
} as ApiResponse);
}
}
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
// loggt aus und schickt zurück zum Login.
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Login',
} as ApiResponse);
return;
}
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) KRITISCH.
const customer = await prisma.customer.findUnique({
where: { id: req.user.customerId },
select: { portalPasswordMustChange: true },
});
if (!customer?.portalPasswordMustChange) {
res.status(403).json({
success: false,
error: 'Nicht erlaubt',
} as ApiResponse);
return;
}
const { newPassword } = req.body || {};
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Neues Passwort erforderlich',
} as ApiResponse);
return;
}
const complexity = validatePasswordComplexity(newPassword);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
}
}
+253 -4
View File
@@ -1,5 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as backupService from '../services/backup.service.js'; import * as backupService from '../services/backup.service.js';
import prisma from '../lib/prisma.js';
/** /**
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf * Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
} }
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
// process-globale Patch-Variante.
function startLogCapture(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
function fmt(args: unknown[]): string {
return args
.map((a) => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
})
.join(' ');
}
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
return {
lines,
restore: () => {
console.log = orig.log;
console.info = orig.info;
console.warn = orig.warn;
console.error = orig.error;
},
};
}
async function recordBackupLog(opts: {
req: Request;
operation: 'CREATE' | 'RESTORE';
backupName: string | null;
success: boolean;
durationMs: number;
summary: string;
fullLog: string;
}) {
try {
const user = (opts.req as any).user;
await prisma.backupLog.create({
data: {
operation: opts.operation,
backupName: opts.backupName,
success: opts.success,
durationMs: opts.durationMs,
summary: opts.summary.slice(0, 2000),
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
fullLog: opts.fullLog.slice(0, 1_000_000),
userId: user?.userId ?? null,
userEmail: user?.email ?? null,
ipAddress:
(opts.req as any).socket?.remoteAddress ||
(opts.req.headers?.['x-forwarded-for'] as string) ||
null,
},
});
} catch (err) {
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
}
}
/** /**
* Liste aller Backups abrufen * Liste aller Backups abrufen
* GET /api/settings/backups * GET /api/settings/backups
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
* POST /api/settings/backup * POST /api/settings/backup
*/ */
export async function createBackup(req: Request, res: Response) { export async function createBackup(req: Request, res: Response) {
const start = Date.now();
const capture = startLogCapture();
try { try {
const result = await backupService.createBackup(); const result = await backupService.createBackup();
const durationMs = Date.now() - start;
if (result.success) { if (result.success) {
capture.restore();
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'CREATE', backupName: result.backupName ?? null,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'Backup', req, action: 'CREATE', resourceType: 'Backup',
label: `Backup ${result.backupName} erstellt`, label: `Backup ${result.backupName} erstellt`,
}); });
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' }); res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
} else { } else {
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error }); res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
} }
} catch (error: any) { } catch (error: any) {
const durationMs = Date.now() - start;
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Fehler: ${error?.message || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message }); res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
} }
} }
@@ -50,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
* Backup wiederherstellen * Backup wiederherstellen
* POST /api/settings/backup/:name/restore * POST /api/settings/backup/:name/restore
*/ */
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
// "Cannot read properties of undefined" → "Interner Code-Fehler".
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
function makeRestoreErrorReadable(raw: unknown): string {
if (!raw) return 'Unbekannter Fehler';
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
// Stack-Frames " at …(…:123:45)" abschneiden
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
// alles auf "Operation fehlgeschlagen" maskiert.
s = s
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
.replace(/is not defined$/i, '(Wert nicht definiert)')
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
return s.slice(0, 500); // Längenlimit für UI
}
export async function restoreBackup(req: Request, res: Response) { export async function restoreBackup(req: Request, res: Response) {
try { const start = Date.now();
const { name } = req.params; const { name } = req.params;
if (!name || !isValidBackupName(name)) { if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' }); return res.status(400).json({ error: 'Ungültiger Backup-Name' });
} }
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
// sofort den destruktiven Restore aus ein versehentlicher
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
// Klick) konnte die DB ungewollt überschreiben. Der String ist
// bewusst ein unique Magic-Value, kein Boolean.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'RESTORE-BESTAETIGT') {
return res.status(400).json({
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
});
}
const capture = startLogCapture();
try {
const result = await backupService.restoreBackup(name); const result = await backupService.restoreBackup(name);
const durationMs = Date.now() - start;
if (result.success) { if (result.success) {
capture.restore();
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'Backup', req, action: 'UPDATE', resourceType: 'Backup',
label: `Backup ${name} wiederhergestellt`, label: `Backup ${name} wiederhergestellt`,
@@ -73,10 +222,35 @@ export async function restoreBackup(req: Request, res: Response) {
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`, message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
}); });
} else { } else {
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error }); console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({
error: 'Wiederherstellung fehlgeschlagen',
details: makeRestoreErrorReadable(result.error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
} }
} catch (error: any) { } catch (error: any) {
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message }); const durationMs = Date.now() - start;
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({
error: 'Fehler bei der Wiederherstellung',
details: makeRestoreErrorReadable(error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
} }
} }
@@ -176,6 +350,22 @@ export async function uploadBackup(req: Request, res: Response) {
*/ */
export async function factoryReset(req: Request, res: Response) { export async function factoryReset(req: Request, res: Response) {
try { try {
// Bestätigung erforderlich: client MUSS explizit
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
// POST plätten (Pentest Runde 11 (2026-05-18) C2 KRITISCH:
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
// Replay-Angriff aus Versehen triggern kann.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
res.status(400).json({
success: false,
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
});
return;
}
const result = await backupService.factoryReset(); const result = await backupService.factoryReset();
if (result.success) { if (result.success) {
@@ -190,6 +380,65 @@ export async function factoryReset(req: Request, res: Response) {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error }); res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
} }
} catch (error: any) { } catch (error: any) {
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message }); res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
console.error('factoryReset error:', error);
}
}
/**
* Liste der Backup-Logs (CREATE oder RESTORE)
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
* Liefert die Übersichtsdaten OHNE den großen fullLog.
*/
export async function listBackupLogs(req: Request, res: Response) {
try {
const op = String(req.query.operation || '').toUpperCase();
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
const where: any = {};
if (op === 'CREATE' || op === 'RESTORE') {
where.operation = op;
}
const logs = await prisma.backupLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
operation: true,
backupName: true,
success: true,
durationMs: true,
summary: true,
userEmail: true,
ipAddress: true,
createdAt: true,
},
});
res.json({ data: logs });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
}
}
/**
* Detail eines Backup-Logs inkl. fullLog
* GET /api/settings/backup-logs/:id
*/
export async function getBackupLogDetail(req: Request, res: Response) {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
return res.status(400).json({ error: 'Ungültige ID' });
}
const log = await prisma.backupLog.findUnique({ where: { id } });
if (!log) {
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
}
res.json({ data: log });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
} }
} }
@@ -11,6 +11,13 @@ import { createAuditLog } from '../services/audit.service.js';
*/ */
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) { export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
try { try {
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
// Telefon und Geburtsdatum ALLER Kunden ausschließlich Mitarbeiter-UI.
// Pentest Runde 6 (2026-05-16) HOCH.
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
return;
}
const past = req.query.past ? parseInt(String(req.query.past)) : 7; const past = req.query.past ? parseInt(String(req.query.past)) : 7;
const future = req.query.future ? parseInt(String(req.query.future)) : 30; const future = req.query.future ? parseInt(String(req.query.future)) : 30;
+137 -31
View File
@@ -8,22 +8,37 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js'; import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js'; import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js'; import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js'; import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js'; import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client'; import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import { import {
canAccessCustomer, canAccessCustomer,
canAccessContract, canAccessContract,
canAccessCachedEmail, canAccessCachedEmail,
canAccessStressfreiEmail,
} from '../utils/accessControl.js'; } from '../utils/accessControl.js';
// ==================== E-MAIL LIST ==================== // ==================== E-MAIL LIST ====================
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
function parseBoolParam(v: unknown): boolean | undefined {
if (v === 'true') return true;
if (v === 'false') return false;
return undefined;
}
function parseDateParam(v: unknown): Date | undefined {
if (typeof v !== 'string' || !v.trim()) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
// E-Mails für einen Kunden abrufen // E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> { export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
try { try {
@@ -41,6 +56,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
limit, limit,
offset, offset,
includeBody: false, includeBody: false,
search: typeof req.query.search === 'string' ? req.query.search : undefined,
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
hasAttachments: parseBoolParam(req.query.hasAttachments),
isRead: parseBoolParam(req.query.isRead),
isStarred: parseBoolParam(req.query.isStarred),
receivedFrom: parseDateParam(req.query.receivedFrom),
receivedTo: parseDateParam(req.query.receivedTo),
}); });
res.json({ success: true, data: emails } as ApiResponse); res.json({ success: true, data: emails } as ApiResponse);
@@ -112,9 +138,10 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
} }
// E-Mail als gelesen/ungelesen markieren // E-Mail als gelesen/ungelesen markieren
export async function markAsRead(req: Request, res: Response): Promise<void> { export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const { isRead } = req.body; const { isRead } = req.body;
if (isRead) { if (isRead) {
@@ -134,9 +161,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
} }
// E-Mail Stern umschalten // E-Mail Stern umschalten
export async function toggleStar(req: Request, res: Response): Promise<void> { export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const isStarred = await cachedEmailService.toggleEmailStar(id); const isStarred = await cachedEmailService.toggleEmailStar(id);
res.json({ success: true, data: { isStarred } } as ApiResponse); res.json({ success: true, data: { isStarred } } as ApiResponse);
@@ -152,10 +180,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
// ==================== CONTRACT ASSIGNMENT ==================== // ==================== CONTRACT ASSIGNMENT ====================
// E-Mail einem Vertrag zuordnen // E-Mail einem Vertrag zuordnen
export async function assignToContract(req: Request, res: Response): Promise<void> { export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { contractId } = req.body; const { contractId } = req.body;
if (!(await canAccessContract(req, res, contractId))) return;
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId); const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
@@ -171,9 +201,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
} }
// Vertragszuordnung aufheben // Vertragszuordnung aufheben
export async function unassignFromContract(req: Request, res: Response): Promise<void> { export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const email = await cachedEmailService.unassignEmailFromContract(emailId); const email = await cachedEmailService.unassignEmailFromContract(emailId);
@@ -188,9 +219,10 @@ export async function unassignFromContract(req: Request, res: Response): Promise
} }
// E-Mail-Anzahl pro Ordner für ein Konto // E-Mail-Anzahl pro Ordner für ein Konto
export async function getFolderCounts(req: Request, res: Response): Promise<void> { export async function getFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try { try {
const stressfreiEmailId = parseInt(req.params.id); const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId); const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
@@ -205,9 +237,10 @@ export async function getFolderCounts(req: Request, res: Response): Promise<void
} }
// E-Mail-Anzahl pro Ordner für einen Vertrag // E-Mail-Anzahl pro Ordner für einen Vertrag
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> { export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId); const counts = await cachedEmailService.getFolderCountsForContract(contractId);
@@ -224,9 +257,10 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
// ==================== SYNC & SEND ==================== // ==================== SYNC & SEND ====================
// E-Mails für ein Konto synchronisieren (INBOX + SENT) // E-Mails für ein Konto synchronisieren (INBOX + SENT)
export async function syncAccount(req: Request, res: Response): Promise<void> { export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
try { try {
const stressfreiEmailId = parseInt(req.params.id); const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const fullSync = req.query.full === 'true'; const fullSync = req.query.full === 'true';
// Synchronisiert sowohl INBOX als auch SENT // Synchronisiert sowohl INBOX als auch SENT
@@ -256,12 +290,31 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
} }
} }
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
function hasCRLF(value: unknown): boolean {
if (typeof value === 'string') return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some(hasCRLF);
return false;
}
// E-Mail senden // E-Mail senden
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> { export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try { try {
const stressfreiEmailId = parseInt(req.params.id); const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body; const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
res.status(400).json({
success: false,
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
} as ApiResponse);
return;
}
// StressfreiEmail laden // StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId); const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -514,10 +567,26 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
return; return;
} }
// Datei senden - inline (öffnen) oder attachment (download) // Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
const disposition = req.query.view === 'true' ? 'inline' : 'attachment'; // o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
res.setHeader('Content-Type', attachment.contentType); // zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`); // via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
res.setHeader('Content-Length', attachment.size); res.setHeader('Content-Length', attachment.size);
res.send(attachment.content); res.send(attachment.content);
} catch (error) { } catch (error) {
@@ -547,9 +616,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
// ==================== MAILBOX ACCOUNTS ==================== // ==================== MAILBOX ACCOUNTS ====================
// Mailbox-Konten eines Kunden abrufen // Mailbox-Konten eines Kunden abrufen
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> { export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId); const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
@@ -564,9 +634,10 @@ export async function getMailboxAccounts(req: Request, res: Response): Promise<v
} }
// Mailbox nachträglich aktivieren // Mailbox nachträglich aktivieren
export async function enableMailbox(req: Request, res: Response): Promise<void> { export async function enableMailbox(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.enableMailbox(id); const result = await stressfreiEmailService.enableMailbox(id);
@@ -589,9 +660,10 @@ export async function enableMailbox(req: Request, res: Response): Promise<void>
} }
// Mailbox-Status mit Provider synchronisieren // Mailbox-Status mit Provider synchronisieren
export async function syncMailboxStatus(req: Request, res: Response): Promise<void> { export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.syncMailboxStatus(id); const result = await stressfreiEmailService.syncMailboxStatus(id);
@@ -620,9 +692,10 @@ export async function syncMailboxStatus(req: Request, res: Response): Promise<vo
} }
// E-Mail-Thread abrufen // E-Mail-Thread abrufen
export async function getThread(req: Request, res: Response): Promise<void> { export async function getThread(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const thread = await cachedEmailService.getEmailThread(id); const thread = await cachedEmailService.getEmailThread(id);
@@ -637,9 +710,13 @@ export async function getThread(req: Request, res: Response): Promise<void> {
} }
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP) // Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
export async function getMailboxCredentials(req: Request, res: Response): Promise<void> { export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
// Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit
// bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials
// eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX.
if (!(await canAccessStressfreiEmail(req, res, id))) return;
// StressfreiEmail laden // StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id); const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
@@ -674,6 +751,15 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
// IMAP/SMTP-Einstellungen laden // IMAP/SMTP-Einstellungen laden
const settings = await getImapSmtpSettings(); const settings = await getImapSmtpSettings();
// Klartext-Mailbox-Passwort-Read auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'MailboxCredentials',
resourceId: id.toString(),
label: `Klartext-Mailbox-Zugangsdaten von ${stressfreiEmail.email} entschlüsselt`,
});
res.json({ res.json({
success: true, success: true,
data: { data: {
@@ -701,7 +787,7 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
} }
// Ungelesene E-Mails zählen // Ungelesene E-Mails zählen
export async function getUnreadCount(req: Request, res: Response): Promise<void> { export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined; const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined; const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
@@ -709,8 +795,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
let count = 0; let count = 0;
if (customerId) { if (customerId) {
if (!(await canAccessCustomer(req, res, customerId))) return;
count = await cachedEmailService.getUnreadCountForCustomer(customerId); count = await cachedEmailService.getUnreadCountForCustomer(customerId);
} else if (contractId) { } else if (contractId) {
if (!(await canAccessContract(req, res, contractId))) return;
count = await cachedEmailService.getUnreadCountForContract(contractId); count = await cachedEmailService.getUnreadCountForContract(contractId);
} }
@@ -725,9 +813,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
} }
// E-Mail in Papierkorb verschieben (nur Admin) // E-Mail in Papierkorb verschieben (nur Admin)
export async function deleteEmail(req: Request, res: Response): Promise<void> { export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
// Prüfen ob E-Mail existiert // Prüfen ob E-Mail existiert
const email = await cachedEmailService.getCachedEmailById(id); const email = await cachedEmailService.getCachedEmailById(id);
@@ -762,9 +851,10 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
// ==================== TRASH OPERATIONS ==================== // ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen // Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(req: Request, res: Response): Promise<void> { export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const emails = await cachedEmailService.getTrashEmails(customerId); const emails = await cachedEmailService.getTrashEmails(customerId);
@@ -779,9 +869,10 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
} }
// Papierkorb-Anzahl für einen Kunden // Papierkorb-Anzahl für einen Kunden
export async function getTrashCount(req: Request, res: Response): Promise<void> { export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const count = await cachedEmailService.getTrashCount(customerId); const count = await cachedEmailService.getTrashCount(customerId);
@@ -796,9 +887,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
} }
// E-Mail aus Papierkorb wiederherstellen // E-Mail aus Papierkorb wiederherstellen
export async function restoreEmail(req: Request, res: Response): Promise<void> { export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.restoreEmailFromTrash(id); const result = await cachedEmailService.restoreEmailFromTrash(id);
@@ -821,9 +913,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
} }
// E-Mail endgültig löschen (aus Papierkorb) // E-Mail endgültig löschen (aus Papierkorb)
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> { export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.permanentDeleteEmail(id); const result = await cachedEmailService.permanentDeleteEmail(id);
@@ -848,9 +941,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
// ==================== ATTACHMENT TARGETS ==================== // ==================== ATTACHMENT TARGETS ====================
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen // Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> { export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail mit StressfreiEmail laden // E-Mail mit StressfreiEmail laden
const email = await cachedEmailService.getCachedEmailById(emailId); const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -1030,9 +1124,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
} }
// E-Mail-Anhang in ein Dokumentenfeld speichern // E-Mail-Anhang in ein Dokumentenfeld speichern
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> { export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename); const filename = decodeURIComponent(req.params.filename);
const { entityType, entityId, targetKey } = req.body; const { entityType, entityId, targetKey } = req.body;
@@ -1317,9 +1412,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
// ==================== SAVE EMAIL AS PDF ==================== // ==================== SAVE EMAIL AS PDF ====================
// E-Mail als PDF exportieren und in Dokumentenfeld speichern // E-Mail als PDF exportieren und in Dokumentenfeld speichern
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> { export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { entityType, entityId, targetKey } = req.body; const { entityType, entityId, targetKey } = req.body;
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey }); console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
@@ -1564,9 +1660,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
// ==================== SAVE EMAIL AS INVOICE ==================== // ==================== SAVE EMAIL AS INVOICE ====================
// E-Mail als PDF exportieren und als Rechnung speichern // E-Mail als PDF exportieren und als Rechnung speichern
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> { export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { invoiceDate, invoiceType, notes } = req.body; const { invoiceDate, invoiceType, notes } = req.body;
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes }); console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
@@ -1690,9 +1787,10 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// ==================== SAVE ATTACHMENT AS INVOICE ==================== // ==================== SAVE ATTACHMENT AS INVOICE ====================
// E-Mail-Anhang als Rechnung speichern // E-Mail-Anhang als Rechnung speichern
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> { export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename); const filename = decodeURIComponent(req.params.filename);
const { invoiceDate, invoiceType, notes } = req.body; const { invoiceDate, invoiceType, notes } = req.body;
@@ -1853,9 +1951,10 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern. * Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.) * Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
*/ */
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> { export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename); const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body; const { documentType, notes } = req.body;
@@ -1891,6 +1990,9 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
return; return;
} }
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Für gesendete E-Mails: Prüfen ob UID vorhanden // Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) { if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({ res.status(400).json({
@@ -1967,6 +2069,10 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
}, },
}); });
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse); res.json({ success: true, data: doc } as ApiResponse);
} catch (error) { } catch (error) {
console.error('saveAttachmentAsContractDocument error:', error); console.error('saveAttachmentAsContractDocument error:', error);
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
} }
} }
res.json({ success: true, data: results }); // Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
// keine internen IDs das war früher der volle CustomerConsent-Record und
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
res.json({
success: true,
data: { granted: results.length },
});
} catch (error: any) { } catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error); console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' }); res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
+152 -9
View File
@@ -6,7 +6,9 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js'; import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js'; import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
export async function getContracts(req: AuthRequest, res: Response): Promise<void> { export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try { try {
@@ -45,9 +47,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined, page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined, limit: limit ? parseInt(limit as string) : undefined,
}); });
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractsStrict(result.contracts as any[])
: sanitizeContracts(result.contracts as any[]);
res.json({ res.json({
success: true, success: true,
data: result.contracts, data,
pagination: result.pagination, pagination: result.pagination,
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
@@ -88,7 +96,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
} }
} }
res.json({ success: true, data: contract } as ApiResponse); const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
@@ -97,8 +109,19 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
} }
} }
export async function createContract(req: Request, res: Response): Promise<void> { export async function createContract(req: AuthRequest, res: Response): Promise<void> {
try { try {
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
const body = (req.body || {}) as Record<string, unknown>;
if (!body.type || typeof body.type !== 'string') {
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
return;
}
if (!body.customerId || typeof body.customerId !== 'number') {
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const contract = await contractService.createContract(req.body); const contract = await contractService.createContract(req.body);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'Contract', req, action: 'CREATE', resourceType: 'Contract',
@@ -106,7 +129,9 @@ export async function createContract(req: Request, res: Response): Promise<void>
label: `Vertrag ${contract.contractNumber} angelegt`, label: `Vertrag ${contract.contractNumber} angelegt`,
customerId: contract.customerId, customerId: contract.customerId,
}); });
res.status(201).json({ success: true, data: contract } as ApiResponse); const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -178,7 +203,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
customerId: before?.customerId, customerId: before?.customerId,
}); });
res.json({ success: true, data: contract } as ApiResponse); // Response sanitisieren sonst leakt portalPasswordEncrypted etc.
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -210,6 +241,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> { export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
try { try {
const previousContractId = parseInt(req.params.id); const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
// Vorgängervertrag laden für Vertragsnummer // Vorgängervertrag laden für Vertragsnummer
const previousContract = await prisma.contract.findUnique({ const previousContract = await prisma.contract.findUnique({
@@ -246,7 +278,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
customerId: contract.customerId, customerId: contract.customerId,
}); });
res.status(201).json({ success: true, data: contract } as ApiResponse); const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -255,6 +289,61 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
} }
} }
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
*/
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
const previousContract = await prisma.contract.findUnique({
where: { id: previousContractId },
select: { contractNumber: true },
});
if (!previousContract) {
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
return;
}
const contract = await contractService.createRenewalContract(previousContractId);
if (!contract) {
res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse);
return;
}
const createdBy = req.user?.email || 'unbekannt';
await contractHistoryService.createRenewalHistoryEntry(
previousContractId,
contract.contractNumber,
createdBy,
);
await contractHistoryService.createNewRenewalFromPredecessorEntry(
contract.id,
previousContract.contractNumber,
createdBy,
);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `VVL erstellt für ${previousContract.contractNumber}`,
customerId: contract.customerId,
});
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
} as ApiResponse);
}
}
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> { export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
@@ -268,6 +357,14 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
} as ApiResponse); } as ApiResponse);
return; return;
} }
// Klartext-Passwort-Read auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'ContractPassword',
resourceId: contractId.toString(),
label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: { password } } as ApiResponse); res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -292,6 +389,14 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return; if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
const credentials = await contractService.getSimCardCredentials(simCardId); const credentials = await contractService.getSimCardCredentials(simCardId);
// Klartext-Read (PIN/PUK) auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SimCardCredentials',
resourceId: simCardId.toString(),
label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse); res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -307,6 +412,14 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
if (!(await canAccessContract(req, res, contractId))) return; if (!(await canAccessContract(req, res, contractId))) return;
const credentials = await contractService.getInternetCredentials(contractId); const credentials = await contractService.getInternetCredentials(contractId);
// Klartext-DSL/Internet-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'InternetCredentials',
resourceId: contractId.toString(),
label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse); res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -331,6 +444,14 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return; if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
const credentials = await contractService.getSipCredentials(phoneNumberId); const credentials = await contractService.getSipCredentials(phoneNumberId);
// Klartext-SIP/Telefon-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SipCredentials',
resourceId: phoneNumberId.toString(),
label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse); res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -344,7 +465,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> { export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
try { try {
const cockpitData = await contractCockpitService.getCockpitData(); // Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
// (Pentest Runde 4, 2026-05-16: HOCH).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
res.json({ success: true, data: cockpitData } as ApiResponse); res.json({ success: true, data: cockpitData } as ApiResponse);
} catch (error) { } catch (error) {
console.error('Cockpit error:', error); console.error('Cockpit error:', error);
@@ -426,6 +562,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
try { try {
const contractMeterId = parseInt(req.params.contractMeterId); const contractMeterId = parseInt(req.params.contractMeterId);
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
await prisma.contractMeter.delete({ where: { id: contractMeterId } }); await prisma.contractMeter.delete({ where: { id: contractMeterId } });
await logChange({ await logChange({
req, action: 'DELETE', resourceType: 'ContractMeter', req, action: 'DELETE', resourceType: 'ContractMeter',
@@ -461,7 +598,8 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> { export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
const { documentType, notes } = req.body; if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
if (!req.file) { if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse); res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
@@ -494,6 +632,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
customerId: contract?.customerId, customerId: contract?.customerId,
}); });
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
res.status(201).json({ success: true, data: doc } as ApiResponse); res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
@@ -507,6 +648,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
try { try {
const documentId = parseInt(req.params.documentId); const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } }); const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) { if (!doc || doc.contractId !== contractId) {
@@ -544,9 +686,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ==================== // ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: Request, res: Response): Promise<void> { export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (!(await canAccessContract(req, res, id))) return;
const { nextReviewDate, months } = req.body; const { nextReviewDate, months } = req.body;
let reviewDate: Date | null = null; let reviewDate: Date | null = null;
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
import * as contractHistoryService from '../services/contractHistory.service.js'; import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> { export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entries = await contractHistoryService.getHistoryEntries(contractId); const entries = await contractHistoryService.getHistoryEntries(contractId);
res.json({ success: true, data: entries } as ApiResponse); res.json({ success: true, data: entries } as ApiResponse);
} catch (error) { } catch (error) {
@@ -19,6 +21,7 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> { export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const { title, description } = req.body; const { title, description } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) { if (!title || typeof title !== 'string' || title.trim().length === 0) {
@@ -54,6 +57,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> { export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId); const entryId = parseInt(req.params.entryId);
const { title, description } = req.body; const { title, description } = req.body;
@@ -80,6 +84,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> { export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId); const entryId = parseInt(req.params.entryId);
await contractHistoryService.deleteHistoryEntry(contractId, entryId); await contractHistoryService.deleteHistoryEntry(contractId, entryId);
@@ -5,19 +5,30 @@ import * as customerService from '../services/customer.service.js';
import * as appSettingService from '../services/appSetting.service.js'; import * as appSettingService from '../services/appSetting.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
// ==================== ALL TASKS (Dashboard & Task List) ==================== // ==================== ALL TASKS (Dashboard & Task List) ====================
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> { export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
try { try {
const { status, customerId } = req.query; const { status, customerId } = req.query;
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
// Für Kundenportal: Filter auf erlaubte Kunden // Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined; let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined; let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) { if (allowedIds) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])]; // Wenn der Portal-User explizit nach einer customerId filtert, die er
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
// (Pentest Runde 10 LOW: konsistentes Response-Verhalten nach
// Vollmacht-Widerruf).
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
return;
}
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds); const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail) .map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -26,7 +37,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
const tasks = await contractTaskService.getAllTasks({ const tasks = await contractTaskService.getAllTasks({
status: status as 'OPEN' | 'COMPLETED' | undefined, status: status as 'OPEN' | 'COMPLETED' | undefined,
customerId: customerId ? parseInt(customerId as string) : undefined, customerId: customerIdNum,
customerPortalCustomerIds, customerPortalCustomerIds,
customerPortalEmails, customerPortalEmails,
}); });
@@ -42,12 +53,13 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> { export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
try { try {
// Für Kundenportal: Filter auf erlaubte Kunden // Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined; let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined; let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) { if (allowedIds) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])]; customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds); const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail) .map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -75,33 +87,17 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
const { status } = req.query; const { status } = req.query;
// Prüfe Zugriff auf den Vertrag // Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
const contract = await contractService.getContractById(contractId); // hasAuthorization (Pentest Runde 6 HOCH-04: widerrufene Vollmachten
if (!contract) { // hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
res.status(404).json({ // konsultiert wurde, ohne Status-Check).
success: false, if (!(await canAccessContract(req, res, contractId))) return;
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Für Kundenportal: Zugriffsprüfung // Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden
let customerPortalEmails: string[] | undefined; let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) { const allowedIds = await getPortalAllowedCustomerIds(req);
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])]; if (allowedIds) {
const customers = await customerService.getCustomersByIds(allowedCustomerIds); const customers = await customerService.getCustomersByIds(allowedIds);
customerPortalEmails = customers customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail) .map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email); .filter((email: string | null): email is string => !!email);
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
return; return;
} }
// Prüfe Zugriff auf den Vertrag // canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
const contract = await contractService.getContractById(contractId); if (!(await canAccessContract(req, res, contractId))) return;
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zugriffsprüfung für Kundenportal
if (req.user?.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
const createdBy = req.user?.email; const createdBy = req.user?.email;
@@ -376,15 +353,26 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return; return;
} }
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde) if (!req.user?.isCustomerPortal || !req.user.customerId) {
if (req.user?.isCustomerPortal && req.user.customerId) { res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
} as ApiResponse);
return;
}
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
// via hasAuthorization, Pentest Runde 6 HOCH-04). Damit kann ein
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
if (!(await canAccessContract(req, res, task.contractId))) return;
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])]; const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds); const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail) .map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email); .filter((email: string | null): email is string => !!email);
// Task muss entweder visibleInPortal sein ODER vom Kunden erstellt worden sein
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy); const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) { if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({ res.status(403).json({
@@ -393,13 +381,6 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
} as ApiResponse); } as ApiResponse);
return; return;
} }
} else {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
} as ApiResponse);
return;
}
const createdBy = req.user?.email; const createdBy = req.user?.email;
+264 -55
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js'; import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js'; import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { import {
sanitizeCustomer, sanitizeCustomer,
@@ -10,29 +11,42 @@ import {
sanitizeCustomerStrict, sanitizeCustomerStrict,
pickCustomerCreate, pickCustomerCreate,
pickCustomerUpdate, pickCustomerUpdate,
isValidEmail,
} from '../utils/sanitize.js'; } from '../utils/sanitize.js';
import { import {
canAccessMeter, canAccessMeter,
canAccessAddress, canAccessAddress,
canAccessBankCard, canAccessBankCard,
canAccessIdentityDocument, canAccessIdentityDocument,
canAccessCustomer,
getPortalAllowedCustomerIds,
} from '../utils/accessControl.js'; } from '../utils/accessControl.js';
// Customer CRUD // Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> { export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
try { try {
const { search, type, page, limit } = req.query; const { search, type, page, limit } = req.query;
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
const allowedIds = await getPortalAllowedCustomerIds(req);
const result = await customerService.getAllCustomers({ const result = await customerService.getAllCustomers({
search: search as string, search: search as string,
type: type as 'PRIVATE' | 'BUSINESS', type: type as 'PRIVATE' | 'BUSINESS',
page: page ? parseInt(page as string) : undefined, page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined, limit: limit ? parseInt(limit as string) : undefined,
allowedIds: allowedIds ?? undefined,
}); });
const customers = result.customers as any[];
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted // Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords const sanitized = canSeePasswords
? sanitizeCustomers(result.customers as any) ? sanitizeCustomers(customers)
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean); : customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse); res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -44,7 +58,9 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> { export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customer = await customerService.getCustomerById(parseInt(req.params.id)); const customerId = parseInt(req.params.id);
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await customerService.getCustomerById(customerId);
if (!customer) { if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return; return;
@@ -64,6 +80,16 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
try { try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen // Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body); const data: any = pickCustomerCreate(req.body);
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
if (data.email && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Convert birthDate string to Date if present // Convert birthDate string to Date if present
if (data.birthDate) { if (data.birthDate) {
data.birthDate = new Date(data.birthDate); data.birthDate = new Date(data.birthDate);
@@ -75,7 +101,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`, label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
customerId: customer.id, customerId: customer.id,
}); });
res.status(201).json({ success: true, data: customer } as ApiResponse); // Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -88,6 +121,15 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
try { try {
const customerId = parseInt(req.params.id); const customerId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (req.body?.email && !isValidEmail(req.body.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
const data: any = pickCustomerUpdate(req.body); const data: any = pickCustomerUpdate(req.body);
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
@@ -154,7 +196,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
} }
} }
res.json({ success: true, data: customer } as ApiResponse); // Response sanitisieren sonst leakt portalPasswordHash +
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
console.error('Update customer error:', error); console.error('Update customer error:', error);
res.status(400).json({ res.status(400).json({
@@ -185,18 +234,21 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
} }
// Addresses // Addresses
export async function getAddresses(req: Request, res: Response): Promise<void> { export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
try { try {
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId)); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const addresses = await customerService.getCustomerAddresses(customerId);
res.json({ success: true, data: addresses } as ApiResponse); res.json({ success: true, data: addresses } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse); res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
} }
} }
export async function createAddress(req: Request, res: Response): Promise<void> { export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const address = await customerService.createAddress(customerId, req.body); const address = await customerService.createAddress(customerId, req.body);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'Address', req, action: 'CREATE', resourceType: 'Address',
@@ -213,9 +265,10 @@ export async function createAddress(req: Request, res: Response): Promise<void>
} }
} }
export async function updateAddress(req: Request, res: Response): Promise<void> { export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
try { try {
const addressId = parseInt(req.params.id); const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const data = req.body; const data = req.body;
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
@@ -276,9 +329,10 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
} }
} }
export async function deleteAddress(req: Request, res: Response): Promise<void> { export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
try { try {
const addressId = parseInt(req.params.id); const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } }); const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
const customerId = addr?.customerId; const customerId = addr?.customerId;
await customerService.deleteAddress(addressId); await customerService.deleteAddress(addressId);
@@ -298,22 +352,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
} }
// Bank Cards // Bank Cards
export async function getBankCards(req: Request, res: Response): Promise<void> { export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true'; const showInactive = req.query.showInactive === 'true';
const cards = await customerService.getCustomerBankCards( const cards = await customerService.getCustomerBankCards(customerId, showInactive);
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: cards } as ApiResponse); res.json({ success: true, data: cards } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse); res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
} }
} }
export async function createBankCard(req: Request, res: Response): Promise<void> { export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const card = await customerService.createBankCard(customerId, req.body); const card = await customerService.createBankCard(customerId, req.body);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'BankCard', req, action: 'CREATE', resourceType: 'BankCard',
@@ -330,9 +384,10 @@ export async function createBankCard(req: Request, res: Response): Promise<void>
} }
} }
export async function updateBankCard(req: Request, res: Response): Promise<void> { export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
try { try {
const cardId = parseInt(req.params.id); const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const data = req.body; const data = req.body;
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
@@ -388,9 +443,10 @@ export async function updateBankCard(req: Request, res: Response): Promise<void>
} }
} }
export async function deleteBankCard(req: Request, res: Response): Promise<void> { export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
try { try {
const cardId = parseInt(req.params.id); const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } }); const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
const customerId = card?.customerId; const customerId = card?.customerId;
await customerService.deleteBankCard(cardId); await customerService.deleteBankCard(cardId);
@@ -410,22 +466,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
} }
// Identity Documents // Identity Documents
export async function getDocuments(req: Request, res: Response): Promise<void> { export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true'; const showInactive = req.query.showInactive === 'true';
const docs = await customerService.getCustomerDocuments( const docs = await customerService.getCustomerDocuments(customerId, showInactive);
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: docs } as ApiResponse); res.json({ success: true, data: docs } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse); res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
} }
} }
export async function createDocument(req: Request, res: Response): Promise<void> { export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const doc = await customerService.createDocument(customerId, req.body); const doc = await customerService.createDocument(customerId, req.body);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'IdentityDocument', req, action: 'CREATE', resourceType: 'IdentityDocument',
@@ -442,9 +498,10 @@ export async function createDocument(req: Request, res: Response): Promise<void>
} }
} }
export async function updateDocument(req: Request, res: Response): Promise<void> { export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const docId = parseInt(req.params.id); const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const data = req.body; const data = req.body;
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
@@ -506,9 +563,10 @@ export async function updateDocument(req: Request, res: Response): Promise<void>
} }
} }
export async function deleteDocument(req: Request, res: Response): Promise<void> { export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
try { try {
const docId = parseInt(req.params.id); const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } }); const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
const customerId = doc?.customerId; const customerId = doc?.customerId;
await customerService.deleteDocument(docId); await customerService.deleteDocument(docId);
@@ -528,22 +586,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
} }
// Meters // Meters
export async function getMeters(req: Request, res: Response): Promise<void> { export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true'; const showInactive = req.query.showInactive === 'true';
const meters = await customerService.getCustomerMeters( const meters = await customerService.getCustomerMeters(customerId, showInactive);
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: meters } as ApiResponse); res.json({ success: true, data: meters } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse); res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
} }
} }
export async function createMeter(req: Request, res: Response): Promise<void> { export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body); const meter = await customerService.createMeter(customerId, req.body);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'Meter', req, action: 'CREATE', resourceType: 'Meter',
@@ -560,9 +618,10 @@ export async function createMeter(req: Request, res: Response): Promise<void> {
} }
} }
export async function updateMeter(req: Request, res: Response): Promise<void> { export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
try { try {
const meterId = parseInt(req.params.id); const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
const data = req.body; const data = req.body;
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
@@ -617,9 +676,10 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
} }
} }
export async function deleteMeter(req: Request, res: Response): Promise<void> { export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
try { try {
const meterId = parseInt(req.params.id); const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
await customerService.deleteMeter(meterId); await customerService.deleteMeter(meterId);
await logChange({ await logChange({
req, action: 'DELETE', resourceType: 'Meter', req, action: 'DELETE', resourceType: 'Meter',
@@ -647,10 +707,11 @@ export async function getMeterReadings(req: AuthRequest, res: Response): Promise
} }
} }
export async function addMeterReading(req: Request, res: Response): Promise<void> { export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
try { try {
const { readingDate, value, valueNt, unit, notes } = req.body; const { readingDate, value, valueNt, unit, notes } = req.body;
const meterId = parseInt(req.params.meterId); const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const reading = await customerService.addMeterReading(meterId, { const reading = await customerService.addMeterReading(meterId, {
readingDate: new Date(readingDate), readingDate: new Date(readingDate),
value: parseFloat(value), value: parseFloat(value),
@@ -683,8 +744,10 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
} }
} }
export async function updateMeterReading(req: Request, res: Response): Promise<void> { export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
try { try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const { readingDate, value, valueNt, unit, notes } = req.body; const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate); if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
@@ -694,7 +757,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
if (notes !== undefined) updateData.notes = notes; if (notes !== undefined) updateData.notes = notes;
const reading = await customerService.updateMeterReading( const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId), meterId,
parseInt(req.params.readingId), parseInt(req.params.readingId),
updateData as any updateData as any
); );
@@ -712,13 +775,12 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
} }
} }
export async function deleteMeterReading(req: Request, res: Response): Promise<void> { export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
try { try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId); const readingId = parseInt(req.params.readingId);
await customerService.deleteMeterReading( await customerService.deleteMeterReading(meterId, readingId);
parseInt(req.params.meterId),
readingId
);
await logChange({ await logChange({
req, action: 'DELETE', resourceType: 'MeterReading', req, action: 'DELETE', resourceType: 'MeterReading',
resourceId: readingId.toString(), resourceId: readingId.toString(),
@@ -819,6 +881,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> { export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
try { try {
const meterId = parseInt(req.params.meterId); const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId); const readingId = parseInt(req.params.readingId);
const reading = await prisma.meterReading.update({ const reading = await prisma.meterReading.update({
@@ -847,9 +910,11 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
// ==================== PORTAL SETTINGS ==================== // ==================== PORTAL SETTINGS ====================
export async function getPortalSettings(req: Request, res: Response): Promise<void> { export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
try { try {
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId)); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const settings = await customerService.getPortalSettings(customerId);
if (!settings) { if (!settings) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return; return;
@@ -876,7 +941,27 @@ export async function getPortalSettings(req: Request, res: Response): Promise<vo
export async function updatePortalSettings(req: Request, res: Response): Promise<void> { export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
const { portalEnabled, portalEmail } = req.body; // `password` (oder password-ähnliche Felder) gehören NICHT in den
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
// ein Passwort setzen will, nutzt POST /portal/password mit
// Komplexitäts-Check. (Pentest-Befund.)
const body = req.body || {};
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
} as ApiResponse);
return;
}
const { portalEnabled, portalEmail } = body;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (portalEmail && !isValidEmail(portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ const before = await prisma.customer.findUnique({
@@ -936,13 +1021,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
} }
} }
/**
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
*/
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
try {
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
}
}
/**
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
*/
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
email: true, portalEmail: true, portalEnabled: true,
portalPasswordEncrypted: true, portalPasswordHash: true,
},
});
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
if (!customer.portalEnabled) {
res.status(400).json({
success: false,
error: 'Portal ist für diesen Kunden nicht aktiviert',
} as ApiResponse);
return;
}
if (!customer.portalPasswordHash) {
res.status(400).json({
success: false,
error: 'Es ist noch kein Portal-Passwort gesetzt',
} as ApiResponse);
return;
}
const targetEmail = customer.email || customer.portalEmail;
if (!targetEmail) {
res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
} as ApiResponse);
return;
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
});
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
// der Kunde sich ein eigenes setzen.
await authService.markPortalPasswordForChange(customerId);
await logChange({
req,
action: 'UPDATE',
resourceType: 'PortalSettings',
resourceId: customerId.toString(),
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
customerId,
});
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
}
}
export async function setPortalPassword(req: Request, res: Response): Promise<void> { export async function setPortalPassword(req: Request, res: Response): Promise<void> {
try { try {
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 6) { // Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein', error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse); } as ApiResponse);
return; return;
} }
@@ -963,9 +1150,22 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
} }
} }
export async function getPortalPassword(req: Request, res: Response): Promise<void> { export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try { try {
const password = await authService.getCustomerPortalPassword(parseInt(req.params.customerId)); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const password = await authService.getCustomerPortalPassword(customerId);
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
// + Insider-Threat-Erkennung.
await logChange({
req,
action: 'READ',
resourceType: 'PortalPassword',
resourceId: customerId.toString(),
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
customerId,
});
res.json({ success: true, data: { password } } as ApiResponse); res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -977,10 +1177,12 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
// ==================== REPRESENTATIVE MANAGEMENT ==================== // ==================== REPRESENTATIVE MANAGEMENT ====================
export async function getRepresentatives(req: Request, res: Response): Promise<void> { export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Wer kann diesen Kunden vertreten (representedBy)? // Wer kann diesen Kunden vertreten (representedBy)?
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId)); const representedBy = await customerService.getRepresentedByList(customerId);
res.json({ success: true, data: representedBy } as ApiResponse); res.json({ success: true, data: representedBy } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({
@@ -990,9 +1192,10 @@ export async function getRepresentatives(req: Request, res: Response): Promise<v
} }
} }
export async function addRepresentative(req: Request, res: Response): Promise<void> { export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { representativeId, notes } = req.body; const { representativeId, notes } = req.body;
const representative = await customerService.addRepresentative( const representative = await customerService.addRepresentative(
customerId, customerId,
@@ -1014,9 +1217,10 @@ export async function addRepresentative(req: Request, res: Response): Promise<vo
} }
} }
export async function removeRepresentative(req: Request, res: Response): Promise<void> { export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
await customerService.removeRepresentative( await customerService.removeRepresentative(
customerId, customerId,
parseInt(req.params.representativeId) parseInt(req.params.representativeId)
@@ -1035,8 +1239,13 @@ export async function removeRepresentative(req: Request, res: Response): Promise
} }
} }
export async function searchForRepresentative(req: Request, res: Response): Promise<void> { export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
try { try {
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { search } = req.query; const { search } = req.query;
if (!search || typeof search !== 'string' || search.length < 2) { if (!search || typeof search !== 'string' || search.length < 2) {
res.json({ success: true, data: [] } as ApiResponse); res.json({ success: true, data: [] } as ApiResponse);
@@ -1044,7 +1253,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
} }
const customers = await customerService.searchCustomersForRepresentative( const customers = await customerService.searchCustomersForRepresentative(
search, search,
parseInt(req.params.customerId) customerId,
); );
res.json({ success: true, data: customers } as ApiResponse); res.json({ success: true, data: customers } as ApiResponse);
} catch (error) { } catch (error) {
@@ -7,6 +7,8 @@ import { ApiResponse } from '../types/index.js';
import { testImapConnection, ImapCredentials } from '../services/imapService.js'; import { testImapConnection, ImapCredentials } from '../services/imapService.js';
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js'; import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
import { decrypt } from '../utils/encryption.js'; import { decrypt } from '../utils/encryption.js';
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -118,6 +120,33 @@ export async function testConnection(req: Request, res: Response): Promise<void>
domain: req.body.domain, domain: req.body.domain,
} : undefined; } : undefined;
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host');
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err.message,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { apiUrl: testData.apiUrl },
});
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
// URL-Parse-Fehler ignorieren Backend reagiert sowieso mit Fehler
}
}
const result = await emailProviderService.testProviderConnection({ id, testData }); const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse); res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) { } catch (error) {
@@ -214,24 +243,56 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
return; return;
} }
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
]);
} catch (err) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { smtpServer, imapServer },
});
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
} as ApiResponse);
return;
}
// IMAP testen // IMAP testen
const imapCredentials: ImapCredentials = { const imapCredentials: ImapCredentials = {
host: imapServer, host: imapResolved.ip,
port: imapPort, port: imapPort,
user: emailAddress, user: emailAddress,
password, password,
encryption: imapEncryption, encryption: imapEncryption,
allowSelfSignedCerts, allowSelfSignedCerts,
servername: imapResolved.servername,
}; };
// SMTP testen // SMTP testen
const smtpCredentials: SmtpCredentials = { const smtpCredentials: SmtpCredentials = {
host: smtpServer, host: smtpResolved.ip,
port: smtpPort, port: smtpPort,
user: emailAddress, user: emailAddress,
password, password,
encryption: smtpEncryption, encryption: smtpEncryption,
allowSelfSignedCerts, allowSelfSignedCerts,
servername: smtpResolved.servername,
}; };
let imapResult: { success: boolean; error?: string } = { success: false }; let imapResult: { success: boolean; error?: string } = { success: false };
@@ -54,6 +54,7 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
contractDurations: data.contractDurations.length, contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length, contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length, pdfTemplates: data.pdfTemplates.length,
appSettings: data.appSettings.length,
}, },
}, },
}); });
@@ -62,3 +63,39 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' }); res.status(500).json({ success: false, error: 'Fehler beim Laden' });
} }
} }
/**
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
*/
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
try {
const file = (req as any).file as Express.Multer.File | undefined;
if (!file || !file.buffer) {
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
}
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
// den Vorgang explizit als Import.
action: 'UPDATE',
resourceType: 'FactoryDefaults',
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Factory-Defaults-Import:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Import',
});
}
}
@@ -0,0 +1,98 @@
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import { findUploadOwner } from '../services/fileDownload.service.js';
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
/**
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
*
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
*
* Schritte:
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
* 3. canAccessCustomer / canAccessContract / Permission-Check
* 4. Datei senden (mit korrektem Content-Type)
*
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
* vertretenen Kunden mit Vollmacht) herunterladen nicht mehr beliebige
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
* mitgeschnitten hätte.
*/
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
const requested = typeof req.query.path === 'string' ? req.query.path : '';
if (!requested) {
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
return;
}
// Format-Validierung (Traversal-Schutz)
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
// Owner ermitteln
const owner = await findUploadOwner(requested);
if (!owner) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Access-Check je nach Owner-Typ
if (owner.kind === 'customer') {
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
} else if (owner.kind === 'contract') {
if (!(await canAccessContract(req, res, owner.contractId))) return;
} else if (owner.kind === 'admin') {
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
const perms = req.user?.permissions || [];
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
} else if (owner.kind === 'gdpr-admin') {
const perms = req.user?.permissions || [];
if (!perms.includes('gdpr:admin')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
}
// Datei vom Disk lesen
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
const relative = requested.substring('/uploads/'.length);
const absolute = path.join(process.cwd(), 'uploads', relative);
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
if (!absolute.startsWith(uploadsRoot)) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
if (!fs.existsSync(absolute)) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
// durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
// nicht, wenn der Server selbst text/html liefert.
//
// Fix: alle Files via Content-Disposition: attachment ausliefern.
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
// Für legitime PDF/Bild-Vorschau ist das vertretbar Browser
// öffnen den Download dann eben aus dem Datei-Manager.
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.sendFile(absolute);
}
+121 -27
View File
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js'; import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js'; import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js'; import * as appSettingService from '../services/appSetting.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js'; import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client'; import { ConsentType, DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
@@ -12,6 +13,7 @@ import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js'; import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js'; import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js'; import * as authorizationService from '../services/authorization.service.js';
import { stripHtml } from '../utils/sanitize.js';
/** /**
* Kundendaten exportieren (DSGVO Art. 15) * Kundendaten exportieren (DSGVO Art. 15)
@@ -229,6 +231,7 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
export async function getCustomerConsents(req: AuthRequest, res: Response) { export async function getCustomerConsents(req: AuthRequest, res: Response) {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const consents = await consentService.getCustomerConsents(customerId); const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen // Labels hinzufügen
@@ -251,6 +254,7 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
export async function checkConsentStatus(req: AuthRequest, res: Response) { export async function checkConsentStatus(req: AuthRequest, res: Response) {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const result = await consentService.hasFullConsent(customerId); const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
@@ -266,7 +270,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType; const consentType = req.params.consentType as ConsentType;
const { status, source, documentPath, version } = req.body; // BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
// und `version` darf der Portal-User NICHT setzen Pentest 2026-05-20
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
// landeten vorher ungefiltert in der DB. source ist für diesen
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
// (falls überhaupt) später nach.
const { status } = req.body;
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern // Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
if (!(req.user as any)?.isCustomerPortal) { if (!(req.user as any)?.isCustomerPortal) {
@@ -276,17 +287,9 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
}); });
} }
// Portal: nur eigene + vertretene Kunden // canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
const allowed = [ // widerrufene Vollmachten hatten vorher noch Zugriff)
(req.user as any).customerId, if (!(await canAccessCustomer(req, res, customerId))) return;
...((req.user as any).representedCustomerIds || []),
];
if (!allowed.includes(customerId)) {
return res.status(403).json({
success: false,
error: 'Keine Berechtigung für diesen Kunden',
});
}
if (!Object.values(ConsentType).includes(consentType)) { if (!Object.values(ConsentType).includes(consentType)) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' }); return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
@@ -301,9 +304,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
const consent = await consentService.updateConsent(customerId, consentType, { const consent = await consentService.updateConsent(customerId, consentType, {
status, status,
source: source || 'portal', source: 'portal',
documentPath,
version,
ipAddress: req.socket.remoteAddress, ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown', createdBy: req.user?.email || 'unknown',
}); });
@@ -312,7 +313,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent', req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`, label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: source || 'portal' }, details: { einwilligung: consentName, status, quelle: 'portal' },
customerId, customerId,
}); });
@@ -799,6 +800,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
export async function getAuthorizations(req: AuthRequest, res: Response) { export async function getAuthorizations(req: AuthRequest, res: Response) {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren // Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId); await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId); const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
@@ -818,9 +820,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId); const representativeId = parseInt(req.params.representativeId);
const { source, notes } = req.body; const { source, notes } = req.body;
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
// stripHtml geschickt (Plain-Text-Feld).
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
const auth = await authorizationService.grantAuthorization(customerId, representativeId, { const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
source: source || 'crm-backend', source: safeSource,
notes, notes: safeNotes as string | undefined,
}); });
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } }); const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
@@ -883,6 +891,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
} }
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
// hochladen. Wir verlangen:
// 1) Magic-Bytes "%PDF-" am Anfang
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
// passierte die reine Magic-Byte-Prüfung).
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
// hier nicht erkannt aber das ist Adobe-Acrobat-Risiko und nicht
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
try {
const stat = fs.statSync(req.file.path);
const fd = fs.openSync(req.file.path, 'r');
// Header
const head = Buffer.alloc(5);
fs.readSync(fd, head, 0, 5, 0);
if (!head.equals(PDF_MAGIC)) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
});
}
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
// klare Spoof-Indikatoren.
const headSize = Math.min(stat.size, 4096);
const headBuf = Buffer.alloc(headSize);
fs.readSync(fd, headBuf, 0, headSize, 0);
const headStr = headBuf.toString('latin1').toLowerCase();
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz']; // last = PE/Windows exe
const hit = forbidden.find((m) => headStr.includes(m));
if (hit) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`,
});
}
// EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am
// Dateiende, aber viele Tools schreiben Whitespace/Newlines
// nach %%EOF, deshalb prüfen wir das letzte KB.
if (stat.size >= 5) {
const tailSize = Math.min(stat.size, 1024);
const tailBuf = Buffer.alloc(tailSize);
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
if (!tailBuf.toString('latin1').includes('%%EOF')) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).',
});
}
}
fs.closeSync(fd);
} catch (_e) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Hochgeladene Datei konnte nicht gelesen werden.',
});
}
const documentPath = `/uploads/authorizations/${req.file.filename}`; const documentPath = `/uploads/authorizations/${req.file.filename}`;
const auth = await authorizationService.updateAuthorizationDocument( const auth = await authorizationService.updateAuthorizationDocument(
customerId, customerId,
@@ -966,12 +1046,27 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId); const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body; const { grant } = req.body;
// Vertreter-Name laden // Validierungen:
const representative = await prisma.customer.findUnique({ // 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
where: { id: representativeId }, if (representativeId === user.customerId) {
select: { firstName: true, lastName: true }, return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
}
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
// DB enumerieren kann (kein 404-vs-403-Disclosure).
const relation = await prisma.customerRepresentative.findFirst({
where: { customerId: user.customerId, representativeId, isActive: true },
include: { representative: { select: { firstName: true, lastName: true } } },
}); });
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`; if (!relation) {
return res.status(403).json({
success: false,
error: 'Kein Vertreter-Verhältnis Vollmacht nicht erlaubt',
});
}
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
let auth; let auth;
if (grant) { if (grant) {
@@ -987,10 +1082,9 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error); console.error('Fehler beim Ändern der Vollmacht:', error);
res.status(400).json({ // Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
success: false, // sollten nicht an Endkunden geleakt werden.
error: error instanceof Error ? error.message : 'Fehler beim Ändern', res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
});
} }
} }
+11 -6
View File
@@ -2,14 +2,15 @@ import { Request, Response } from 'express';
import * as invoiceService from '../services/invoice.service.js'; import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract } from '../utils/accessControl.js'; import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
/** /**
* Alle Rechnungen für ein EnergyContractDetails abrufen * Alle Rechnungen für ein EnergyContractDetails abrufen
*/ */
export async function getInvoices(req: Request, res: Response): Promise<void> { export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ecdId = parseInt(req.params.ecdId); const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoices = await invoiceService.getInvoices(ecdId); const invoices = await invoiceService.getInvoices(ecdId);
res.json({ success: true, data: invoices } as ApiResponse); res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) { } catch (error) {
@@ -24,10 +25,11 @@ export async function getInvoices(req: Request, res: Response): Promise<void> {
/** /**
* Einzelne Rechnung abrufen * Einzelne Rechnung abrufen
*/ */
export async function getInvoice(req: Request, res: Response): Promise<void> { export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ecdId = parseInt(req.params.ecdId); const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId); const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoice = await invoiceService.getInvoice(ecdId, invoiceId); const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
if (!invoice) { if (!invoice) {
@@ -51,9 +53,10 @@ export async function getInvoice(req: Request, res: Response): Promise<void> {
/** /**
* Neue Rechnung hinzufügen * Neue Rechnung hinzufügen
*/ */
export async function addInvoice(req: Request, res: Response): Promise<void> { export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ecdId = parseInt(req.params.ecdId); const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body; const { invoiceDate, invoiceType, documentPath, notes } = req.body;
if (!invoiceDate || !invoiceType) { if (!invoiceDate || !invoiceType) {
@@ -90,10 +93,11 @@ export async function addInvoice(req: Request, res: Response): Promise<void> {
/** /**
* Rechnung aktualisieren * Rechnung aktualisieren
*/ */
export async function updateInvoice(req: Request, res: Response): Promise<void> { export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ecdId = parseInt(req.params.ecdId); const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId); const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body; const { invoiceDate, invoiceType, documentPath, notes } = req.body;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, { const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
@@ -122,10 +126,11 @@ export async function updateInvoice(req: Request, res: Response): Promise<void>
/** /**
* Rechnung löschen * Rechnung löschen
*/ */
export async function deleteInvoice(req: Request, res: Response): Promise<void> { export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
try { try {
const ecdId = parseInt(req.params.ecdId); const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId); const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
await invoiceService.deleteInvoice(ecdId, invoiceId); await invoiceService.deleteInvoice(ecdId, invoiceId);
@@ -0,0 +1,209 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import * as appSettingService from '../services/appSetting.service.js';
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
/**
* GET /api/monitoring/events
* Liste der Security-Events mit Filter + Pagination.
*/
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const page = parseInt((req.query.page as string) || '1');
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
const type = req.query.type as SecurityEventType | undefined;
const severity = req.query.severity as SecuritySeverity | undefined;
const search = req.query.search as string | undefined;
const since = req.query.since as string | undefined;
const ip = req.query.ip as string | undefined;
const where: any = {};
if (type) where.type = type;
if (severity) where.severity = severity;
if (ip) where.ipAddress = ip;
if (since) where.createdAt = { gte: new Date(since) };
if (search) {
where.OR = [
{ message: { contains: search } },
{ userEmail: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [events, total, byType, bySeverity] = await Promise.all([
prisma.securityEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: (page - 1) * limit,
}),
prisma.securityEvent.count({ where }),
prisma.securityEvent.groupBy({
by: ['type'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
prisma.securityEvent.groupBy({
by: ['severity'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
]);
res.json({
success: true,
data: events,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
stats: {
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
},
} as any);
} catch (error) {
console.error('listEvents error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
}
}
/**
* GET /api/monitoring/settings
*/
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
res.json({
success: true,
data: {
alertEmail: alertEmail || '',
digestEnabled,
lastDigestAt: lastDigest || null,
},
} as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
}
}
/**
* PUT /api/monitoring/settings
*/
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
try {
const { alertEmail, digestEnabled } = req.body || {};
if (typeof alertEmail === 'string') {
// Email-Validierung minimal: muss @ enthalten oder leer sein
if (alertEmail !== '' && !alertEmail.includes('@')) {
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
return;
}
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
}
if (typeof digestEnabled === 'boolean') {
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
}
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
}
}
/**
* POST /api/monitoring/test-alert
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
*/
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) {
res.status(400).json({
success: false,
error: 'Keine Alert-E-Mail konfiguriert',
} as ApiResponse);
return;
}
const result = await sendAlertEmail(alertEmail, {
subject: '[OpenCRM] Test-Alert',
events: [{
type: 'SUSPICIOUS' as any,
severity: 'INFO' as any,
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
createdAt: new Date(),
} as any],
isDigest: false,
});
if (result.success) {
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
} else {
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
}
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
} as ApiResponse);
}
}
/**
* DELETE /api/monitoring/events
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
* Audit-Trail erhalten bleibt.
*/
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const olderThanDays = req.query.olderThanDays
? parseInt(req.query.olderThanDays as string)
: undefined;
const where: any = {};
if (olderThanDays && olderThanDays > 0) {
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
where.createdAt = { lt: cutoff };
}
const result = await prisma.securityEvent.deleteMany({ where });
// Audit-Spur: Wer hat geleert
const user = (req as any).user;
await prisma.securityEvent.create({
data: {
type: 'PERMISSION_CHANGED',
severity: 'INFO',
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
userId: user?.userId || null,
userEmail: user?.email || null,
ipAddress: req.ip || 'unknown',
endpoint: 'DELETE /api/monitoring/events',
},
});
res.json({
success: true,
message: `${result.count} Events gelöscht`,
data: { deletedCount: result.count },
} as any);
} catch (error) {
console.error('clearEvents error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
}
}
/**
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
*/
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
try {
const result = await sendDigest({ force: true });
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
} as ApiResponse);
}
}
@@ -2,6 +2,7 @@ import { Response } from 'express';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.service.js'; import * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getTemplates(req: AuthRequest, res: Response) { export async function getTemplates(req: AuthRequest, res: Response) {
try { try {
@@ -149,6 +150,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
try { try {
const templateId = parseInt(req.params.id); const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId); const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs }); res.json({ success: true, data: inputs });
} catch (error) { } catch (error) {
@@ -160,6 +162,7 @@ export async function generatePdf(req: AuthRequest, res: Response) {
try { try {
const templateId = parseInt(req.params.id); const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Extras aus Body (POST) oder Query-Parametern (GET) // Extras aus Body (POST) oder Query-Parametern (GET)
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId; const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
+14 -1
View File
@@ -18,7 +18,20 @@ export async function getProviders(req: Request, res: Response): Promise<void> {
export async function getProvider(req: Request, res: Response): Promise<void> { export async function getProvider(req: Request, res: Response): Promise<void> {
try { try {
const provider = await providerService.getProviderById(parseInt(req.params.id)); // `req.params.id` ist Pfad-Segment bei /api/providers/email landet
// hier der String "email", den parseInt zu NaN macht. Ohne Validierung
// fuhr Prisma dann gegen `WHERE id = NaN` und warf 500.
// Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes
// wie /api/providers/<id>/tariffs greifen weiter wie gehabt.
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(404).json({
success: false,
error: 'Anbieter nicht gefunden',
} as ApiResponse);
return;
}
const provider = await providerService.getProviderById(id);
if (!provider) { if (!provider) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
@@ -0,0 +1,155 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.js';
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
type ActiveLock = {
ipAddress: string;
email: string | null; // null = Passwort-Reset oder Login ohne Email
lastHit: Date;
hitCount: number;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
function lockKey(ip: string, email: string | null): string {
return `${ip}|${(email || '').toLowerCase()}`;
}
/**
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
* Bucket im Limiter Reset gilt exakt für dieses Paar.
*/
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({
where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
select: {
ipAddress: true,
userEmail: true,
endpoint: true,
createdAt: true,
details: true,
},
});
const byKey = new Map<string, ActiveLock>();
for (const ev of events) {
const ip = ev.ipAddress || 'unknown';
const email = (ev.userEmail || '').toLowerCase() || null;
const limiter = (ev.details as any)?.limiter ?? 'unknown';
const key = lockKey(ip, email);
const existing = byKey.get(key);
if (existing) {
existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else {
byKey.set(key, {
ipAddress: ip,
email,
lastHit: ev.createdAt,
hitCount: 1,
lastEndpoint: ev.endpoint,
limiters: [limiter],
});
}
}
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
mapKey: k,
resourceId: k,
lastHit: e.lastHit,
}));
if (candidates.length > 0) {
const recentResets = await prisma.auditLog.findMany({
where: {
resourceType: 'RateLimit',
resourceId: { in: candidates.map((c) => c.resourceId) },
createdAt: { gte: since },
},
select: { resourceId: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
const resetMap = new Map<string, Date>();
for (const r of recentResets) {
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
}
for (const c of candidates) {
const reset = resetMap.get(c.resourceId);
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
}
}
const list = Array.from(byKey.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
);
res.json({ success: true, data: list } as ApiResponse);
} catch (error) {
console.error('getActiveRateLimits error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der aktiven Rate-Limits',
} as ApiResponse);
}
}
/**
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
* (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der
* IP-only-Key (alter Stil) zusätzlich reseted.
*/
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try {
const ip = (req.body?.ipAddress || '').toString().trim();
const email = (req.body?.email || '').toString().trim().toLowerCase();
if (!ip) {
res.status(400).json({
success: false,
error: 'IP-Adresse erforderlich',
} as ApiResponse);
return;
}
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
const loginKey = email ? `${ip}|${email}` : `${ip}|<no-email>`;
await (loginRateLimiter as any).resetKey?.(loginKey);
// Passwort-Reset-Limit ist (noch) IP-only auch zurücksetzen
await (passwordResetRateLimiter as any).resetKey?.(ip);
// Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den
// Eintrag aus der Anzeige filtern kann.
const audited = `${ip}|${email || ''}`;
await logChange({
req,
action: 'UPDATE',
resourceType: 'RateLimit',
resourceId: audited,
label: email
? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben`
: `Rate-Limit für IP ${ip} manuell freigegeben`,
});
res.json({
success: true,
message: email
? `Rate-Limit für (${ip}, ${email}) freigegeben`
: `Rate-Limit für ${ip} freigegeben`,
} as ApiResponse);
} catch (error) {
console.error('resetRateLimit error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Zurücksetzen des Rate-Limits',
} as ApiResponse);
}
}
@@ -68,9 +68,11 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
} }
} }
export async function updateEmail(req: Request, res: Response): Promise<void> { export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
try { try {
const email = await stressfreiEmailService.updateEmail(parseInt(req.params.id), req.body); const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'StressfreiEmail', req, action: 'UPDATE', resourceType: 'StressfreiEmail',
resourceId: email.id.toString(), resourceId: email.id.toString(),
@@ -85,9 +87,10 @@ export async function updateEmail(req: Request, res: Response): Promise<void> {
} }
} }
export async function deleteEmail(req: Request, res: Response): Promise<void> { export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try { try {
const emailId = parseInt(req.params.id); const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
await stressfreiEmailService.deleteEmail(emailId); await stressfreiEmailService.deleteEmail(emailId);
await logChange({ await logChange({
req, action: 'DELETE', resourceType: 'StressfreiEmail', req, action: 'DELETE', resourceType: 'StressfreiEmail',
@@ -103,9 +106,50 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
} }
} }
export async function resetPassword(req: Request, res: Response): Promise<void> { export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
try { try {
const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id)); const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
if (!result.success) {
res.status(400).json({ success: false, error: result.error } as ApiResponse);
return;
}
const labelParts = [`Weiterleitungen: ${(result.forwardTargets || []).join(', ')}`];
if (result.passwordReset) labelParts.push('Mailbox-Passwort am Provider neu gesetzt');
await logChange({
req,
action: 'UPDATE',
resourceType: 'StressfreiEmail',
resourceId: emailId.toString(),
label: `Stressfrei-Sync: ${labelParts.join(' | ')}`,
});
res.json({
success: true,
data: {
forwardTargets: result.forwardTargets,
customerEmail: result.customerEmail,
passwordReset: result.passwordReset,
},
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
} as ApiResponse);
}
}
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
if (!result.success) { if (!result.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
+104 -4
View File
@@ -3,7 +3,8 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js'; import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js'; import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users // Users
export async function getUsers(req: Request, res: Response): Promise<void> { export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -51,7 +52,24 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> { export async function createUser(req: Request, res: Response): Promise<void> {
try { try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any); const data = pickUserCreate(req.body) as any;
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
// (Pentest 29.4 SMTP-Header-Injection).
if (!isValidEmail(data?.email) || !data?.email) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data?.password) {
const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
const user = await userService.createUser(data);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'User', req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(), resourceId: user.id.toString(),
@@ -69,11 +87,53 @@ export async function createUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: Request, res: Response): Promise<void> { export async function updateUser(req: Request, res: Response): Promise<void> {
try { try {
const userId = parseInt(req.params.id); const userId = parseInt(req.params.id);
// `permissions` und `password` darf der generische Update nicht
// entgegennehmen. Vorher landeten sie auf dem Floor (Whitelist-Drop),
// der Caller bekam aber 200 zurück und glaubte fälschlich, die Werte
// wären übernommen worden. Stattdessen sofort 400, damit Tooling /
// Client den Fehler sieht. (Pentest 2026-05-20)
// - permissions kommen aus Rollen (PUT roleIds bzw. die DSGVO-/
// Developer-Checkboxen) und können nicht direkt am User hängen.
// - password wird über POST /users/:id/password gesetzt
// (eigene Komplexitäts-Validierung + Audit-Trail).
const body = req.body || {};
const forbidden = ['permissions', 'password', 'passwordHash'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. ` +
(offending.includes('permissions')
? 'Permissions werden über roleIds / hasGdprAccess / hasDeveloperAccess gesteuert. '
: '') +
(offending.includes('password') || offending.includes('passwordHash')
? `Passwort über POST /users/${userId}/password setzen.`
: ''),
} as ApiResponse);
return;
}
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body); const data = pickUserUpdate(req.body);
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit // Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
const before = await prisma.user.findUnique({ where: { id: userId } }); // hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
const beforeUser = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } },
});
const before = beforeUser
? {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
}
: null;
const user = await userService.updateUser(userId, data as any); const user = await userService.updateUser(userId, data as any);
if (user) { if (user) {
@@ -82,6 +142,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
const changes: Record<string, { von: unknown; nach: unknown }> = {}; const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = { const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv', email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
}; };
for (const [key, newVal] of Object.entries(data)) { for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -121,6 +182,45 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
} }
} }
// Admin setzt das Passwort eines anderen Users zurück. Separat vom
// generischen Update damit der Vorgang explizit auditiert wird und nicht
// versehentlich über Mass-Assignment passieren kann.
// Pentest Runde 12 (2026-05-18) MITTEL.
export async function setUserPassword(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const { password } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
const user = await userService.updateUser(userId, { password } as any);
if (!user) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return;
}
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
}
}
export async function deleteUser(req: Request, res: Response): Promise<void> { export async function deleteUser(req: Request, res: Response): Promise<void> {
try { try {
const userId = parseInt(req.params.id); const userId = parseInt(req.params.id);
+303 -16
View File
@@ -1,8 +1,34 @@
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import path from 'path'; import path from 'path';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
// .env-Dateien laden Root-.env hat Priorität (zentrale Konfiguration für
// Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind
// Variablen schon via env_file/environment gesetzt dotenv überschreibt
// existierende process.env-Werte nicht.
// __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen
// liegt Root /.env zwei Ebenen darüber.
//
// dotenvExpand löst ${VAR}-Substitution auf, sodass z.B.
// DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
// dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen).
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') }));
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') }));
dotenvExpand.expand(dotenv.config());
// Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution
// nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'localhost';
const port = process.env.DB_PORT || '3306';
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
}
import authRoutes from './routes/auth.routes.js'; import authRoutes from './routes/auth.routes.js';
import customerRoutes from './routes/customer.routes.js'; import customerRoutes from './routes/customer.routes.js';
@@ -34,11 +60,14 @@ import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js'; import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js'; import { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
dotenv.config();
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ==================== // ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
@@ -55,14 +84,124 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// Zwei Szenarien:
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
// EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
// genau einem Hop → req.ip = echter Client (nicht der Proxy).
// Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
// sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
// Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
// durch das Docker-Network + nicht-veröffentlichten Backend-Port
// gewährleistet.
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
// Zugriff): `loopback` reicht kein vertrauenswürdiger Hop davor.
//
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
app.set('trust proxy', trustProxyValue);
// ==================== SECURITY MIDDLEWARE ==================== // ==================== SECURITY MIDDLEWARE ====================
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.) // HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...)
//
// CSP ist konservativ aber SPA-tauglich:
// - script-src 'self' → keine externen Skripte, keine inline-Scripts
// (Vite baut Module-Skripte zu separaten Files,
// die sind 'self')
// - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das
// (sicheres Trade-off; XSS via CSS ist
// marginal vs Lock-Out gegen die UI)
// - img-src self/data/blob → base64-Avatare + blob-URLs für PDFs/Downloads
// - font-src self/data → eingebettete Fonts
// - connect-src 'self' → API + WebSocket nur zur eigenen Origin
// - frame-ancestors 'none' → Clickjacking-Schutz (ersetzt X-Frame-Options)
// - object-src 'none' → keine Flash/<object>/<embed>-Embeds
// - base-uri 'self' → keine <base>-Hijacking-Tricks
// - form-action 'self' → POST-Targets nur auf eigene Origin
// Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen.
// Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc.
// bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten,
// fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet.
app.use((_req, res, next) => {
res.setHeader(
'Permissions-Policy',
[
'accelerometer=()',
'ambient-light-sensor=()',
'autoplay=()',
'battery=()',
'camera=()',
'clipboard-read=()',
'clipboard-write=(self)',
'cross-origin-isolated=()',
'display-capture=()',
'encrypted-media=()',
'fullscreen=(self)',
'geolocation=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'magnetometer=()',
'microphone=()',
'midi=()',
'payment=()',
'picture-in-picture=()',
'publickey-credentials-get=()',
'screen-wake-lock=()',
'sync-xhr=()',
'usb=()',
'web-share=()',
'xr-spatial-tracking=()',
].join(', '),
);
next();
});
// HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn
// wirklich TLS davor läuft sonst sperrt sich die App auf direkt-via-IP-
// Deployments (Browser versucht /assets/* via https zu laden → SSL-Error).
// Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy
// (Caddy/Traefik/Nginx) vor OpenCRM steht.
const httpsEnabled = process.env.HTTPS_ENABLED === 'true';
app.use( app.use(
helmet({ helmet({
// CSP ausschalten wird bei SPA schwierig, frontend setzt eigene CSP via meta contentSecurityPolicy: {
contentSecurityPolicy: false, useDefaults: true,
// Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'data:'],
'connect-src': ["'self'"],
// Explizit gesetzt obwohl Fallback auf default-src/script-src greift
// ZAP markiert sonst "No-Fallback-Direktiven" als CSP-Lücke.
'worker-src': ["'self'"],
'manifest-src': ["'self'"],
'media-src': ["'self'"],
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
// 'none' würde sogar same-origin blocken und damit die UI brechen.
// Externe Sites bleiben weiterhin gesperrt.
'frame-ancestors': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
// useDefaults bringt 'upgrade-insecure-requests' selbst mit explizit
// auf null setzen entfernt es aus dem Header (helmet-API).
'upgrade-insecure-requests': httpsEnabled ? [] : null,
},
},
// HSTS NIE in Helmet senden der vorgelagerte TLS-Reverse-Proxy
// (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt
// RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt.
// HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant.
strictTransportSecurity: false,
crossOriginResourcePolicy: { policy: 'same-site' }, crossOriginResourcePolicy: { policy: 'same-site' },
}), }),
); );
@@ -83,13 +222,115 @@ app.use(
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json()) // JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
// Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht
// (POST /api/auth/refresh liest ihn aus req.cookies).
app.use(cookieParser());
// Audit-Logging Middleware (DSGVO-konform) // Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware); app.use(auditContextMiddleware);
app.use(auditMiddleware); app.use(auditMiddleware);
// Statische Dateien für Uploads // Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads'))); // `/api/uploads/*` express.static).
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
// und prüft canAccessCustomer/canAccessContract damit kann ein Portal-Kunde
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
//
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
// Request über denselben Owner-Check (kein freier static-Handler mehr).
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> egal ob als
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
app.get('/api/files/download', authenticate as any, downloadFile as any);
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
// (siehe express.static mit immutable weiter unten).
app.use('/api', (_req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
// Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster
// in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen.
// So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe
// Prisma-Internals wie "Invalid `prisma.user.update()` invocation:
// Record to update not found" (Pentest Runde 11 M3). Der Original-Text
// landet weiterhin im Server-Log.
const ORM_LEAK_PATTERNS: RegExp[] = [
/Invalid `prisma\./i,
/PrismaClient/i,
/^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
/at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
// JS-Runtime-Fehler Pentest Runde 12 (2026-05-18): "Cannot read
// properties of undefined (reading 'substring')" leakte aus POST
// /contracts. Solche Texte verraten Implementierungs-Details.
/^TypeError\b/i,
/^ReferenceError\b/i,
/^SyntaxError\b/i,
/^RangeError\b/i,
/Cannot read propert(y|ies) of (undefined|null)/i,
/is not a function/i,
/is not defined$/i,
];
function sanitizeErrorString(s: string): string {
if (!s) return s;
for (const re of ORM_LEAK_PATTERNS) {
if (re.test(s)) {
console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300));
return 'Operation fehlgeschlagen';
}
}
return s;
}
app.use('/api', (_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (body && typeof body === 'object') {
if (typeof body.error === 'string') {
body.error = sanitizeErrorString(body.error);
}
if (typeof body.details === 'string') {
body.details = sanitizeErrorString(body.details);
}
}
return originalJson(body);
};
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$`
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Öffentliche Routes (OHNE Authentifizierung) // Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes); app.use('/api/public/consent', consentPublicRoutes);
@@ -124,9 +365,15 @@ app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes); app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes); app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes); app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check // Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
app.get('/api/health', (req, res) => { // pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
// "ok" + Timestamp, keine Version, kein DB-Status, kein Hostname damit
// auch unauth Caller keine internen Infos einsammeln können. Pentest
// 2026-05-20 (INFO): kein Auth → akzeptiert, Antwort liefert nichts
// Sensibles.
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });
@@ -134,8 +381,29 @@ app.get('/api/health', (req, res) => {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(process.cwd(), 'public'); const publicPath = path.join(process.cwd(), 'public');
// Serve static files // Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
app.use(express.static(publicPath)); // im Dateinamen das Image ist also versioniert. Daher kann der Browser
// sie für ein Jahr aggressiv cachen und muss nicht revalidieren.
app.use(
'/assets',
express.static(path.join(publicPath, 'assets'), {
maxAge: '1y',
immutable: true,
}),
);
// Rest des Frontends (index.html selbst, vite.svg, robots.txt, sitemap.xml).
// express.static findet index.html bei GET /, deshalb MUSS hier das gleiche
// no-store-Verhalten greifen wie im SPA-Fallback weiter unten sonst
// serviert der erste Static-Handler / mit dem express-Default `max-age=0`,
// bevor der Fallback überhaupt greift, und der Browser cached die alte SPA.
app.use(
express.static(publicPath, {
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate');
},
}),
);
// SPA fallback: serve index.html for all non-API routes // SPA fallback: serve index.html for all non-API routes
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
@@ -143,18 +411,37 @@ if (process.env.NODE_ENV === 'production') {
if (req.path.startsWith('/api')) { if (req.path.startsWith('/api')) {
return next(); return next();
} }
// SPA-Wurzel darf NIE gecached werden sonst sieht der Browser nach einem
// Deploy weiterhin die alte index.html mit alten Asset-Hashes.
res.setHeader('Cache-Control', 'no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html')); res.sendFile(path.join(publicPath, 'index.html'));
}); });
} }
// Error handling // Error handling
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { // body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
// kaschiert und landen als "Interner Serverfehler" beim User.
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack); console.error(err.stack);
res.status(500).json({ success: false, error: 'Interner Serverfehler' }); const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
let message = 'Interner Serverfehler';
if (status === 413) message = 'Anfrage zu groß';
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
message = 'Ungültiges JSON';
}
res.status(status).json({ success: false, error: message });
}); });
app.listen(PORT, () => { // Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
console.log(`Server läuft auf Port ${PORT}`); // Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
startBirthdayScheduler(); startBirthdayScheduler();
startContractStatusScheduler();
startSecurityMonitorScheduler();
}); });
+16
View File
@@ -1,6 +1,22 @@
import { PrismaClient, Prisma } from '@prisma/client'; import { PrismaClient, Prisma } from '@prisma/client';
import { setBeforeValues, setAfterValues } from '../middleware/auditContext.js'; import { setBeforeValues, setAfterValues } from '../middleware/auditContext.js';
// DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
// Der entrypoint.sh macht das ebenfalls (für den Server-Start). Aber bei
// `docker exec opencrm-app npx tsx prisma/<script>.ts` läuft eine neue
// Shell ohne diese exportierte Variable die DB_*-Vars sind aus dem
// docker-compose.yml vererbt, DATABASE_URL aber nicht. Damit alle
// Wartungsskripte (reset-admin-password, cleanup-xss-...) und Server
// dieselbe Logik nutzen, machen wir es einmal zentral hier.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'db';
const port = process.env.DB_PORT || '3306';
const n = process.env.DB_NAME;
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${n}`;
}
// Modelle die für Before/After-Tracking relevant sind // Modelle die für Before/After-Tracking relevant sind
const AUDITED_MODELS = [ const AUDITED_MODELS = [
'Customer', 'Customer',
+40 -2
View File
@@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js'; import { AuthRequest, JwtPayload } from '../types/index.js';
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
export async function authenticate( export async function authenticate(
req: AuthRequest, req: AuthRequest,
@@ -12,12 +13,15 @@ export async function authenticate(
// Token aus Header oder Query-Parameter (für Downloads) // Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null; let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) { if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1]; token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') { } else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter // Fallback für Downloads: Token als Query-Parameter
token = req.query.token; token = req.query.token;
tokenSource = 'query';
} }
if (!token) { if (!token) {
@@ -27,7 +31,32 @@ export async function authenticate(
try { try {
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts) // JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload; // Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
}) as JwtPayload & { type?: string };
// Defense-in-Depth: Refresh-Tokens haben `type: 'refresh'` und dürfen
// NICHT für normale API-Calls verwendet werden nur am /api/auth/refresh-
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
// zwangsabgemeldet werden.
if (decoded.type === 'refresh') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
// werden (Pentest Runde 7 NIEDRIG, Token-in-URL-Defense).
if (decoded.type === 'download' && tokenSource !== 'query') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde // Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
if (decoded.userId && decoded.iat) { if (decoded.userId && decoded.iat) {
@@ -78,7 +107,16 @@ export async function authenticate(
req.user = decoded; req.user = decoded;
next(); next();
} catch { } catch (err) {
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
endpoint: `${req.method} ${req.path}`,
});
res.status(401).json({ success: false, error: 'Ungültiger Token' }); res.status(401).json({ success: false, error: 'Ungültiger Token' });
} }
} }
+75 -6
View File
@@ -1,24 +1,66 @@
/** /**
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset). * Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe. * Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
*
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
* SecurityEvent (RATE_LIMIT_HIT) damit der Monitoring-View und das
* Alert-System sehen, wenn jemand auf die Tür hämmert.
*/ */
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
return (req: any, _res: any) => {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'RATE_LIMIT_HIT',
severity,
message: `Rate-Limit überschritten: ${label}`,
ipAddress: ctx.ipAddress,
userEmail: req.body?.email,
endpoint: ctx.endpoint,
details: { limiter: label },
});
};
}
/** /**
* Login: 10 Versuche pro 15 Minuten pro IP. * Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
* Nach Überschreitung: 15 Min Sperre für diese IP. *
* Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein:
* - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder
* 10 freie Versuche gegen den gleichen Account.
* - Email allein erzeugt False-Positives (Familie hinter NAT: Max
* vertippt sich Nina kommt von gleicher IP nicht mehr rein) und
* macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts
* aus, indem er von beliebigen IPs falsche PWs gegen sie probiert).
* - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von
* gleicher IP schon. Max von einer anderen IP auch, solange er das
* richtige PW hat ihre eigene Spur in den Buckets ist sauber.
*
* keyGenerator `${ip}|${email-lowercase}`. Bei fehlender Email
* (z.B. komplett leerer Body) Fallback nur auf IP, damit kein
* Single-Shared-Bucket entsteht.
*/ */
export const loginRateLimiter = rateLimit({ export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten windowMs: 15 * 60 * 1000,
limit: 10, // Max. 10 Versuche pro Zeitfenster limit: 10,
standardHeaders: 'draft-7', standardHeaders: 'draft-7',
legacyHeaders: false, legacyHeaders: false,
message: { message: {
success: false, success: false,
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.', error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
}, },
// Erfolgreiche Logins zählen nicht gegen das Limit
skipSuccessfulRequests: true, skipSuccessfulRequests: true,
keyGenerator: (req): string => {
const email = (req.body?.email || '').toString().trim().toLowerCase();
const ip = req.ip || 'unknown';
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
},
handler: (req, res, _next, options) => {
onLimitReached('login', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message);
},
}); });
/** /**
@@ -34,4 +76,31 @@ export const passwordResetRateLimiter = rateLimit({
success: false, success: false,
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.', error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
}, },
handler: (req, res, _next, options) => {
onLimitReached('password-reset', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.message);
},
});
/**
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
* unauthenticated. Der hash ist 128-bit-UUID kein Brute-Force-Risk,
* aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und
* den Service durch Audit-Log-Spam + Mail-Versand belasten.
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
* für legitime Kunden weit aus.
*/
export const publicConsentRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.',
},
handler: (req, res, _next, options) => {
onLimitReached('public-consent', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.message);
},
}); });
+29
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js'; import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js'; import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern) // Multer für Backup-Upload (in Memory speichern)
@@ -100,4 +101,32 @@ router.post(
backupController.factoryReset backupController.factoryReset
); );
// Backup-Operations-Log: Liste (ohne fullLog) + Detail
router.get(
'/backup-logs',
authenticate,
requirePermission('settings:update'),
backupController.listBackupLogs
);
router.get(
'/backup-logs/:id',
authenticate,
requirePermission('settings:update'),
backupController.getBackupLogDetail
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
authenticate,
requirePermission('settings:read'),
rateLimitAdminController.getActiveRateLimits,
);
router.post(
'/rate-limits/reset',
authenticate,
requirePermission('settings:update'),
rateLimitAdminController.resetRateLimit,
);
export default router; export default router;
+13
View File
@@ -5,13 +5,26 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
const router = Router(); const router = Router();
// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich
// `nina` von derselben IP einloggen, auch wenn `max` dort gerade
// 10x vergeigt hat und umgekehrt darf `max` von einer anderen IP
// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt
// hat (Pentest 2026-05-18 Szenario).
router.post('/login', loginRateLimiter, authController.login); router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);
router.get('/me', authenticate, authController.me); router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
router.post('/register', authenticate, requirePermission('users:create'), authController.register); router.post('/register', authenticate, requirePermission('users:create'), authController.register);
// Passwort-Reset-Flow // Passwort-Reset-Flow
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset); router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset); router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
router.post('/download-token', authenticate, authController.createDownloadToken);
export default router; export default router;
+5 -1
View File
@@ -1,9 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import * as controller from '../controllers/consent-public.controller.js'; import * as controller from '../controllers/consent-public.controller.js';
import { publicConsentRateLimiter } from '../middleware/rateLimit.js';
const router = Router(); const router = Router();
// Öffentliche Routes - KEINE Authentifizierung erforderlich // Öffentliche Routes - KEINE Authentifizierung erforderlich.
// Rate-Limit gegen DoS siehe publicConsentRateLimiter
// (Pentest 2026-05-20 INFO 28.4).
router.use(publicConsentRateLimiter);
router.get('/:hash', controller.getConsentPage); router.get('/:hash', controller.getConsentPage);
router.post('/:hash/grant', controller.grantAllConsents); router.post('/:hash/grant', controller.grantAllConsents);
router.get('/:hash/pdf', controller.getConsentPdf); router.get('/:hash/pdf', controller.getConsentPdf);
+3
View File
@@ -42,6 +42,9 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
// Follow-up contract // Follow-up contract
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp); router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
// VVL (Vertragsverlängerung beim selben Anbieter, vollständige Kopie + Datums-Berechnung)
router.post('/:id/renewal', authenticate, requirePermission('contracts:create'), contractController.createRenewal);
// Snooze (Vertrag zurückstellen) // Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract); router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
+2
View File
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings); router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword); router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword); router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Representatives (Vertreter) // Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives); router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+5 -44
View File
@@ -1,54 +1,15 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
const router = Router(); const router = Router();
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu // HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird // 2026-05-16 KRITISCH). Er war ohne Auth erreichbar und konnte
router.post('/setup', async (req, res: Response) => { // `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
try { // volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
// Create or get the developer:access permission // werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
const developerPerm = await prisma.permission.upsert({
where: { resource_action: { resource: 'developer', action: 'access' } },
update: {},
create: { resource: 'developer', action: 'access' },
});
// Get the Admin role
const adminRole = await prisma.role.findUnique({
where: { name: 'Admin' },
include: { permissions: true },
});
if (!adminRole) {
res.status(404).json({ success: false, error: 'Admin-Rolle nicht gefunden' });
return;
}
// Check if Admin already has this permission
const hasPermission = adminRole.permissions.some(
(rp) => rp.permissionId === developerPerm.id
);
if (!hasPermission) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: developerPerm.id,
},
});
res.json({ success: true, message: 'developer:access Permission wurde zur Admin-Rolle hinzugefügt. Bitte neu einloggen!' });
} else {
res.json({ success: true, message: 'Admin-Rolle hat bereits die developer:access Permission' });
}
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Setup' });
}
});
// Tabellen-Metadaten mit Beziehungen // Tabellen-Metadaten mit Beziehungen
const tableMetadata: Record<string, { const tableMetadata: Record<string, {
@@ -1,9 +1,25 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js'; import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// In-Memory-Upload für die ZIP wird direkt verarbeitet, keine temporäre Datei.
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: (_req, file, cb) => {
const ok =
file.mimetype === 'application/zip' ||
file.mimetype === 'application/x-zip-compressed' ||
file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip
file.originalname.toLowerCase().endsWith('.zip');
if (ok) cb(null, true);
else cb(new Error('Nur ZIP-Dateien sind erlaubt'));
},
limits: { fileSize: 50 * 1024 * 1024 },
});
// Preview (was wäre im Export drin?) // Preview (was wäre im Export drin?)
router.get( router.get(
'/preview', '/preview',
@@ -20,4 +36,13 @@ router.get(
factoryDefaultsController.exportFactoryDefaults, factoryDefaultsController.exportFactoryDefaults,
); );
// Import aus ZIP (multipart, Feld 'zip')
router.post(
'/import',
authenticate,
requirePermission('settings:update'),
upload.single('zip'),
factoryDefaultsController.importFactoryDefaults,
);
export default router; export default router;
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as monitoringController from '../controllers/monitoring.controller.js';
const router = Router();
router.use(authenticate);
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
export default router;
+4 -4
View File
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Provider routes // Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
router.get('/', authenticate, providerController.getProviders); router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider); router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
router.get('/:id', authenticate, providerController.getProvider); router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider);
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider); router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider); router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// Nested tariff routes // Nested tariff routes
router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs); router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff); router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
export default router; export default router;
@@ -12,4 +12,7 @@ router.delete('/:id', authenticate, requirePermission('customers:delete'), stres
// Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider) // Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider)
router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword); router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword);
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
export default router; export default router;
+1 -1
View File
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Standalone tariff routes (for update/delete by tariff id) // Standalone tariff routes (for update/delete by tariff id)
router.get('/:id', authenticate, tariffController.getTariff); router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff); router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff); router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
+43 -1
View File
@@ -6,6 +6,7 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router(); const router = Router();
@@ -546,6 +547,7 @@ async function handleContractDocumentUpload(
} }
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`; const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// Alte Datei löschen falls vorhanden // Alte Datei löschen falls vorhanden
@@ -563,12 +565,51 @@ async function handleContractDocumentUpload(
} }
} }
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
let target: Date | null = null;
if (provided) {
const parsed = new Date(provided);
if (!isNaN(parsed.getTime())) target = parsed;
}
if (target) {
updateData[dateField] = target;
} else if (!contract[dateField]) {
updateData[dateField] = new Date();
}
}
// Vertrag in der DB aktualisieren // Vertrag in der DB aktualisieren
await prisma.contract.update({ await prisma.contract.update({
where: { id: contractId }, where: { id: contractId },
data: { [fieldName]: relativePath }, data: updateData,
}); });
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
}
res.json({ res.json({
success: true, success: true,
data: { data: {
@@ -592,6 +633,7 @@ async function handleContractDocumentDelete(
) { ) {
try { try {
const contractId = parseInt(req.params.id); const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } }); const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) { if (!contract) {
+2
View File
@@ -10,6 +10,8 @@ router.post('/', authenticate, requirePermission('users:create'), userController
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser); router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser); router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser); router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
// Passwort-Reset durch Admin dedizierter Endpoint (Pentest Runde 12)
router.post('/:id/password', authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles // Roles
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles); router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
// Default settings // Default settings
const DEFAULT_SETTINGS: Record<string, string> = { const DEFAULT_SETTINGS: Record<string, string> = {
@@ -12,6 +13,53 @@ const DEFAULT_SETTINGS: Record<string, string> = {
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage) documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
}; };
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
// anderen Keys mehr an (Pentest Runde 11 (2026-05-18) M1: Mass
// Assignment, "superAdminEmail", "debugMode", "allowedOrigins" landeten
// vorher ungefiltert in der DB).
export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
...Object.keys(DEFAULT_SETTINGS),
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
'monitoringAlertEmail',
'monitoringDigestEnabled',
'monitoringLastDigestAt',
'companyName',
'defaultEmailDomain',
]);
export function isAllowedSettingKey(key: string): boolean {
return ALLOWED_SETTING_KEYS.has(key);
}
// Keys deren Wert legitim HTML enthalten darf (Datenschutz-/Impressum-Editoren
// liefern WYSIWYG-HTML). Alle anderen Plain-Text-Keys (companyName,
// defaultEmailDomain, Schwellenwerte etc.) werden vor dem Persistieren durch
// stripHtml geschickt Pentest 2026-05-19, MEDIUM: <img src=x onerror=alert(1)>
// in companyName landete ungefiltert in der DB und konnte später z.B. in
// E-Mail-Templates oder PDF-Generatoren unescaped landen.
const HTML_ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
/**
* Bereinigt den Wert vor dem Speichern: für Plain-Text-Keys werden alle
* HTML-Tags entfernt. Die dedizierten Editor-Keys
* (imprintHtml/privacyPolicyHtml/...) bleiben unverändert, da sie sonst
* den WYSIWYG-Editor unbenutzbar machen würden sie werden über
* dedizierte /api/gdpr-Endpoints gepflegt.
*/
export function sanitizeSettingValue(key: string, value: string): string {
if (HTML_ALLOWED_SETTING_KEYS.has(key)) return value;
const stripped = stripHtml(value);
return typeof stripped === 'string' ? stripped : String(stripped);
}
export async function getSetting(key: string): Promise<string | null> { export async function getSetting(key: string): Promise<string | null> {
const setting = await prisma.appSetting.findUnique({ const setting = await prisma.appSetting.findUnique({
where: { key }, where: { key },
+7
View File
@@ -112,6 +112,13 @@ function determineSensitivity(resourceType: string): AuditSensitivity {
Authentication: 'CRITICAL', Authentication: 'CRITICAL',
BankCard: 'CRITICAL', BankCard: 'CRITICAL',
IdentityDocument: 'CRITICAL', IdentityDocument: 'CRITICAL',
// Klartext-Passwort-Reads jeder Decrypt-Vorgang muss nachvollziehbar sein
PortalPassword: 'CRITICAL',
ContractPassword: 'CRITICAL',
SimCardCredentials: 'CRITICAL',
InternetCredentials: 'CRITICAL',
SipCredentials: 'CRITICAL',
MailboxCredentials: 'CRITICAL',
// HIGH // HIGH
Customer: 'HIGH', Customer: 'HIGH',
User: 'HIGH', User: 'HIGH',
+355 -18
View File
@@ -6,6 +6,76 @@ import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js'; import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js'; import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js'; import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import { getAuthorizedCustomerIds } from './authorization.service.js';
// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
// - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff
const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn'];
const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'];
// Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als
// Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim
// /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt.
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function signRefreshToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
});
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
// nginx-Access-Logs oder der Browser-History landet, ist er nach
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) NIEDRIG.
export function signDownloadToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
expiresIn: '60s',
});
}
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;
// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
// dem Timing-Angleich.
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';
/**
* Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
* Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
* aus der Installation) werden so lazy auf Cost 12 migriert damit sich die
* Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
*/
async function maybeUpgradePasswordHash(
table: 'user' | 'customer',
id: number,
plaintextPassword: string,
currentHash: string,
): Promise<void> {
const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
const currentCost = match ? parseInt(match[1], 10) : 0;
if (currentCost === BCRYPT_COST) return;
try {
const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
if (table === 'user') {
await prisma.user.update({ where: { id }, data: { password: newHash } });
} else {
await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
}
} catch (err) {
// Nicht kritisch Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
}
}
// Mitarbeiter-Login // Mitarbeiter-Login
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
@@ -29,6 +99,9 @@ export async function login(email: string, password: string) {
}); });
if (!user || !user.isActive) { if (!user || !user.isActive) {
// Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
// nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
@@ -37,6 +110,10 @@ export async function login(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
// Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
// Async, nicht blockierend für die Response.
maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});
// Collect all permissions from all roles // Collect all permissions from all roles
const permissions = new Set<string>(); const permissions = new Set<string>();
for (const userRole of user.roles) { for (const userRole of user.roles) {
@@ -55,12 +132,12 @@ export async function login(email: string, password: string) {
isCustomerPortal: false, isCustomerPortal: false,
}; };
const token = jwt.sign(payload, process.env.JWT_SECRET as string, { const accessToken = signAccessToken(payload);
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], const refreshToken = signRefreshToken(payload);
});
return { return {
token, accessToken,
refreshToken,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -103,6 +180,8 @@ export async function customerLogin(email: string, password: string) {
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) { if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert'); console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
// Timing-Attack-Schutz (siehe login())
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
@@ -113,16 +192,42 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten'); throw new Error('Ungültige Anmeldedaten');
} }
// Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
// Falls ja, jetzt sofort verbrauchen Hash + Encrypted nullen, damit
// weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
// Force-Change-Password-Flow.
const mustChangePassword = customer.portalPasswordMustChange === true;
if (mustChangePassword) {
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: null,
portalPasswordEncrypted: null,
portalLastLogin: new Date(),
},
});
} else {
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren // Letzte Anmeldung aktualisieren
await prisma.customer.update({ await prisma.customer.update({
where: { id: customer.id }, where: { id: customer.id },
data: { portalLastLogin: new Date() }, data: { portalLastLogin: new Date() },
}); });
}
// IDs der Kunden sammeln, die dieser Kunde vertreten kann // IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map( // GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
(rep) => rep.customer.id // hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte
// representedCustomerIds-Liste; die UI würde dem Vertreter noch
// anzeigen, dass er vertreten kann, obwohl der Live-Check beim
// Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
); );
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
// Kundenportal-Berechtigungen (eingeschränkt) // Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [ const customerPermissions = [
@@ -138,12 +243,13 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds, representedCustomerIds,
}; };
const token = jwt.sign(payload, process.env.JWT_SECRET as string, { const accessToken = signAccessToken(payload);
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], const refreshToken = signRefreshToken(payload);
});
return { return {
token, accessToken,
refreshToken,
mustChangePassword,
user: { user: {
id: customer.id, id: customer.id,
email: customer.portalEmail, email: customer.portalEmail,
@@ -152,7 +258,8 @@ export async function customerLogin(email: string, password: string) {
permissions: customerPermissions, permissions: customerPermissions,
customerId: customer.id, customerId: customer.id,
isCustomerPortal: true, isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({ mustChangePassword,
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id, id: rep.customer.id,
customerNumber: rep.customer.customerNumber, customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName, firstName: rep.customer.firstName,
@@ -164,26 +271,142 @@ export async function customerLogin(email: string, password: string) {
}; };
} }
// Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei
// ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt
// vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch
// das Refresh) sofort tot.
export async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
user: any;
}> {
let decoded: any;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
});
} catch {
throw new Error('Refresh-Token ungültig oder abgelaufen');
}
if (decoded.type !== 'refresh') {
throw new Error('Falscher Token-Typ');
}
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
if (!decoded.isCustomerPortal && decoded.userId) {
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
},
});
if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv');
if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
}
const permissions = new Set<string>();
for (const ur of user.roles) {
for (const rp of ur.role.permissions) {
permissions.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
const payload: JwtPayload = {
userId: user.id,
email: user.email,
permissions: Array.from(permissions),
customerId: user.customerId ?? undefined,
isCustomerPortal: false,
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
},
};
}
// Customer-Portal
if (decoded.isCustomerPortal && decoded.customerId) {
const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } });
if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden');
if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert');
}
const portalUser = await getCustomerPortalUser(customer.id);
if (!portalUser) throw new Error('Portal-Konto nicht gefunden');
const payload: JwtPayload = {
email: customer.portalEmail,
permissions: portalUser.permissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id),
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: portalUser,
};
}
throw new Error('Refresh-Token konnte nicht interpretiert werden');
}
// Kundenportal-Passwort setzen/ändern // Kundenportal-Passwort setzen/ändern
export async function setCustomerPortalPassword(customerId: number, password: string) { export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId); console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
const encryptedPassword = encrypt(password); const encryptedPassword = encrypt(password);
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length); console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
// Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
// falls vorher ein OTP gesetzt war.
await prisma.customer.update({ await prisma.customer.update({
where: { id: customerId }, where: { id: customerId },
data: { data: {
portalPasswordHash: hashedPassword, portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword, portalPasswordEncrypted: encryptedPassword,
portalPasswordMustChange: false,
}, },
}); });
console.log('[SetPortalPassword] Passwort gespeichert'); console.log('[SetPortalPassword] Passwort gespeichert');
} }
// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
// gefordert wird.
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: null,
portalPasswordMustChange: false,
portalTokenInvalidatedAt: new Date(),
},
});
}
export async function markPortalPasswordForChange(customerId: number) {
await prisma.customer.update({
where: { id: customerId },
data: { portalPasswordMustChange: true },
});
}
// Kundenportal-Passwort im Klartext abrufen // Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> { export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({ const customer = await prisma.customer.findUnique({
@@ -211,7 +434,7 @@ export async function createUser(data: {
roleIds: number[]; roleIds: number[];
customerId?: number; customerId?: number;
}) { }) {
const hashedPassword = await bcrypt.hash(data.password, 10); const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
@@ -323,6 +546,13 @@ export async function getCustomerPortalUser(customerId: number) {
'customers:read', 'customers:read',
]; ];
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
);
return { return {
id: customer.id, id: customer.id,
email: customer.portalEmail, email: customer.portalEmail,
@@ -332,7 +562,7 @@ export async function getCustomerPortalUser(customerId: number) {
customerId: customer.id, customerId: customer.id,
permissions: customerPermissions, permissions: customerPermissions,
isCustomerPortal: true, isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({ representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id, id: rep.customer.id,
customerNumber: rep.customer.customerNumber, customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName, firstName: rep.customer.firstName,
@@ -355,6 +585,86 @@ function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173'; return process.env.PUBLIC_URL || 'http://localhost:5173';
} }
/**
* Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
* UI ausgelöst nie automatisch , weil das Klartext-Passwort im Mail-
* Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
*/
export async function sendPortalCredentialsEmail(params: {
to: string;
customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
loginEmail: string;
password: string;
}): Promise<void> {
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const loginUrl = `${getPublicUrl()}/portal/login`;
const name = params.customer.companyName?.trim()
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|| 'Kunde';
// HTML-Escape Customer-Namen können theoretisch Sonderzeichen enthalten,
// die wir nicht ungefiltert in die Mail rendern wollen.
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
<p>Hallo ${esc(name)},</p>
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
</table>
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
Dieses Passwort ist ein <u>Einmalpasswort</u>.
</p>
<p style="color: #6b7280; font-size: 14px;">
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> falls Sie den
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
Passwort-vergessen-Funktion.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Diese Nachricht enthält sensible Zugangsdaten bitte sicher verwahren oder nach
dem Login löschen.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: params.to,
subject: 'Ihre Zugangsdaten zum Kundenportal',
html,
},
{
context: 'portal-credentials',
triggeredBy: 'admin-action',
},
);
}
/** /**
* Passwort-Reset-Link per Email senden. * Passwort-Reset-Link per Email senden.
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden * Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
@@ -458,6 +768,26 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
); );
} }
/**
* Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem
* Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den
* passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden,
* BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13.
*/
export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> {
const user = await prisma.user.findUnique({
where: { passwordResetToken: token },
select: { id: true },
});
if (user) return 'admin';
const customer = await prisma.customer.findUnique({
where: { portalPasswordResetToken: token },
select: { id: true },
});
if (customer) return 'portal';
return null;
}
/** /**
* Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen. * Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
* Invalidiert alle bestehenden JWT-Sessions des Users. * Invalidiert alle bestehenden JWT-Sessions des Users.
@@ -471,7 +801,7 @@ export async function confirmPasswordReset(token: string, newPassword: string):
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
} }
const hash = await bcrypt.hash(newPassword, 10); const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -493,16 +823,23 @@ export async function confirmPasswordReset(token: string, newPassword: string):
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.'); throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
} }
const hash = await bcrypt.hash(newPassword, 10); const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({ await prisma.customer.update({
where: { id: customer.id }, where: { id: customer.id },
data: { data: {
portalPasswordHash: hash, portalPasswordHash: hash,
portalPasswordEncrypted: encrypt(newPassword), // Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir
// KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte
// Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per
// Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort
// ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak.
portalPasswordEncrypted: null,
portalPasswordResetToken: null, portalPasswordResetToken: null,
portalPasswordResetExpiresAt: null, portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken // Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(), portalTokenInvalidatedAt: new Date(),
// OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
portalPasswordMustChange: false,
}, },
}); });
return; return;
+36 -4
View File
@@ -138,6 +138,24 @@ function deleteDirectory(dirPath: string): void {
fs.rmdirSync(dirPath); fs.rmdirSync(dirPath);
} }
// Wie deleteDirectory, ABER das Ziel-Verzeichnis selbst bleibt stehen
// nur die Inhalte verschwinden. Notwendig für Docker-Bind-Mounts wie
// `/app/uploads`: dort wirft `rmdir` ein EBUSY, weil das Volume vom Host
// gemountet ist und sich nicht aushängen lässt.
function emptyDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) return;
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.lstatSync(itemPath);
if (stats.isDirectory()) {
deleteDirectory(itemPath);
} else {
fs.unlinkSync(itemPath);
}
}
}
/** /**
* Liste aller verfügbaren Backups * Liste aller verfügbaren Backups
*/ */
@@ -249,6 +267,7 @@ export async function createBackup(): Promise<BackupResult> {
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() }, { name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() }, { name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() }, { name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
]; ];
let totalRecords = 0; let totalRecords = 0;
@@ -310,6 +329,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
// Logs & Audit zuerst (hängen an allem) // Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({}); await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({}); await prisma.emailLog.deleteMany({});
await prisma.securityEvent.deleteMany({});
// Detail-Tabellen // Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({}); await prisma.carInsuranceDetails.deleteMany({});
@@ -887,6 +907,18 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
} }
}, },
}, },
{
name: 'SecurityEvent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.securityEvent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
]; ];
let totalRestored = 0; let totalRestored = 0;
@@ -912,10 +944,10 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
let restoredFiles = 0; let restoredFiles = 0;
const uploadsBackupDir = path.join(backupDir, 'uploads'); const uploadsBackupDir = path.join(backupDir, 'uploads');
if (fs.existsSync(uploadsBackupDir)) { if (fs.existsSync(uploadsBackupDir)) {
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben) // Inhalte leeren, das Verzeichnis selbst NICHT löschen
if (fs.existsSync(UPLOADS_DIR)) { // UPLOADS_DIR ist im Container ein Bind-Mount auf den Host und
deleteDirectory(UPLOADS_DIR); // `rmdir` darauf liefert EBUSY (siehe emptyDirectory()).
} emptyDirectory(UPLOADS_DIR);
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR); restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
} }
@@ -49,6 +49,18 @@ export interface EmailListOptions {
limit?: number; limit?: number;
offset?: number; offset?: number;
includeBody?: boolean; includeBody?: boolean;
// Suche / Filter (alle AND-verknüpft)
search?: string; // Volltextsuche über subject + from + body
fromFilter?: string; // Absender enthält
toFilter?: string; // Empfänger enthält
subjectFilter?: string; // Subject enthält
bodyFilter?: string; // Body enthält (text/html)
attachmentNameFilter?: string; // Anhang-Dateiname enthält
hasAttachments?: boolean; // Nur mit/ohne Anhang
isRead?: boolean; // Gelesen-Status
isStarred?: boolean; // Markiert-Status
receivedFrom?: Date; // Empfangen ab
receivedTo?: Date; // Empfangen bis
} }
// ==================== SYNC FUNCTIONS ==================== // ==================== SYNC FUNCTIONS ====================
@@ -273,6 +285,59 @@ export async function getCachedEmails(
where.folder = EmailFolder.INBOX; where.folder = EmailFolder.INBOX;
} }
// ===== Such-/Filter-Parameter =====
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
if (options.search && options.search.trim()) {
const q = options.search.trim();
where.OR = [
{ subject: { contains: q } },
{ fromAddress: { contains: q } },
{ fromName: { contains: q } },
{ textBody: { contains: q } },
];
}
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
if (options.fromFilter?.trim()) {
const q = options.fromFilter.trim();
// Treffer in fromAddress ODER fromName für den Nutzer ist „Von" beides
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
];
}
if (options.toFilter?.trim()) {
where.toAddresses = { contains: options.toFilter.trim() };
}
if (options.subjectFilter?.trim()) {
where.subject = { contains: options.subjectFilter.trim() };
}
if (options.bodyFilter?.trim()) {
const q = options.bodyFilter.trim();
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
];
}
if (options.attachmentNameFilter?.trim()) {
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
}
if (typeof options.hasAttachments === 'boolean') {
where.hasAttachments = options.hasAttachments;
}
if (typeof options.isRead === 'boolean') {
where.isRead = options.isRead;
}
if (typeof options.isStarred === 'boolean') {
where.isStarred = options.isStarred;
}
if (options.receivedFrom || options.receivedTo) {
where.receivedAt = {};
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
}
// Body-Felder nur wenn explizit angefordert (spart Bandbreite) // Body-Felder nur wenn explizit angefordert (spart Bandbreite)
const select: Prisma.CachedEmailSelect = { const select: Prisma.CachedEmailSelect = {
id: true, id: true,
+18
View File
@@ -3,6 +3,24 @@ import prisma from '../lib/prisma.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
// 'public-link', CRM-Backend-Override 'crm-backend'. Alles andere
// (z.B. "ADMIN_OVERRIDE", "<script>") wird abgelehnt Pentest 2026-05-20.
export const ALLOWED_CONSENT_SOURCES: ReadonlySet<string> = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
export function sanitizeConsentSource(value: unknown, fallback: string): string {
const v = typeof value === 'string' ? value : '';
return ALLOWED_CONSENT_SOURCES.has(v) ? v : fallback;
}
export interface UpdateConsentData { export interface UpdateConsentData {
status: ConsentStatus; status: ConsentStatus;
source?: string; source?: string;
+267 -1
View File
@@ -2,6 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js'; import { encrypt, decrypt } from '../utils/encryption.js';
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
export interface ContractFilters { export interface ContractFilters {
customerId?: number; customerId?: number;
@@ -154,7 +155,18 @@ export async function getContractById(id: number, decryptPassword = false) {
if (!contract) return null; if (!contract) return null;
// Decrypt password if requested and exists // SECURITY: Embedded Customer-Objekt sanitizen, sonst leaken
// portalPasswordHash + portalPasswordEncrypted + Reset-Token in jede
// contract.customer-Response. Der direkte `/customers/:id`-Endpoint hat
// den Schutz schon; hier wäre er ohne Sanitize bypassbar.
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
// Decrypt password if requested and exists (Contract-Anbieter-Passwort,
// nicht zu verwechseln mit Customer-Portal-Passwort)
if (decryptPassword && contract.portalPasswordEncrypted) { if (decryptPassword && contract.portalPasswordEncrypted) {
try { try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt( (contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
@@ -385,6 +397,15 @@ export async function createContract(data: ContractCreateData) {
}, },
}); });
// Embedded Customer-Objekt sanitizen (siehe getContractById derselbe
// Schutz; createContract gibt den frisch erstellten Vertrag inkl. Customer
// zurück, und der darf keine Passwort-Hashes/-Encryptions leaken).
if (contract.customer) {
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
contract.customer as Record<string, unknown>,
);
}
return contract; return contract;
} }
@@ -765,6 +786,251 @@ export async function createFollowUpContract(previousContractId: number) {
return createContract(newContractData); return createContract(newContractData);
} }
/**
* Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration.
* Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt
* sie auf 12 Monate als sicheren Default zurück.
*/
function durationToMonths(code: string | null | undefined, description: string | null | undefined): number {
const c = (code || '').trim();
const d = (description || '').trim();
let m = c.match(/^(\d+)\s*M$/i);
if (m) return parseInt(m[1], 10);
m = c.match(/^(\d+)\s*J$/i);
if (m) return parseInt(m[1], 10) * 12;
m = d.match(/(\d+)\s*Monat/i);
if (m) return parseInt(m[1], 10);
m = d.match(/(\d+)\s*Jahr/i);
if (m) return parseInt(m[1], 10) * 12;
return 12;
}
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
*
* Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert:
* Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments.
*
* Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit.
* Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann
* der User es im Vertrag manuell anpassen.
*
* NICHT mitkopiert wird:
* - das Auftragsdokument (documentType "Auftragsformular") das ist
* schließlich die NEU zu unterschreibende VVL.
* - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow,
* bei einer VVL nicht relevant)
*/
export async function createRenewalContract(previousContractId: number) {
const previousContract = await getContractById(previousContractId, true);
if (!previousContract) {
throw new Error('Vorgängervertrag nicht gefunden');
}
// Bereits ein Folge-/VVL-Vertrag vorhanden?
const existing = await prisma.contract.findFirst({
where: { previousContractId },
select: { id: true, contractNumber: true },
});
if (existing) {
throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`);
}
// Neues Startdatum = altes Start + Laufzeit
let newStartDate: Date | null = null;
let newEndDate: Date | null = null;
if (previousContract.startDate && previousContract.contractDuration) {
const months = durationToMonths(
previousContract.contractDuration.code,
previousContract.contractDuration.description,
);
newStartDate = new Date(previousContract.startDate);
newStartDate.setMonth(newStartDate.getMonth() + months);
newEndDate = new Date(newStartDate);
newEndDate.setMonth(newEndDate.getMonth() + months);
}
// Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder)
const contractNumber = generateContractNumber(previousContract.type);
const newContract = await prisma.contract.create({
data: {
contractNumber,
customerId: previousContract.customerId,
type: previousContract.type,
status: 'DRAFT',
contractCategoryId: previousContract.contractCategoryId,
addressId: previousContract.addressId,
billingAddressId: previousContract.billingAddressId,
bankCardId: previousContract.bankCardId,
identityDocumentId: previousContract.identityDocumentId,
salesPlatformId: previousContract.salesPlatformId,
cancellationPeriodId: previousContract.cancellationPeriodId,
contractDurationId: previousContract.contractDurationId,
previousContractId: previousContract.id,
previousProviderId: previousContract.previousProviderId,
providerId: previousContract.providerId,
tariffId: previousContract.tariffId,
providerName: previousContract.providerName,
tariffName: previousContract.tariffName,
customerNumberAtProvider: previousContract.customerNumberAtProvider,
portalUsername: previousContract.portalUsername,
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
commission: previousContract.commission,
notes: previousContract.notes,
startDate: newStartDate,
endDate: newEndDate,
// Cancellation-Felder bewusst leer lassen die VVL hat den alten
// Cancel-Flow nicht geerbt.
},
});
// Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu)
if (previousContract.energyDetails) {
const ed = previousContract.energyDetails;
const newEnergy = await prisma.energyContractDetails.create({
data: {
contractId: newContract.id,
meterId: ed.meterId,
maloId: ed.maloId,
annualConsumption: ed.annualConsumption,
annualConsumptionKwh: ed.annualConsumptionKwh,
basePrice: ed.basePrice,
unitPrice: ed.unitPrice,
unitPriceNt: ed.unitPriceNt,
bonus: ed.bonus,
previousProviderName: ed.previousProviderName,
previousCustomerNumber: ed.previousCustomerNumber,
},
});
// ContractMeter-Verknüpfungen mitkopieren
for (const cm of ed.contractMeters || []) {
await prisma.contractMeter.create({
data: {
energyContractDetailsId: newEnergy.id,
meterId: cm.meterId,
position: cm.position,
installedAt: cm.installedAt,
removedAt: cm.removedAt,
finalReading: cm.finalReading,
},
});
}
}
if (previousContract.internetDetails) {
const id = previousContract.internetDetails;
const newInet = await prisma.internetContractDetails.create({
data: {
contractId: newContract.id,
downloadSpeed: id.downloadSpeed,
uploadSpeed: id.uploadSpeed,
routerModel: id.routerModel,
routerSerialNumber: id.routerSerialNumber,
installationDate: id.installationDate,
internetUsername: id.internetUsername,
internetPasswordEncrypted: id.internetPasswordEncrypted,
propertyType: id.propertyType,
propertyLocation: id.propertyLocation,
connectionLocation: id.connectionLocation,
homeId: id.homeId,
activationCode: id.activationCode,
},
});
for (const pn of id.phoneNumbers || []) {
await prisma.phoneNumber.create({
data: {
internetContractDetailsId: newInet.id,
phoneNumber: pn.phoneNumber,
isMain: pn.isMain,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPasswordEncrypted,
sipServer: pn.sipServer,
},
});
}
}
if (previousContract.mobileDetails) {
const md = previousContract.mobileDetails;
const newMob = await prisma.mobileContractDetails.create({
data: {
contractId: newContract.id,
requiresMultisim: md.requiresMultisim,
dataVolume: md.dataVolume,
includedMinutes: md.includedMinutes,
includedSMS: md.includedSMS,
deviceModel: md.deviceModel,
deviceImei: md.deviceImei,
phoneNumber: md.phoneNumber,
simCardNumber: md.simCardNumber,
},
});
for (const sc of md.simCards || []) {
await prisma.simCard.create({
data: {
mobileDetailsId: newMob.id,
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
pin: sc.pin,
puk: sc.puk,
},
});
}
}
if (previousContract.tvDetails) {
await prisma.tvContractDetails.create({
data: {
contractId: newContract.id,
receiverModel: previousContract.tvDetails.receiverModel,
smartcardNumber: previousContract.tvDetails.smartcardNumber,
package: previousContract.tvDetails.package,
},
});
}
if (previousContract.carInsuranceDetails) {
const ci = previousContract.carInsuranceDetails;
await prisma.carInsuranceDetails.create({
data: {
contractId: newContract.id,
licensePlate: ci.licensePlate,
hsn: ci.hsn,
tsn: ci.tsn,
vin: ci.vin,
vehicleType: ci.vehicleType,
firstRegistration: ci.firstRegistration,
noClaimsClass: ci.noClaimsClass,
insuranceType: ci.insuranceType,
deductiblePartial: ci.deductiblePartial,
deductibleFull: ci.deductibleFull,
previousInsurer: ci.previousInsurer,
},
});
}
// ContractDocuments mitkopieren AUSSER "Auftragsformular" (das ist die
// neue Unterschrift, die der User selbst hochlädt). Files werden NICHT
// physisch dupliziert; beide Verträge zeigen auf dieselbe Datei.
const docs = await prisma.contractDocument.findMany({
where: { contractId: previousContract.id },
});
for (const d of docs) {
if (d.documentType.toLowerCase().includes('auftragsformular')) continue;
await prisma.contractDocument.create({
data: {
contractId: newContract.id,
documentType: d.documentType,
documentPath: d.documentPath,
originalName: d.originalName,
notes: d.notes,
uploadedBy: d.uploadedBy,
},
});
}
return prisma.contract.findUnique({ where: { id: newContract.id } });
}
// Decrypt password for viewing // Decrypt password for viewing
export async function getContractPassword(id: number): Promise<string | null> { export async function getContractPassword(id: number): Promise<string | null> {
const contract = await prisma.contract.findUnique({ const contract = await prisma.contract.findUnique({
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
return end; return end;
} }
export async function getCockpitData(): Promise<CockpitResult> { export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
// Lade Einstellungen // Lade Einstellungen
const settings = await appSettingService.getAllSettings(); const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14; const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
@@ -192,12 +192,19 @@ export async function getCockpitData(): Promise<CockpitResult> {
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30; const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90; const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
// Portal-Filter: Wenn customerIds gesetzt sind (Kundenportal-User), beschränken
// wir ALLE Cockpit-Queries auf diese Customer-IDs. Leeres Array → keine Treffer.
const customerScopeFilter = opts?.customerIds
? { customerId: { in: opts.customerIds } }
: {};
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check) // Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
const contracts = await prisma.contract.findMany({ const contracts = await prisma.contract.findMany({
where: { where: {
status: { status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'], in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
}, },
...customerScopeFilter,
}, },
include: { include: {
customer: { customer: {
@@ -283,9 +290,9 @@ export async function getCockpitData(): Promise<CockpitResult> {
}, },
}; };
// Consent-Daten batch-laden für alle Kunden // Consent-Daten batch-laden für alle (erlaubten) Kunden
const allConsents = await prisma.customerConsent.findMany({ const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED' }, where: { status: 'GRANTED', ...customerScopeFilter },
select: { customerId: true, consentType: true }, select: { customerId: true, consentType: true },
}); });
@@ -300,7 +307,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
// Widerrufene Consents laden // Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({ const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' }, where: { status: 'WITHDRAWN', ...customerScopeFilter },
select: { customerId: true, consentType: true }, select: { customerId: true, consentType: true },
}); });
const withdrawnConsentsMap = new Map<number, Set<string>>(); const withdrawnConsentsMap = new Map<number, Set<string>>();
@@ -733,10 +740,10 @@ export async function getCockpitData(): Promise<CockpitResult> {
}); });
// Vertragsunabhängige Ausweis-Warnungen // Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays); const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
// Gemeldete Zählerstände (REPORTED Status) // Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings(); const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
return { return {
contracts: cockpitContracts, contracts: cockpitContracts,
@@ -754,7 +761,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
/** /**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig) * Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/ */
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> { async function getDocumentExpiryAlerts(
criticalDays: number,
warningDays: number,
customerIds?: number[],
): Promise<DocumentAlert[]> {
const now = new Date(); const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000); const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
@@ -762,6 +773,7 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
where: { where: {
isActive: true, isActive: true,
expiryDate: { lte: inWarningDays }, expiryDate: { lte: inWarningDays },
...(customerIds ? { customerId: { in: customerIds } } : {}),
}, },
include: { include: {
customer: { customer: {
@@ -798,9 +810,12 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
/** /**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden * Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/ */
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> { async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({ const readings = await prisma.meterReading.findMany({
where: { status: 'REPORTED' }, where: {
status: 'REPORTED',
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
},
include: { include: {
meter: { meter: {
include: { include: {
@@ -129,3 +129,35 @@ export async function createNewContractFromPredecessorEntry(
createdBy, createdBy,
}); });
} }
/**
* Automatischen Historie-Eintrag für VVL (Vertragsverlängerung) im Vorgängervertrag.
*/
export async function createRenewalHistoryEntry(
previousContractId: number,
newContractNumber: string,
createdBy: string
) {
return createHistoryEntry(previousContractId, {
title: `Vertragsverlängerung erstellt: ${newContractNumber}`,
description: `Eine Vertragsverlängerung (VVL) als ${newContractNumber} wurde aus diesem Vertrag erstellt alle Daten wurden 1:1 übernommen, das Auftragsdokument muss neu hochgeladen werden.`,
isAutomatic: true,
createdBy,
});
}
/**
* Automatischen Historie-Eintrag im neuen VVL-Vertrag.
*/
export async function createNewRenewalFromPredecessorEntry(
newContractId: number,
previousContractNumber: string,
createdBy: string
) {
return createHistoryEntry(newContractId, {
title: `VVL zu ${previousContractNumber}`,
description: `Dieser Vertrag wurde als Vertragsverlängerung (VVL) zu ${previousContractNumber} erstellt.`,
isAutomatic: true,
createdBy,
});
}
@@ -0,0 +1,149 @@
/**
* Scheduler für automatische Vertrags-Status-Übergänge.
*
* Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und
* endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log).
*
* Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls
* der Prozess zum 02:00-Slot neu gestartet wurde.
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { createAuditLog, logChange } from './audit.service.js';
async function runExpireCheck(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expiring = await prisma.contract.findMany({
where: {
status: 'ACTIVE',
endDate: { not: null, lt: today },
},
select: {
id: true,
contractNumber: true,
customerId: true,
endDate: true,
},
});
if (expiring.length === 0) {
console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.');
return;
}
console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`);
for (const c of expiring) {
try {
await prisma.contract.update({
where: { id: c.id },
data: { status: 'EXPIRED' },
});
await createAuditLog({
userEmail: 'system',
userRole: 'System',
action: 'UPDATE',
resourceType: 'Contract',
resourceId: c.id.toString(),
resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`,
endpoint: 'scheduler:contract-status',
httpMethod: 'SYSTEM',
ipAddress: 'localhost',
dataSubjectId: c.customerId,
changesBefore: { status: 'ACTIVE' },
changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() },
});
} catch (err) {
console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err);
}
}
console.log('[ContractStatusScheduler] Fertig.');
}
export function startContractStatusScheduler(): void {
// Täglich um 02:00 Uhr (Server-Zeit)
cron.schedule('0 2 * * *', () => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Daily run failed:', err),
);
});
// Catch-up 60 Sekunden nach Start
setTimeout(() => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Catch-up run failed:', err),
);
}, 60_000);
console.log('[ContractStatusScheduler] Gestartet täglich um 02:00 + Catch-up nach 60s');
}
export { runExpireCheck };
/**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist:
* - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT)
* - Contract.startDate auf deliveryDate (oder heute) setzen, falls noch leer
*
* Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
* getrimmt zur Robustheit.
*/
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,
req: unknown,
deliveryDate?: Date | string | null,
): Promise<void> {
if (!documentType || typeof documentType !== 'string') return;
if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
select: { status: true, contractNumber: true, customerId: true, startDate: true },
});
if (!contract) return;
// deliveryDate parsen, Fallback auf heute
let parsedDate: Date | null = null;
if (deliveryDate) {
const parsed = new Date(deliveryDate);
if (!isNaN(parsed.getTime())) parsedDate = parsed;
}
const effectiveDate = parsedDate || new Date();
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (contract.status === 'DRAFT') {
updateData.status = 'ACTIVE';
changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' };
}
if (!contract.startDate) {
updateData.startDate = effectiveDate;
changes.startDate = { vorher: null, nachher: effectiveDate.toISOString().split('T')[0] };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({
where: { id: contractId },
data: updateData,
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`,
details: { ...changes, trigger: 'Lieferbestätigung-Upload' },
customerId: contract.customerId,
});
}
+8 -1
View File
@@ -22,10 +22,13 @@ export interface CustomerFilters {
type?: CustomerType; type?: CustomerType;
page?: number; page?: number;
limit?: number; limit?: number;
// Wenn gesetzt: nur Customer mit id in dieser Liste. Für Portal-User, damit
// weder Liste noch pagination.total die globale Kunden-Zahl preisgibt.
allowedIds?: number[];
} }
export async function getAllCustomers(filters: CustomerFilters) { export async function getAllCustomers(filters: CustomerFilters) {
const { search, type, page = 1, limit = 20 } = filters; const { search, type, page = 1, limit = 20, allowedIds } = filters;
const { skip, take } = paginate(page, limit); const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
@@ -34,6 +37,10 @@ export async function getAllCustomers(filters: CustomerFilters) {
where.type = type; where.type = type;
} }
if (allowedIds) {
where.id = { in: allowedIds };
}
if (search) { if (search) {
where.OR = [ where.OR = [
{ firstName: { contains: search } }, { firstName: { contains: search } },
@@ -469,6 +469,22 @@ export async function deprovisionEmail(localPart: string): Promise<EmailOperatio
} }
} }
// Weiterleitungsziele ersetzen (set:, nicht add:) nutzen wir, um nach einer
// Kunden-Email-Änderung die Forwards einer Stressfrei-Adresse auf den neuen
// Kunden-Inbox + unsere Service-Adresse zu setzen.
export async function setEmailForwardTargets(
localPart: string,
targets: string[],
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.updateForwardTargets(localPart, targets);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return { success: false, error: errorMessage };
}
}
// E-Mail umbenennen // E-Mail umbenennen
export async function renameProvisionedEmail( export async function renameProvisionedEmail(
oldLocalPart: string, oldLocalPart: string,
+272 -3
View File
@@ -1,15 +1,32 @@
/** /**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen. * Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen * Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, * nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien und PDF-Auftragsvorlagen. * Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
*/ */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import archiver from 'archiver'; import archiver from 'archiver';
import AdmZip from 'adm-zip';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören.
// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte
// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings.
export const FACTORY_DEFAULT_APP_SETTING_KEYS = [
'privacyPolicyHtml',
'authorizationTemplateHtml',
'imprintHtml',
'websitePrivacyPolicyHtml',
] as const;
export interface AppSettingExport {
key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number];
value: string;
}
export interface FactoryDefaultsManifest { export interface FactoryDefaultsManifest {
version: 1; version: 1;
exportedAt: string; exportedAt: string;
@@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest {
contractDurations: number; contractDurations: number;
contractCategories: number; contractCategories: number;
pdfTemplates: number; pdfTemplates: number;
appSettings: number;
}; };
} }
@@ -49,7 +67,7 @@ export interface PdfTemplateExport {
* Sammelt alle Katalog-Daten aus der DB. * Sammelt alle Katalog-Daten aus der DB.
*/ */
export async function collectFactoryDefaults() { export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] = const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
await Promise.all([ await Promise.all([
prisma.provider.findMany({ prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } }, include: { tariffs: { select: { name: true, isActive: true } } },
@@ -59,6 +77,11 @@ export async function collectFactoryDefaults() {
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }), prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }), prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }), prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
prisma.appSetting.findMany({
where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } },
select: { key: true, value: true },
orderBy: { key: 'asc' },
}),
]); ]);
return { return {
@@ -108,6 +131,7 @@ export async function collectFactoryDefaults() {
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'), pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
}; };
}), }),
appSettings: appSettings as AppSettingExport[],
}; };
} }
@@ -132,6 +156,7 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
contractDurations: data.contractDurations.length, contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length, contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length, pdfTemplates: data.pdfTemplates.length,
appSettings: data.appSettings.length,
}, },
}; };
@@ -160,6 +185,9 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
archive.append(JSON.stringify(data.pdfTemplates, null, 2), { archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
name: 'pdf-templates/pdf-templates.json', name: 'pdf-templates/pdf-templates.json',
}); });
archive.append(JSON.stringify(data.appSettings, null, 2), {
name: 'app-settings/app-settings.json',
});
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden) // PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
const uploadsRoot = path.join(process.cwd(), 'uploads'); const uploadsRoot = path.join(process.cwd(), 'uploads');
@@ -192,3 +220,244 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
})(); })();
}); });
} }
// ============================================================
// IMPORT
// ============================================================
export interface FactoryDefaultsImportResult {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
pdfTemplatesSkipped: number;
appSettings: number;
warnings: string[];
}
function parseJsonEntry<T>(zip: AdmZip, name: string): T[] {
const entry = zip.getEntry(name);
if (!entry) return [];
try {
const parsed = JSON.parse(entry.getData().toString('utf-8'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Wendet ein Factory-Defaults-ZIP idempotent auf die DB an.
* - upsert über unique-Keys: nichts wird gelöscht
* - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix
* - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS)
*
* Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu
* (`pdf-templates/<basename>`), niemals auf einen aus dem ZIP konstruierten
* Pfad im Filesystem.
*/
export async function importFactoryDefaults(
zipBuffer: Buffer,
): Promise<FactoryDefaultsImportResult> {
const zip = new AdmZip(zipBuffer);
const result: FactoryDefaultsImportResult = {
providers: 0,
tariffs: 0,
cancellationPeriods: 0,
contractDurations: 0,
contractCategories: 0,
pdfTemplates: 0,
pdfTemplatesSkipped: 0,
appSettings: 0,
warnings: [],
};
// --- Providers + Tariffs
const providers = parseJsonEntry<ProviderExport>(zip, 'providers/providers.json');
for (const p of providers) {
if (!p.name) continue;
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
create: {
name: p.name,
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
});
result.providers++;
for (const t of p.tariffs ?? []) {
if (!t.name) continue;
await prisma.tariff.upsert({
where: { providerId_name: { providerId: provider.id, name: t.name } },
update: { isActive: t.isActive ?? true },
create: { providerId: provider.id, name: t.name, isActive: t.isActive ?? true },
});
result.tariffs++;
}
}
// --- Contract-Meta
const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
zip,
'contract-meta/cancellation-periods.json',
);
for (const c of cancellationPeriods) {
if (!c.code || !c.description) continue;
await prisma.cancellationPeriod.upsert({
where: { code: c.code },
update: { description: c.description, isActive: c.isActive ?? true },
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
});
result.cancellationPeriods++;
}
const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
zip,
'contract-meta/contract-durations.json',
);
for (const d of contractDurations) {
if (!d.code || !d.description) continue;
await prisma.contractDuration.upsert({
where: { code: d.code },
update: { description: d.description, isActive: d.isActive ?? true },
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
});
result.contractDurations++;
}
const contractCategories = parseJsonEntry<{
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}>(zip, 'contract-meta/contract-categories.json');
for (const c of contractCategories) {
if (!c.code || !c.name) continue;
await prisma.contractCategory.upsert({
where: { code: c.code },
update: {
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
create: {
code: c.code,
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
});
result.contractCategories++;
}
// --- PDF-Vorlagen (JSON + binär aus dem ZIP)
const pdfTemplates = parseJsonEntry<PdfTemplateExport>(
zip,
'pdf-templates/pdf-templates.json',
);
if (pdfTemplates.length > 0) {
const uploadsRoot = path.join(process.cwd(), 'uploads');
const pdfDestDir = path.join(uploadsRoot, 'pdf-templates');
if (!fs.existsSync(pdfDestDir)) {
fs.mkdirSync(pdfDestDir, { recursive: true });
}
for (const t of pdfTemplates) {
if (!t.name || !t.pdfFilename) continue;
// Anti-Zip-Slip: nur basename verwenden, kein Pfad
const basename = path.basename(t.pdfFilename);
const entry = zip.getEntry(`pdf-templates/${basename}`);
if (!entry) {
result.pdfTemplatesSkipped++;
result.warnings.push(`PDF fehlt im ZIP: ${basename} Vorlage "${t.name}" übersprungen`);
continue;
}
const ext = path.extname(t.originalName || basename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-');
const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`;
const destPdf = path.join(pdfDestDir, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.writeFileSync(destPdf, entry.getData());
// Bei existierender Vorlage die alte Datei aufräumen
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
if (existing?.templatePath) {
const oldRel = existing.templatePath.startsWith('/uploads/')
? existing.templatePath.substring('/uploads/'.length)
: existing.templatePath;
const oldAbs = path.join(uploadsRoot, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {});
await prisma.pdfTemplate.upsert({
where: { name: t.name },
update: {
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
create: {
name: t.name,
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
});
result.pdfTemplates++;
}
}
// --- AppSettings (HTML-Templates, Whitelist)
const appSettings = parseJsonEntry<AppSettingExport>(zip, 'app-settings/app-settings.json');
const allowedKeys = new Set<string>(FACTORY_DEFAULT_APP_SETTING_KEYS);
for (const s of appSettings) {
if (!s.key || typeof s.value !== 'string') continue;
if (!allowedKeys.has(s.key)) {
result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist ignoriert`);
continue;
}
await prisma.appSetting.upsert({
where: { key: s.key },
update: { value: s.value },
create: { key: s.key, value: s.value },
});
result.appSettings++;
}
return result;
}
@@ -0,0 +1,126 @@
/**
* Pfad Resource Owner Mapping für `/api/files/download`.
*
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
* canAccessContract entscheidet danach über Zugriff.
*
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
* automatisch ausgeschlossen ein konstruierter Pfad findet keinen Record.
*/
import prisma from '../lib/prisma.js';
export type FileOwner =
| { kind: 'customer'; customerId: number }
| { kind: 'contract'; contractId: number }
| { kind: 'admin' }
| { kind: 'gdpr-admin' };
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
if (!uploadPath.startsWith('/uploads/')) return null;
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
const parts = uploadPath.split('/');
// ['', 'uploads', '<subDir>', '<filename...>']
if (parts.length < 4) return null;
const subDir = parts[2];
switch (subDir) {
case 'bank-cards': {
const r = await prisma.bankCard.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'documents': {
const r = await prisma.identityDocument.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'business-registrations': {
const r = await prisma.customer.findFirst({
where: { businessRegistrationPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'commercial-registers': {
const r = await prisma.customer.findFirst({
where: { commercialRegisterPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'privacy-policies': {
const r = await prisma.customer.findFirst({
where: { privacyPolicyPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'authorizations': {
const r = await prisma.representativeAuthorization.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'contract-documents': {
const r = await prisma.contractDocument.findFirst({
where: { documentPath: uploadPath },
select: { contractId: true },
});
return r ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'invoices': {
const r = await prisma.invoice.findFirst({
where: { documentPath: uploadPath },
select: { contractId: true },
});
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'cancellation-letters':
case 'cancellation-confirmations':
case 'cancellation-letters-options':
case 'cancellation-confirmations-options': {
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
'cancellation-letters': 'cancellationLetterPath',
'cancellation-confirmations': 'cancellationConfirmationPath',
'cancellation-letters-options': 'cancellationLetterOptionsPath',
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
};
const field = fieldMap[subDir];
const r = await prisma.contract.findFirst({
where: { [field]: uploadPath },
select: { id: true },
});
return r ? { kind: 'contract', contractId: r.id } : null;
}
case 'pdf-templates': {
// Admin-only Resource: Vorlagen gehören keinem Customer.
const r = await prisma.pdfTemplate.findFirst({
where: { templatePath: uploadPath },
select: { id: true },
});
return r ? { kind: 'admin' } : null;
}
default:
return null;
}
}
+9
View File
@@ -14,6 +14,9 @@ export interface ImapCredentials {
password: string; password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL) encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
servername?: string;
} }
/** /**
@@ -29,6 +32,12 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
const rejectUnauthorized = !credentials.allowSelfSignedCerts; const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const options: Record<string, unknown> = { rejectUnauthorized }; const options: Record<string, unknown> = { rejectUnauthorized };
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
if (credentials.servername) {
options.servername = credentials.servername;
}
if (credentials.allowSelfSignedCerts) { if (credentials.allowSelfSignedCerts) {
options.minVersion = 'TLSv1'; options.minVersion = 'TLSv1';
options.ciphers = 'DEFAULT:@SECLEVEL=0'; options.ciphers = 'DEFAULT:@SECLEVEL=0';
@@ -0,0 +1,289 @@
/**
* Security-Alerting:
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
* Cron alle 60s gepollt) z.B. Threshold-Überschreitungen.
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
* Events wenn die Schwelle erreicht ist.
*
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import * as appSettingService from './appSetting.service.js';
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
import type { SecurityEvent } from '@prisma/client';
interface AlertEmailParams {
subject: string;
events: SecurityEvent[];
isDigest: boolean;
}
interface SendResult {
success: boolean;
error?: string;
}
function severityIcon(s: string): string {
switch (s) {
case 'CRITICAL': return '🚨';
case 'HIGH': return '⚠️';
case 'MEDIUM': return '🟡';
case 'LOW': return '🟢';
default: return '️';
}
}
function eventToHtmlRow(e: SecurityEvent): string {
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
const ip = e.ipAddress || '';
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '');
const ep = e.endpoint || '';
return `<tr>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
<td style="padding:4px 8px">${e.type}</td>
<td style="padding:4px 8px">${e.message}</td>
<td style="padding:4px 8px">${who}</td>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
</tr>`;
}
function buildHtmlEmail(params: AlertEmailParams): string {
const rows = params.events.map(eventToHtmlRow).join('\n');
const heading = params.isDigest
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
${heading}
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
<thead style="background:#f3f4f6">
<tr>
<th align="left" style="padding:6px 8px">Zeit</th>
<th align="left" style="padding:6px 8px">Severity</th>
<th align="left" style="padding:6px 8px">Typ</th>
<th align="left" style="padding:6px 8px">Nachricht</th>
<th align="left" style="padding:6px 8px">Wer</th>
<th align="left" style="padding:6px 8px">IP</th>
<th align="left" style="padding:6px 8px">Endpoint</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
Konfiguration: Einstellungen Monitoring.</p>
</body></html>`;
}
/**
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
*/
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
const sysEmail = await getSystemEmailCredentials();
if (!sysEmail) {
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
}
const credentials: SmtpCredentials = {
host: sysEmail.smtpServer,
port: sysEmail.smtpPort,
user: sysEmail.emailAddress,
password: sysEmail.password,
encryption: sysEmail.smtpEncryption,
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
};
const result = await sendEmail(
credentials,
sysEmail.emailAddress,
{
to: toAddress,
subject: params.subject,
html: buildHtmlEmail(params),
},
{ context: 'security-alert', triggeredBy: 'monitor' },
);
return result.success
? { success: true }
: { success: false, error: result.error };
}
/**
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
*
* Regeln (alle pro 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
*/
export async function detectThresholds(): Promise<void> {
const now = new Date();
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
type Bucket = {
windowStart: Date;
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
threshold: number;
label: string;
};
const buckets: Bucket[] = [
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
];
for (const b of buckets) {
const grouped = await prisma.securityEvent.groupBy({
by: ['ipAddress'],
where: {
type: b.type as any,
createdAt: { gte: b.windowStart },
},
_count: true,
});
for (const g of grouped) {
if ((g._count as number) < b.threshold) continue;
// Debounce: pro IP max. 1 SUSPICIOUS-Alert pro 60min (sliding window).
// Vorher: floor(now, hour) → resettete bei Stundenwechsel und produzierte
// doppelte Alerts (Bug aus Runde 10).
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const existing = await prisma.securityEvent.findFirst({
where: {
type: 'SUSPICIOUS',
severity: 'CRITICAL',
ipAddress: g.ipAddress,
createdAt: { gte: oneHourAgo },
},
});
if (existing) continue;
await emitSecurityEvent({
type: 'SUSPICIOUS',
severity: 'CRITICAL',
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
ipAddress: g.ipAddress,
details: { rule: b.type, count: g._count, threshold: b.threshold },
});
}
}
}
/**
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
* 1 Mail pro IP pro Stunde, damit nicht spammend).
*/
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) return { sent: 0, skipped: 0 };
const pending = await prisma.securityEvent.findMany({
where: { severity: 'CRITICAL', alerted: false },
orderBy: { createdAt: 'asc' },
take: 50,
});
let sent = 0;
let skipped = 0;
for (const ev of pending) {
const result = await sendAlertEmail(alertEmail, {
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
events: [ev],
isDigest: false,
});
if (result.success) {
sent++;
await prisma.securityEvent.update({
where: { id: ev.id },
data: { alerted: true, alertedAt: new Date() },
});
} else {
skipped++;
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
}
}
return { sent, skipped };
}
/**
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
*/
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
const events = await prisma.securityEvent.findMany({
where: {
severity: { in: ['HIGH', 'MEDIUM'] },
alerted: false,
createdAt: { gte: since },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
if (events.length === 0) {
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
}
const result = await sendAlertEmail(alertEmail, {
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
events,
isDigest: true,
});
if (result.success) {
await prisma.securityEvent.updateMany({
where: { id: { in: events.map((e) => e.id) } },
data: { alerted: true, alertedAt: new Date() },
});
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
return { sent: true, eventCount: events.length };
}
return { sent: false, eventCount: events.length, reason: result.error };
}
/**
* Cron-Scheduler:
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
*/
export function startSecurityMonitorScheduler(): void {
cron.schedule('* * * * *', async () => {
try {
await detectThresholds();
await sendPendingCriticalAlerts();
} catch (err) {
console.error('[securityAlert] minute-cron failed:', err);
}
});
cron.schedule('0 * * * *', async () => {
try {
await sendDigest();
} catch (err) {
console.error('[securityAlert] hourly-digest failed:', err);
}
});
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
}
@@ -0,0 +1,81 @@
/**
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
*
* Hooks für die wichtigsten Klassen:
* - LOGIN_FAILED Login mit falschem Passwort
* - LOGIN_SUCCESS erfolgreicher Login (informativ)
* - RATE_LIMIT_HIT express-rate-limit hat zugeschlagen
* - ACCESS_DENIED 403 von canAccess* (versuchter IDOR)
* - SSRF_BLOCKED ssrfGuard hat geblockt
* - PASSWORD_RESET_REQUEST Reset angefordert
* - PASSWORD_RESET_CONFIRM Reset abgeschlossen
* - LOGOUT expliziter Logout
* - TOKEN_REJECTED JWT verify-Failure
* - PERMISSION_CHANGED Rolle/Permission-Update
*
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
* konfiguriert), sonst Sammlung im stündlichen Digest.
*/
import prisma from '../lib/prisma.js';
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
export interface SecurityEventInput {
type: SecurityEventType;
severity: SecuritySeverity;
message: string;
ipAddress?: string | null;
userId?: number | null;
customerId?: number | null;
userEmail?: string | null;
endpoint?: string | null;
details?: Record<string, unknown>;
}
/**
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
*/
export async function emit(event: SecurityEventInput): Promise<void> {
try {
await prisma.securityEvent.create({
data: {
type: event.type,
severity: event.severity,
message: event.message,
ipAddress: event.ipAddress || null,
userId: event.userId || null,
customerId: event.customerId || null,
userEmail: event.userEmail || null,
endpoint: event.endpoint || null,
details: event.details ? (event.details as any) : undefined,
},
});
} catch (err) {
console.error('[securityMonitor] emit failed:', err);
}
}
/**
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
* Requests (Login-Versuch etc.).
*/
export function contextFromRequest(req: any): {
ipAddress: string;
userId?: number;
customerId?: number;
userEmail?: string;
endpoint: string;
} {
const user = req?.user;
return {
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
userId: user?.userId,
customerId: user?.customerId,
userEmail: user?.email,
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
};
}

Some files were not shown because too many files have changed in this diff Show More