Compare commits

...

86 Commits

Author SHA1 Message Date
duffyduck 771f46d2ac Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Input
Feature 1 – Kunden-Schnellansicht:
Info-Icon neben dem Kundenlink im Vertragsdetail oeffnet ein Modal
mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
Gruendungsdatum, Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail,
Steuer-/Handelsregisternr). Jedes Feld hat einen Copy-Button.
Lazy-Fetch via customerApi.getById, staleTime 30s.

Feature 2 – Cent/Euro-Doppel-Input:
Neben dem €/kWh-Arbeitspreis-Feld jetzt ein zweites ct/kWh-Feld.
Bidirektional gekoppelt – Tippen in € aktualisiert ct (×100),
Tippen in ct aktualisiert € (÷100). Backend speichert weiterhin
nur den Euro-Wert; Cent ist reine UI-Hilfe. Float-Rausch-Schutz
verhindert "0.25 → 25.0000000000004". Greift fuer unitPrice und
unitPriceNt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:41:37 +02:00
duffyduck 20d42c5270 Energie-Bonus aufgeteilt in Sofort + Neukunden
EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas-
Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel
+ Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden
müssen.

Migration 20260524100000_split_energy_bonus:
- ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus
- bestehende `bonus`-Werte → instantBonus (Annahme: Sofort)
- DROP COLUMN IF EXISTS bonus

UI:
- ContractForm zeigt zwei Input-Felder
- Detail-Ansicht zeigt beide einzeln + Gesamtbonus
- Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive
  Jahreskosten

Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation
liefert instantBonus, newCustomerBonus, totalBonus.

PDF-Template: drei neue Variablen energyDetails.instantBonus,
.newCustomerBonus, .totalBonus.

Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert,
GET liefert zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:27:54 +02:00
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
136 changed files with 9809 additions and 1587 deletions
+9
View File
@@ -46,6 +46,15 @@ backups
backend/uploads
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)
*.db
*.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
+350 -68
View File
@@ -41,38 +41,70 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Backend**: Node.js, Express 4.x, TypeScript
- **Datenbank**: MariaDB
- **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:
> - Express 4.x → `@types/express@^4.17.x`
> - 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
- Node.js 18+ (empfohlen: 20+)
- Docker & Docker Compose
- npm
- Docker & Docker Compose v2
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
## Installation
## Installation für Entwicklung (ohne Container)
### 1. Repository klonen
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Konfiguration anpassen
```
### 2. MariaDB-Datenbank starten
### 2. MariaDB-Container starten
```bash
docker-compose up -d
docker compose up -d db
```
Dies startet einen MariaDB-Container mit:
- **Port:** 3306
- **Datenbank:** opencrm
- **Root-Passwort:** rootpassword
- **Benutzer:** opencrm / opencrm123
Das startet nur die Datenbank (mit Daten in `./data/db/`).
Konfiguration kommt aus `./.env`:
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
@@ -94,9 +126,14 @@ Die `.env`-Datei sollte folgende Werte enthalten:
# Database
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_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_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -173,10 +210,36 @@ LISTEN_ADDR=127.0.0.1
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.
@@ -185,6 +248,138 @@ Plus:
- 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
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
@@ -1036,8 +1231,9 @@ Folgende Felder werden in Audit-Logs gefiltert:
## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups:
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
zu Datenbank-Backups:
### Abgrenzung
@@ -1045,64 +1241,117 @@ OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backup
|---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ |
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
| 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
2. Übersicht prüfen (Anzahl pro Kategorie)
3. Button **„Factory-Defaults exportieren"** klicken
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
2. Button **„Factory-Defaults exportieren"** klicken
3. 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:**
```
factory-defaults-2026-04-23.zip
factory-defaults-2026-05-07-1949.zip
├── manifest.json # Version + Datum + Counts
├── providers/
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
├── providers/providers.json
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung)
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung)
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...)
── pdf-templates/
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen
└── *.pdf # Die eigentlichen PDF-Dateien
│ ├── cancellation-periods.json
│ ├── contract-durations.json
│ └── contract-categories.json
── pdf-templates/
├── pdf-templates.json
└── *.pdf # Die eigentlichen PDF-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates (Whitelist-only)
```
Die ZIP kann an andere Installationen weitergegeben werden
(Partner, Test-System, neue Installation).
### Import
### 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
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
3. Im Backend-Verzeichnis ausführen:
```bash
npm run seed:defaults
```
**Variante B CLI:**
```bash
./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:**
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
✓ Anbieter: 10
✓ Tarife: 4
✓ Kündigungsfristen: 18
✓ Laufzeiten: 18
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 2
✓ HTML-Templates: 2
```
### Mehrere ZIPs kombinieren
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen
JSON-Dateien werden automatisch gemerged:
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
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/
@@ -1112,40 +1361,73 @@ backend/factory-defaults/
eigene.json # 5 eigene Anbieter
```
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch.
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
nimmt nur eine ZIP entgegen für Merges nutze `npm run seed:defaults`.
### Idempotenz
Das Script nutzt ausschließlich Prisma `upsert`:
Alle Pfade nutzen Prisma `upsert`:
- **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
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.
### PDF-Dateien beim Import
### PDF-Dateien
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt.
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue
ersetzt.
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
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
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
| Factory-Defaults Export (UI/CLI) | `settings:update` |
| 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
gepflegtem Anbieter- und Vorlagenkatalog loslegen
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
(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
(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
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
+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_URL="mysql://user:password@localhost:3306/opencrm"
# JWT
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_KEY="32-byte-hex-key-for-aes-256-gcm"
+2 -1
View File
@@ -4,10 +4,11 @@ node_modules/
# Build
dist/
# Environment
# Environment echte Secrets blocken, .env.example weiter mittracken
.env
.env.local
.env.*.local
!.env.example
# Database Backups (can be large, keep folder structure)
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
│ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
── pdf-templates/
├── pdf-templates.json # Metadaten + Feldzuordnungen
└── *.pdf # PDF-Vorlagen-Dateien
── pdf-templates/
├── pdf-templates.json # Metadaten + Feldzuordnungen
└── *.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,
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).
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)
@@ -46,7 +52,8 @@ factory-defaults-2026-04-23.zip
├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.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,
@@ -56,7 +63,15 @@ neue Installationen oder Partner-Setups.
## 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)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
@@ -234,6 +249,24 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
**Unique Key:** `name`
**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
@@ -242,6 +275,7 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
---
+66 -52
View File
@@ -1,19 +1,22 @@
{
"name": "opencrm-backend",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencrm-backend",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
@@ -26,6 +29,7 @@
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -42,7 +46,6 @@
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
},
@@ -53,7 +56,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@@ -69,7 +71,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -85,7 +86,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -101,7 +101,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -117,7 +116,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -133,7 +131,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -149,7 +146,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -165,7 +161,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -181,7 +176,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -197,7 +191,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -213,7 +206,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -229,7 +221,6 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -245,7 +236,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -261,7 +251,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -277,7 +266,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -293,7 +281,6 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -309,7 +296,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -325,7 +311,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -341,7 +326,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -357,7 +341,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -373,7 +356,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -389,7 +371,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
@@ -405,7 +386,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@@ -421,7 +401,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -437,7 +416,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -453,7 +431,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -633,7 +610,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
@@ -643,11 +619,19 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": {
"@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": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -661,7 +645,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -673,7 +656,6 @@
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
@@ -684,8 +666,7 @@
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
@@ -722,8 +703,7 @@
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"node_modules/@types/ms": {
"version": "2.1.0",
@@ -744,7 +724,6 @@
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -777,14 +756,12 @@
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
},
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
@@ -799,7 +776,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
@@ -808,7 +784,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
@@ -819,7 +794,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
@@ -1277,6 +1251,25 @@
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -1463,6 +1456,33 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1557,7 +1577,6 @@
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@@ -1784,7 +1803,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -1841,7 +1859,6 @@
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@@ -2867,7 +2884,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
@@ -3315,7 +3331,6 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3377,8 +3392,7 @@
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/unicode-properties": {
"version": "1.4.1",
+6 -2
View File
@@ -4,7 +4,7 @@
"description": "OpenCRM Backend API",
"main": "dist/index.js",
"prisma": {
"seed": "tsx prisma/seed.ts"
"seed": "npx tsx prisma/seed.ts"
},
"scripts": {
"dev": "tsx watch src/index.ts",
@@ -12,6 +12,7 @@
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"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:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
@@ -20,11 +21,14 @@
},
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
@@ -37,6 +41,7 @@
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -53,7 +58,6 @@
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"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;
@@ -0,0 +1,30 @@
-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen.
-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei
-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus).
-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
-- `prisma db push` gefahren hat.
ALTER TABLE `EnergyContractDetails`
ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL,
ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL;
-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'EnergyContractDetails'
AND COLUMN_NAME = 'bonus'
);
SET @sql := IF(
@col_exists > 0,
'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL',
'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alte Spalte droppen, falls vorhanden
ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`;
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
provider = "mysql"
+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();
});
+39 -1
View File
@@ -172,6 +172,10 @@ model Customer {
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
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)
lastBirthdayGreetingYear Int?
@@ -801,7 +805,14 @@ model EnergyContractDetails {
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus Float?
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
// dargestellt. Migration kopiert das alte `bonus` nach
// `instantBonus` (Annahme: meistgenutzte Variante).
instantBonus Float? // Sofort-Bonus
newCustomerBonus Float? // Neukunden-Bonus
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
@@ -1142,6 +1153,33 @@ enum SecuritySeverity {
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
+48 -3
View File
@@ -221,8 +221,41 @@ async function main() {
console.log('Roles created');
// Create admin user
const hashedPassword = await bcrypt.hash('admin', 10);
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// 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({
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
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 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 PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
@@ -61,6 +65,19 @@ interface PdfTemplateDef {
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.
*/
@@ -299,6 +316,31 @@ async function seedPdfTemplates() {
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() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
@@ -313,6 +355,7 @@ async function main() {
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
await seedAppSettings();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
@@ -41,10 +41,22 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
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
const before = await prisma.appSetting.findUnique({ where: { key } });
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);
@@ -78,12 +90,24 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
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
const changes: Record<string, { von: unknown; nach: unknown }> = {};
for (const [key, value] of Object.entries(settings)) {
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
if (oldValue !== newValue) {
changes[key] = { von: oldValue, nach: newValue };
}
+192 -7
View File
@@ -1,8 +1,51 @@
import { Request, Response } from 'express';
import { Request, Response, CookieOptions } from 'express';
import * as authService from '../services/auth.service.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
export async function login(req: Request, res: Response): Promise<void> {
@@ -18,6 +61,9 @@ export async function login(req: Request, res: Response): Promise<void> {
}
const result = await authService.login(email, password);
// 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',
@@ -27,7 +73,10 @@ export async function login(req: Request, res: Response): Promise<void> {
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
@@ -39,7 +88,7 @@ export async function login(req: Request, res: Response): Promise<void> {
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
@@ -58,6 +107,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
}
const result = await authService.customerLogin(email, password);
setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
@@ -67,7 +117,10 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
@@ -79,7 +132,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
@@ -191,10 +244,15 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
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({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -257,6 +315,10 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
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',
@@ -277,6 +339,36 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
}
}
// 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> {
try {
const { email, password, firstName, lastName, roleIds } = req.body;
@@ -289,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
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({
email,
password,
@@ -308,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
} 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);
}
}
+258 -9
View File
@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
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
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
}
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
* GET /api/settings/backups
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
* POST /api/settings/backup
*/
export async function createBackup(req: Request, res: Response) {
const start = Date.now();
const capture = startLogCapture();
try {
const result = await backupService.createBackup();
const durationMs = Date.now() - start;
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({
req, action: 'CREATE', resourceType: 'Backup',
label: `Backup ${result.backupName} erstellt`,
});
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
} 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 });
}
} 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 });
}
}
@@ -50,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
* Backup wiederherstellen
* 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) {
const start = Date.now();
const { name } = req.params;
if (!name || !isValidBackupName(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 { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.restoreBackup(name);
const durationMs = Date.now() - start;
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({
req, action: 'UPDATE', resourceType: 'Backup',
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`,
});
} 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) {
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) {
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();
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 });
}
} 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) {
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 future = req.query.future ? parseInt(String(req.query.future)) : 30;
@@ -8,7 +8,8 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.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 { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
@@ -16,15 +17,28 @@ import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import {
canAccessCustomer,
canAccessContract,
canAccessCachedEmail,
canAccessStressfreiEmail,
} from '../utils/accessControl.js';
// ==================== 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
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -42,6 +56,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
limit,
offset,
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);
@@ -113,9 +138,10 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const { isRead } = req.body;
if (isRead) {
@@ -135,9 +161,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const isStarred = await cachedEmailService.toggleEmailStar(id);
res.json({ success: true, data: { isStarred } } as ApiResponse);
@@ -153,10 +180,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
// ==================== CONTRACT ASSIGNMENT ====================
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { contractId } = req.body;
if (!(await canAccessContract(req, res, contractId))) return;
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
@@ -172,9 +201,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
}
// Vertragszuordnung aufheben
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const email = await cachedEmailService.unassignEmailFromContract(emailId);
@@ -189,9 +219,10 @@ export async function unassignFromContract(req: Request, res: Response): Promise
}
// 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 {
const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
@@ -206,9 +237,10 @@ export async function getFolderCounts(req: Request, res: Response): Promise<void
}
// 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 {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
@@ -225,9 +257,10 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
// ==================== SYNC & SEND ====================
// 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 {
const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const fullSync = req.query.full === 'true';
// Synchronisiert sowohl INBOX als auch SENT
@@ -267,9 +300,10 @@ function hasCRLF(value: unknown): boolean {
}
// E-Mail senden
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try {
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;
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
@@ -582,9 +616,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
// ==================== MAILBOX ACCOUNTS ====================
// 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 {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
@@ -599,9 +634,10 @@ export async function getMailboxAccounts(req: Request, res: Response): Promise<v
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.enableMailbox(id);
@@ -624,9 +660,10 @@ export async function enableMailbox(req: Request, res: Response): Promise<void>
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.syncMailboxStatus(id);
@@ -655,9 +692,10 @@ export async function syncMailboxStatus(req: Request, res: Response): Promise<vo
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const thread = await cachedEmailService.getEmailThread(id);
@@ -672,9 +710,13 @@ export async function getThread(req: Request, res: Response): Promise<void> {
}
// 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 {
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
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
@@ -709,6 +751,15 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
// IMAP/SMTP-Einstellungen laden
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({
success: true,
data: {
@@ -736,7 +787,7 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
}
// 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 {
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
@@ -744,8 +795,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
let count = 0;
if (customerId) {
if (!(await canAccessCustomer(req, res, customerId))) return;
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
} else if (contractId) {
if (!(await canAccessContract(req, res, contractId))) return;
count = await cachedEmailService.getUnreadCountForContract(contractId);
}
@@ -760,9 +813,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
// Prüfen ob E-Mail existiert
const email = await cachedEmailService.getCachedEmailById(id);
@@ -797,9 +851,10 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
// ==================== TRASH OPERATIONS ====================
// 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 {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const emails = await cachedEmailService.getTrashEmails(customerId);
@@ -814,9 +869,10 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
}
// 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 {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const count = await cachedEmailService.getTrashCount(customerId);
@@ -831,9 +887,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.restoreEmailFromTrash(id);
@@ -856,9 +913,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
}
// 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 {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.permanentDeleteEmail(id);
@@ -883,9 +941,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
// ==================== ATTACHMENT TARGETS ====================
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail mit StressfreiEmail laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -1065,9 +1124,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
}
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { entityType, entityId, targetKey } = req.body;
@@ -1352,9 +1412,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
// ==================== SAVE EMAIL AS PDF ====================
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { entityType, entityId, targetKey } = req.body;
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
@@ -1599,9 +1660,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
// ==================== SAVE EMAIL AS INVOICE ====================
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
@@ -1725,9 +1787,10 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// ==================== SAVE ATTACHMENT AS INVOICE ====================
// 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { invoiceDate, invoiceType, notes } = req.body;
@@ -1888,9 +1951,10 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* 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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body;
@@ -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) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
+147 -9
View File
@@ -6,6 +6,7 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
@@ -46,9 +47,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page 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({
success: true,
data: result.contracts,
data,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
@@ -89,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) {
res.status(500).json({
success: false,
@@ -98,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 {
// 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);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
@@ -107,7 +129,9 @@ export async function createContract(req: Request, res: Response): Promise<void>
label: `Vertrag ${contract.contractNumber} angelegt`,
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) {
res.status(400).json({
success: false,
@@ -140,7 +164,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
};
const energyLabels: Record<string, string> = {
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
};
// Hauptfelder vergleichen
@@ -179,7 +204,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
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) {
res.status(400).json({
success: false,
@@ -211,6 +242,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
// Vorgängervertrag laden für Vertragsnummer
const previousContract = await prisma.contract.findUnique({
@@ -247,7 +279,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
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) {
res.status(400).json({
success: false,
@@ -256,6 +290,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> {
try {
const contractId = parseInt(req.params.id);
@@ -269,6 +358,14 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
} as ApiResponse);
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);
} catch (error) {
res.status(500).json({
@@ -293,6 +390,14 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
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);
} catch (error) {
res.status(500).json({
@@ -308,6 +413,14 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
if (!(await canAccessContract(req, res, contractId))) return;
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);
} catch (error) {
res.status(500).json({
@@ -332,6 +445,14 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
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);
} catch (error) {
res.status(500).json({
@@ -345,7 +466,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
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);
} catch (error) {
console.error('Cockpit error:', error);
@@ -427,6 +563,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
try {
const contractMeterId = parseInt(req.params.contractMeterId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
await logChange({
req, action: 'DELETE', resourceType: 'ContractMeter',
@@ -550,9 +687,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
// ==================== 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 {
const id = parseInt(req.params.id);
if (!(await canAccessContract(req, res, id))) return;
const { nextReviewDate, months } = req.body;
let reviewDate: Date | null = null;
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entries = await contractHistoryService.getHistoryEntries(contractId);
res.json({ success: true, data: entries } as ApiResponse);
} 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> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const { title, description } = req.body;
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> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId);
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> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.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 { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
// ==================== ALL TASKS (Dashboard & Task List) ====================
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
try {
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 customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
// 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);
customerPortalEmails = customers
.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({
status: status as 'OPEN' | 'COMPLETED' | undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
customerId: customerIdNum,
customerPortalCustomerIds,
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> {
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 customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.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 { status } = req.query;
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
// hasAuthorization (Pentest Runde 6 HOCH-04: widerrufene Vollmachten
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
// konsultiert wurde, ohne Status-Check).
if (!(await canAccessContract(req, res, contractId))) return;
// Für Kundenportal: Zugriffsprüfung
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
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedIds = await getPortalAllowedCustomerIds(req);
if (allowedIds) {
const customers = await customerService.getCustomersByIds(allowedIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
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;
}
}
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
if (!(await canAccessContract(req, res, contractId))) return;
const createdBy = req.user?.email;
@@ -376,24 +353,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.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);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Anfrage',
} as ApiResponse);
return;
}
} else {
if (!req.user?.isCustomerPortal || !req.user.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
@@ -401,6 +361,27 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
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 customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Anfrage',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
const subtask = await contractTaskService.createSubtask({
+225 -39
View File
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
@@ -10,6 +11,7 @@ import {
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
isValidEmail,
} from '../utils/sanitize.js';
import {
canAccessMeter,
@@ -17,30 +19,28 @@ import {
canAccessBankCard,
canAccessIdentityDocument,
canAccessCustomer,
getPortalAllowedCustomerIds,
} from '../utils/accessControl.js';
// Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
try {
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({
search: search as string,
type: type as 'PRIVATE' | 'BUSINESS',
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
allowedIds: allowedIds ?? undefined,
});
let customers = result.customers as any[];
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
// an einen einzelnen Portal-Account preisgeben.
if (req.user?.isCustomerPortal) {
const allowedIds = new Set<number>();
if (req.user.customerId) allowedIds.add(req.user.customerId);
const represented = (req.user as any).representedCustomerIds || [];
for (const id of represented) allowedIds.add(id);
customers = customers.filter((c) => allowedIds.has(c.id));
}
const customers = result.customers as any[];
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
@@ -80,6 +80,16 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
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
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -91,7 +101,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
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) {
res.status(400).json({
success: false,
@@ -104,6 +121,15 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
try {
const customerId = parseInt(req.params.id);
// 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);
// Vorherigen Stand laden für Audit
@@ -170,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) {
console.error('Update customer error:', error);
res.status(400).json({
@@ -232,9 +265,10 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
}
}
export async function updateAddress(req: Request, res: Response): Promise<void> {
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -295,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 {
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 customerId = addr?.customerId;
await customerService.deleteAddress(addressId);
@@ -349,9 +384,10 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
}
}
export async function updateBankCard(req: Request, res: Response): Promise<void> {
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -407,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 {
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 customerId = card?.customerId;
await customerService.deleteBankCard(cardId);
@@ -461,9 +498,10 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
}
}
export async function updateDocument(req: Request, res: Response): Promise<void> {
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -525,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 {
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 customerId = doc?.customerId;
await customerService.deleteDocument(docId);
@@ -579,9 +618,10 @@ export async function createMeter(req: AuthRequest, 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 {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -636,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 {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
await customerService.deleteMeter(meterId);
await logChange({
req, action: 'DELETE', resourceType: 'Meter',
@@ -666,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 {
const { readingDate, value, valueNt, unit, notes } = req.body;
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const reading = await customerService.addMeterReading(meterId, {
readingDate: new Date(readingDate),
value: parseFloat(value),
@@ -702,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 {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData: Record<string, unknown> = {};
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
@@ -713,7 +757,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
if (notes !== undefined) updateData.notes = notes;
const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId),
meterId,
parseInt(req.params.readingId),
updateData as any
);
@@ -731,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 {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
await customerService.deleteMeterReading(
parseInt(req.params.meterId),
readingId
);
await customerService.deleteMeterReading(meterId, readingId);
await logChange({
req, action: 'DELETE', resourceType: 'MeterReading',
resourceId: readingId.toString(),
@@ -838,6 +881,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
const reading = await prisma.meterReading.update({
@@ -897,7 +941,27 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try {
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
const before = await prisma.customer.findUnique({
@@ -957,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> {
try {
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({
success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -984,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 {
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);
} catch (error) {
res.status(500).json({
@@ -1013,9 +1192,10 @@ export async function getRepresentatives(req: AuthRequest, res: Response): Promi
}
}
export async function addRepresentative(req: Request, res: Response): Promise<void> {
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { representativeId, notes } = req.body;
const representative = await customerService.addRepresentative(
customerId,
@@ -1037,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 {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
await customerService.removeRepresentative(
customerId,
parseInt(req.params.representativeId)
@@ -1058,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 {
// 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;
if (!search || typeof search !== 'string' || search.length < 2) {
res.json({ success: true, data: [] } as ApiResponse);
@@ -1067,7 +1253,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
}
const customers = await customerService.searchCustomersForRepresentative(
search,
parseInt(req.params.customerId)
customerId,
);
res.json({ success: true, data: customers } as ApiResponse);
} catch (error) {
@@ -54,6 +54,7 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.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' });
}
}
/**
* 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',
});
}
}
@@ -78,7 +78,21 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
return;
}
// Content-Type aus Extension bestimmen (konservativ Express macht das eh)
// 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);
}
+94 -18
View File
@@ -13,6 +13,7 @@ import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js';
import { stripHtml } from '../utils/sanitize.js';
/**
* Kundendaten exportieren (DSGVO Art. 15)
@@ -269,7 +270,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
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
if (!(req.user as any)?.isCustomerPortal) {
@@ -279,17 +287,9 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
});
}
// Portal: nur eigene + vertretene Kunden
const allowed = [
(req.user as any).customerId,
...((req.user as any).representedCustomerIds || []),
];
if (!allowed.includes(customerId)) {
return res.status(403).json({
success: false,
error: 'Keine Berechtigung für diesen Kunden',
});
}
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
// widerrufene Vollmachten hatten vorher noch Zugriff)
if (!(await canAccessCustomer(req, res, customerId))) return;
if (!Object.values(ConsentType).includes(consentType)) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
@@ -304,9 +304,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: source || 'portal',
documentPath,
version,
source: 'portal',
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
@@ -315,7 +313,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: source || 'portal' },
details: { einwilligung: consentName, status, quelle: 'portal' },
customerId,
});
@@ -822,9 +820,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
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, {
source: source || 'crm-backend',
notes,
source: safeSource,
notes: safeNotes as string | undefined,
});
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
@@ -887,6 +891,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
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 auth = await authorizationService.updateAuthorizationDocument(
customerId,
+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> {
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) {
res.status(404).json({
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 {
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({
req, action: 'UPDATE', resourceType: 'StressfreiEmail',
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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
await stressfreiEmailService.deleteEmail(emailId);
await logChange({
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 {
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) {
res.status(400).json({
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 { logChange } from '../services/audit.service.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
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> {
try {
// 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({
req, action: 'CREATE', resourceType: 'User',
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> {
try {
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)
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
const before = await prisma.user.findUnique({ where: { id: userId } });
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// 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);
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 fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
};
for (const [key, newVal] of Object.entries(data)) {
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> {
try {
const userId = parseInt(req.params.id);
+253 -19
View File
@@ -1,8 +1,34 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
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 customerRoutes from './routes/customer.routes.js';
@@ -43,8 +69,6 @@ import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
dotenv.config();
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
@@ -60,25 +84,124 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
const app = express();
const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// 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 ====================
// 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(
helmet({
// CSP ausschalten wird bei SPA schwierig, frontend setzt eigene CSP via meta
contentSecurityPolicy: false,
// Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin
contentSecurityPolicy: {
useDefaults: true,
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' },
}),
);
@@ -99,6 +222,9 @@ app.use(
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
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)
app.use(auditContextMiddleware);
@@ -127,6 +253,85 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
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)
app.use('/api/public/consent', consentPublicRoutes);
@@ -162,8 +367,13 @@ app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check
app.get('/api/health', (req, res) => {
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// 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() });
});
@@ -171,8 +381,29 @@ app.get('/api/health', (req, res) => {
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(process.cwd(), 'public');
// Serve static files
app.use(express.static(publicPath));
// Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
// 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
app.get('*', (req, res, next) => {
@@ -180,6 +411,9 @@ if (process.env.NODE_ENV === 'production') {
if (req.path.startsWith('/api')) {
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'));
});
}
+16
View File
@@ -1,6 +1,22 @@
import { PrismaClient, Prisma } from '@prisma/client';
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
const AUDITED_MODELS = [
'Customer',
+26 -1
View File
@@ -13,12 +13,15 @@ export async function authenticate(
// Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter
token = req.query.token;
tokenSource = 'query';
}
if (!token) {
@@ -31,7 +34,29 @@ export async function authenticate(
// 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;
}) 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
if (decoded.userId && decoded.iat) {
+47 -6
View File
@@ -25,20 +25,38 @@ function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
}
/**
* Login: 10 Versuche pro 15 Minuten pro IP.
* Nach Überschreitung: 15 Min Sperre für diese IP.
* Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
*
* 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({
windowMs: 15 * 60 * 1000, // 15 Minuten
limit: 10, // Max. 10 Versuche pro Zeitfenster
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
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,
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);
@@ -63,3 +81,26 @@ export const passwordResetRateLimiter = rateLimit({
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 * as appSettingController from '../controllers/appSetting.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';
// Multer für Backup-Upload (in Memory speichern)
@@ -100,4 +101,32 @@ router.post(
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;
+12
View File
@@ -5,8 +5,14 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi
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('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);
router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
@@ -15,4 +21,10 @@ router.post('/register', authenticate, requirePermission('users:create'), authCo
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
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;
+5 -1
View File
@@ -1,9 +1,13 @@
import { Router } from 'express';
import * as controller from '../controllers/consent-public.controller.js';
import { publicConsentRateLimiter } from '../middleware/rateLimit.js';
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.post('/:hash/grant', controller.grantAllConsents);
router.get('/:hash/pdf', controller.getConsentPdf);
+3
View File
@@ -42,6 +42,9 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
// Follow-up contract
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)
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.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
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)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+5 -44
View File
@@ -1,54 +1,15 @@
import { Router, Response } from 'express';
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
const router = Router();
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird
router.post('/setup', async (req, res: Response) => {
try {
// Create or get the developer:access permission
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' });
}
});
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
// 2026-05-16 KRITISCH). Er war ohne Auth erreichbar und konnte
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
// Tabellen-Metadaten mit Beziehungen
const tableMetadata: Record<string, {
@@ -1,9 +1,25 @@
import { Router } from 'express';
import multer from 'multer';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
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?)
router.get(
'/preview',
@@ -20,4 +36,13 @@ router.get(
factoryDefaultsController.exportFactoryDefaults,
);
// Import aus ZIP (multipart, Feld 'zip')
router.post(
'/import',
authenticate,
requirePermission('settings:update'),
upload.single('zip'),
factoryDefaultsController.importFactoryDefaults,
);
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)
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;
+2
View File
@@ -10,6 +10,8 @@ router.post('/', authenticate, requirePermission('users:create'), userController
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
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
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -12,6 +13,53 @@ const DEFAULT_SETTINGS: Record<string, string> = {
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> {
const setting = await prisma.appSetting.findUnique({
where: { key },
+7
View File
@@ -112,6 +112,13 @@ function determineSensitivity(resourceType: string): AuditSensitivity {
Authentication: 'CRITICAL',
BankCard: '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
Customer: 'HIGH',
User: 'HIGH',
+308 -21
View File
@@ -6,6 +6,38 @@ import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.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).
@@ -100,12 +132,12 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
return {
token,
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
@@ -160,19 +192,42 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// 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
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
}
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map(
(rep) => rep.customer.id
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
// GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
// 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)
const customerPermissions = [
@@ -188,12 +243,13 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
return {
token,
accessToken,
refreshToken,
mustChangePassword,
user: {
id: customer.id,
email: customer.portalEmail,
@@ -202,7 +258,8 @@ export async function customerLogin(email: string, password: string) {
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
mustChangePassword,
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -214,6 +271,94 @@ 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
export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
@@ -223,17 +368,45 @@ export async function setCustomerPortalPassword(customerId: number, password: st
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({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword,
portalPasswordMustChange: false,
},
});
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
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({
@@ -373,6 +546,13 @@ export async function getCustomerPortalUser(customerId: number) {
'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 {
id: customer.id,
email: customer.portalEmail,
@@ -382,7 +562,7 @@ export async function getCustomerPortalUser(customerId: number) {
customerId: customer.id,
permissions: customerPermissions,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -405,6 +585,86 @@ function getPublicUrl(): string {
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.
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
@@ -508,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.
* Invalidiert alle bestehenden JWT-Sessions des Users.
@@ -548,11 +828,18 @@ export async function confirmPasswordReset(token: string, newPassword: string):
where: { id: customer.id },
data: {
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,
portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(),
// OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
portalPasswordMustChange: false,
},
});
return;
+36 -4
View File
@@ -138,6 +138,24 @@ function deleteDirectory(dirPath: string): void {
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
*/
@@ -249,6 +267,7 @@ export async function createBackup(): Promise<BackupResult> {
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
];
let totalRecords = 0;
@@ -310,6 +329,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
// Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({});
await prisma.securityEvent.deleteMany({});
// Detail-Tabellen
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;
@@ -912,10 +944,10 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
let restoredFiles = 0;
const uploadsBackupDir = path.join(backupDir, 'uploads');
if (fs.existsSync(uploadsBackupDir)) {
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
if (fs.existsSync(UPLOADS_DIR)) {
deleteDirectory(UPLOADS_DIR);
}
// Inhalte leeren, das Verzeichnis selbst NICHT löschen
// UPLOADS_DIR ist im Container ein Bind-Mount auf den Host und
// `rmdir` darauf liefert EBUSY (siehe emptyDirectory()).
emptyDirectory(UPLOADS_DIR);
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
}
@@ -49,6 +49,18 @@ export interface EmailListOptions {
limit?: number;
offset?: number;
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 ====================
@@ -273,6 +285,59 @@ export async function getCachedEmails(
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)
const select: Prisma.CachedEmailSelect = {
id: true,
+18
View File
@@ -3,6 +3,24 @@ import prisma from '../lib/prisma.js';
import fs from 'fs';
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 {
status: ConsentStatus;
source?: string;
+272 -3
View File
@@ -2,6 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
export interface ContractFilters {
customerId?: number;
@@ -154,7 +155,18 @@ export async function getContractById(id: number, decryptPassword = false) {
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) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
@@ -206,7 +218,8 @@ interface ContractCreateData {
annualConsumption?: number;
basePrice?: number;
unitPrice?: number;
bonus?: number;
instantBonus?: number;
newCustomerBonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
};
@@ -385,6 +398,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;
}
@@ -689,7 +711,8 @@ export async function createFollowUpContract(previousContractId: number) {
previousContract.energyDetails.annualConsumption ?? undefined,
basePrice: previousContract.energyDetails.basePrice ?? undefined,
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
bonus: previousContract.energyDetails.bonus ?? undefined,
instantBonus: previousContract.energyDetails.instantBonus ?? undefined,
newCustomerBonus: previousContract.energyDetails.newCustomerBonus ?? undefined,
previousProviderName: previousContract.providerName ?? undefined,
previousCustomerNumber:
previousContract.customerNumberAtProvider ?? undefined,
@@ -765,6 +788,252 @@ export async function createFollowUpContract(previousContractId: number) {
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,
instantBonus: ed.instantBonus,
newCustomerBonus: ed.newCustomerBonus,
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
export async function getContractPassword(id: number): Promise<string | null> {
const contract = await prisma.contract.findUnique({
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
return end;
}
export async function getCockpitData(): Promise<CockpitResult> {
export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
// Lade Einstellungen
const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
@@ -192,12 +192,19 @@ export async function getCockpitData(): Promise<CockpitResult> {
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
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)
const contracts = await prisma.contract.findMany({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
},
...customerScopeFilter,
},
include: {
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({
where: { status: 'GRANTED' },
where: { status: 'GRANTED', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
@@ -300,7 +307,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
// Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' },
where: { status: 'WITHDRAWN', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
const withdrawnConsentsMap = new Map<number, Set<string>>();
@@ -733,10 +740,10 @@ export async function getCockpitData(): Promise<CockpitResult> {
});
// Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays);
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings();
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
return {
contracts: cockpitContracts,
@@ -754,7 +761,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
/**
* 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 inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
@@ -762,6 +773,7 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
where: {
isActive: true,
expiryDate: { lte: inWarningDays },
...(customerIds ? { customerId: { in: customerIds } } : {}),
},
include: {
customer: {
@@ -798,9 +810,12 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
/**
* 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({
where: { status: 'REPORTED' },
where: {
status: 'REPORTED',
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
},
include: {
meter: {
include: {
@@ -129,3 +129,35 @@ export async function createNewContractFromPredecessorEntry(
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,
});
}
+8 -1
View File
@@ -22,10 +22,13 @@ export interface CustomerFilters {
type?: CustomerType;
page?: 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) {
const { search, type, page = 1, limit = 20 } = filters;
const { search, type, page = 1, limit = 20, allowedIds } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
@@ -34,6 +37,10 @@ export async function getAllCustomers(filters: CustomerFilters) {
where.type = type;
}
if (allowedIds) {
where.id = { in: allowedIds };
}
if (search) {
where.OR = [
{ 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
export async function renameProvisionedEmail(
oldLocalPart: string,
+272 -3
View File
@@ -1,15 +1,32 @@
/**
* 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,
* Vertragskategorien und PDF-Auftragsvorlagen.
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import AdmZip from 'adm-zip';
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 {
version: 1;
exportedAt: string;
@@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest {
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
appSettings: number;
};
}
@@ -49,7 +67,7 @@ export interface PdfTemplateExport {
* Sammelt alle Katalog-Daten aus der DB.
*/
export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
await Promise.all([
prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } },
@@ -59,6 +77,11 @@ export async function collectFactoryDefaults() {
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: '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 {
@@ -108,6 +131,7 @@ export async function collectFactoryDefaults() {
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,
contractCategories: data.contractCategories.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), {
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)
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;
}
+8 -2
View File
@@ -87,7 +87,9 @@ export const CRM_FIELDS = [
{ path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' },
{ path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' },
{ path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' },
{ path: 'energyDetails.bonus', label: 'Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.instantBonus', label: 'Sofort-Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.newCustomerBonus', label: 'Neukunden-Bonus (€)', group: 'Energie' },
{ path: 'energyDetails.totalBonus', label: 'Gesamtbonus (€)', group: 'Energie' },
// Internet/DSL/Glasfaser/Kabel
{ path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' },
{ path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' },
@@ -469,7 +471,11 @@ export async function generateFilledPdf(
'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '',
'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '',
'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '',
'energyDetails.bonus': contract.energyDetails?.bonus?.toString() || '',
'energyDetails.instantBonus': contract.energyDetails?.instantBonus?.toString() || '',
'energyDetails.newCustomerBonus': contract.energyDetails?.newCustomerBonus?.toString() || '',
'energyDetails.totalBonus': (
((contract.energyDetails?.instantBonus ?? 0) + (contract.energyDetails?.newCustomerBonus ?? 0)) || ''
).toString(),
// Internet
'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '',
'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '',
@@ -155,14 +155,16 @@ export async function detectThresholds(): Promise<void> {
});
for (const g of grouped) {
if ((g._count as number) < b.threshold) continue;
// Prüfen ob wir für diese (IP+Type+Stunde) schon einen CRITICAL emittiert haben
const hourBucket = new Date(now.getTime() - (now.getTime() % (60 * 60 * 1000)));
// 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: hourBucket },
createdAt: { gte: oneHourAgo },
},
});
if (existing) continue;
+154 -8
View File
@@ -7,6 +7,8 @@ import {
checkEmailExists,
getProviderDomain,
updateMailboxPassword,
setEmailForwardTargets,
getActiveProviderConfig,
} from './emailProvider/emailProviderService.js';
import { generateSecurePassword } from '../utils/passwordGenerator.js';
@@ -113,6 +115,8 @@ export async function createEmail(data: CreateEmailData) {
...emailData,
isActive: true,
hasMailbox: true,
isProvisioned: true,
provisionedAt: new Date(),
emailPasswordEncrypted: passwordEncrypted,
},
});
@@ -131,6 +135,11 @@ export async function createEmail(data: CreateEmailData) {
...emailData,
isActive: true,
hasMailbox: createMailbox || false,
// Provisioned-Flag nur setzen wenn Provider-Aufruf gerade lief (oder
// die Mail bei Plesk schon existierte und der „existiert bereits"-Pfad
// gegriffen hat).
isProvisioned: !!provisionAtProvider,
provisionedAt: provisionAtProvider ? new Date() : null,
},
});
}
@@ -201,7 +210,7 @@ export async function syncMailboxStatus(id: number): Promise<{
}> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { email: true, hasMailbox: true },
select: { email: true, hasMailbox: true, isProvisioned: true, provisionedAt: true },
});
if (!stressfreiEmail) {
@@ -213,19 +222,42 @@ export async function syncMailboxStatus(id: number): Promise<{
// Provider-Status prüfen
const providerStatus = await checkEmailExists(localPart);
// Self-Healing für `isProvisioned`: das Flag wurde in einer früheren Code-
// Version beim Provisioning nie gesetzt → DB ist stellenweise inkonsistent
// zum Provider. Wir reconciliieren bei jedem Status-Sync mit.
const updates: Record<string, unknown> = {};
if (!providerStatus.exists) {
// Beim Provider nicht (mehr) vorhanden → DB-Flag entsprechend
if (stressfreiEmail.isProvisioned) {
updates.isProvisioned = false;
}
if (stressfreiEmail.hasMailbox) {
updates.hasMailbox = false;
}
if (Object.keys(updates).length > 0) {
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
return { success: true, hasMailbox: false, wasUpdated: true };
}
return { success: true, hasMailbox: false, wasUpdated: false };
}
const providerHasMailbox = providerStatus.hasMailbox === true;
// Beim Provider vorhanden → isProvisioned auf true ziehen falls noch nicht
if (!stressfreiEmail.isProvisioned) {
updates.isProvisioned = true;
if (!stressfreiEmail.provisionedAt) {
updates.provisionedAt = new Date();
}
}
// DB aktualisieren wenn Status abweicht
const providerHasMailbox = providerStatus.hasMailbox === true;
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
await prisma.stressfreiEmail.update({
where: { id },
data: { hasMailbox: providerHasMailbox },
});
console.log(`Mailbox-Status für ${stressfreiEmail.email} aktualisiert: ${stressfreiEmail.hasMailbox} -> ${providerHasMailbox}`);
updates.hasMailbox = providerHasMailbox;
}
if (Object.keys(updates).length > 0) {
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
console.log(`Stressfrei-Status für ${stressfreiEmail.email} reconciled:`, updates);
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
}
@@ -251,6 +283,120 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
}
}
// Weiterleitungen einer Stressfrei-Adresse neu setzen (z.B. nach Änderung der
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config].
//
// Wenn die Adresse `hasMailbox` ist: setzt zusätzlich das im CRM verschlüsselt
// hinterlegte Passwort am Provider neu (Use-Case: Plesk-Restore, manueller
// Eingriff im Plesk-UI etc. CRM und Provider können sich entkoppeln, sodass
// IMAP/SMTP-Logins im CRM nicht mehr passen). Self-Healing.
//
// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein
// Duplikat-Risiko bei Mehrfachaufruf. Wenn die Operation erfolgreich war wird
// das `isProvisioned`-Flag automatisch auf `true` gezogen (historische
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
export async function syncForwardingForEmail(
id: number,
): Promise<{
success: boolean;
forwardTargets?: string[];
customerEmail?: string;
passwordReset?: boolean;
error?: string;
}> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: {
email: true,
customerId: true,
isProvisioned: true,
hasMailbox: true,
emailPasswordEncrypted: true,
},
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
const customer = await prisma.customer.findUnique({
where: { id: stressfreiEmail.customerId },
select: { email: true },
});
if (!customer?.email) {
return { success: false, error: 'Kunde hat keine Stamm-E-Mail-Adresse hinterlegt' };
}
const config = await getActiveProviderConfig();
const forwardTargets: string[] = [customer.email];
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
const localPart = stressfreiEmail.email.split('@')[0];
// 1) Forwards neu setzen.
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
if (!forwardResult.success) {
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
// statt der rohen Provider-Nachricht.
const err = forwardResult.error || 'Provider-Update fehlgeschlagen';
const friendly = /not\s*found|nicht\s*gefunden/i.test(err)
? 'E-Mail-Adresse beim Provider nicht gefunden wurde sie dort gelöscht?'
: err;
return { success: false, error: friendly };
}
// 2) Wenn Mailbox: Passwort aus CRM-Speicher entschlüsseln und am Provider
// neu setzen (Self-Healing nach Provider-seitigen Änderungen).
let passwordReset = false;
if (stressfreiEmail.hasMailbox && stressfreiEmail.emailPasswordEncrypted) {
try {
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
const pwResult = await updateMailboxPassword(localPart, password);
if (!pwResult.success) {
// Forwards waren schon erfolgreich wir geben Forward-Erfolg + Passwort-
// Fehler kombiniert zurück, statt die ganze Operation rot zu machen.
return {
success: false,
forwardTargets,
customerEmail: customer.email,
error:
'Weiterleitungen aktualisiert, aber Passwort-Sync fehlgeschlagen: ' +
(pwResult.error || 'unbekannt'),
};
}
passwordReset = true;
} catch (e) {
return {
success: false,
forwardTargets,
customerEmail: customer.email,
error:
'Weiterleitungen aktualisiert, aber Passwort konnte nicht entschlüsselt werden ' +
'evtl. wurde der ENCRYPTION_KEY rotiert',
};
}
}
// 3) Self-Healing: nach erfolgreichem Provider-Aufruf wissen wir definitiv,
// dass die Adresse beim Provider existiert → Flag korrigieren.
if (!stressfreiEmail.isProvisioned) {
await prisma.stressfreiEmail.update({
where: { id },
data: { isProvisioned: true, provisionedAt: new Date() },
});
}
return {
success: true,
forwardTargets,
customerEmail: customer.email,
passwordReset,
};
}
// Passwort neu generieren und beim Provider setzen
export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
+28
View File
@@ -130,6 +130,34 @@ export async function canAccessCustomer(
return true;
}
/**
* Liefert die Liste aller Customer-IDs, auf die ein Portal-User aktuell
* Zugriff hat: eigene + vertretene MIT aktiver Vollmacht (Live-Check via
* `authorizationService.hasAuthorization`). Für Nicht-Portal-User wird
* `null` zurückgegeben (= kein Filter, alle Kunden erlaubt).
*
* Diese Funktion fängt einen wiederkehrenden Pentest-Befund ab: ohne den
* Live-Vollmacht-Check hätte ein Portal-User mit widerrufener Vollmacht
* weiterhin Zugriff auf die Daten des vertretenen Kunden, nur weil seine
* `representedCustomerIds` im JWT noch drin sind (Token kann bis zu
* 15min alt sein).
*/
export async function getPortalAllowedCustomerIds(
req: AuthRequest,
): Promise<number[] | null> {
if (!req.user?.isCustomerPortal || !req.user.customerId) return null;
const allowed: number[] = [req.user.customerId];
const represented: number[] = (req.user as any).representedCustomerIds || [];
for (const repCustId of represented) {
const hasAuth = await authorizationService.hasAuthorization(
repCustId,
req.user.customerId,
);
if (hasAuth) allowed.push(repCustId);
}
return allowed;
}
/**
* Generische Zugriffsprüfung: Ressource customerId canAccessCustomer.
*/
+6 -2
View File
@@ -4,8 +4,12 @@ export function generateCustomerNumber(): string {
return `K${timestamp}${random}`;
}
export function generateContractNumber(type: string): string {
const prefix = type.substring(0, 3).toUpperCase();
export function generateContractNumber(type: string | null | undefined): string {
// Defensiv: ohne validen Type-String fällt der Prefix auf "CON" zurück.
// Pentest Runde 12: POST /contracts ohne `type` warf
// "Cannot read properties of undefined (reading 'substring')".
const safeType = (typeof type === 'string' && type.length > 0) ? type : 'CON';
const prefix = safeType.substring(0, 3).toUpperCase();
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 5).toUpperCase();
return `${prefix}-${timestamp}${random}`;
+49
View File
@@ -88,6 +88,55 @@ export function generateSimplePassword(length = 12): string {
});
}
// ==================== PASSWORD COMPLEXITY VALIDATION ====================
/**
* Mindestanforderungen für vom User vergebene Passwörter.
* Generator-Output (generateSecurePassword) erfüllt diese standardmäßig.
*/
export interface PasswordComplexityResult {
ok: boolean;
errors: string[];
}
// Mindestlängen nach Kontext (Pentest Runde 13 / 2026-05-18):
// Endkunden tippen ihr Portal-Passwort auch auf dem Handy ein 12 ist hier
// der Endkunden-Floor. Mitarbeiter/Admin nutzen Passwort-Manager → 25
// Zeichen entsprechen der aktuellen BSI-Empfehlung für lange Passphrasen
// mit Komplexität.
export const PORTAL_MIN_PASSWORD_LENGTH = 12;
export const STAFF_MIN_PASSWORD_LENGTH = 25;
export function validatePasswordComplexity(
pw: unknown,
opts: { minLength?: number } = {},
): PasswordComplexityResult {
const minLength = opts.minLength ?? PORTAL_MIN_PASSWORD_LENGTH;
const errors: string[] = [];
if (typeof pw !== 'string') {
return { ok: false, errors: ['Passwort fehlt oder ist kein Text'] };
}
if (pw.length < minLength) errors.push(`mindestens ${minLength} Zeichen`);
if (!/[a-z]/.test(pw)) errors.push('mindestens einen Kleinbuchstaben');
if (!/[A-Z]/.test(pw)) errors.push('mindestens einen Großbuchstaben');
if (!/[0-9]/.test(pw)) errors.push('mindestens eine Ziffer');
// Sonderzeichen-Set bewusst breit auch Leerzeichen + Unicode-Punktuation
// zulassen, damit gängige Passwort-Manager-Outputs nicht abgelehnt werden.
if (!/[^A-Za-z0-9]/.test(pw)) errors.push('mindestens ein Sonderzeichen');
return { ok: errors.length === 0, errors };
}
/**
* Wirft mit sprechender Fehlermeldung, wenn das Passwort die Komplexität
* nicht erfüllt. Für Aufruf direkt im Controller, der die Exception fängt.
*/
export function assertPasswordComplexity(pw: unknown, opts: { minLength?: number } = {}): void {
const r = validatePasswordComplexity(pw, opts);
if (!r.ok) {
throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', '));
}
}
// Kryptografisch sichere Zufallszahl
function getRandomInt(max: number): number {
const bytes = randomBytes(4);
+273 -13
View File
@@ -9,6 +9,54 @@ const SENSITIVE_CUSTOMER_FIELDS = [
'portalPasswordHash',
'portalPasswordResetToken',
'portalPasswordResetExpiresAt',
// consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link
// (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer
// anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash',
// Session-/OTP-State Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
// Auth-Bypass, aber unnötig. Wenn Admin diese Information legitim
// braucht (z.B. UI-Hinweis "OTP wurde noch nicht eingelöst"), führen
// wir bei Bedarf einen eigenen Endpoint ein.
'portalPasswordMustChange',
'portalTokenInvalidatedAt',
] as const;
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
// sollen Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
// unnötige Informationsleckage über den DB-Aufbau.
// Pentest Runde 7 (2026-05-17), MEDIUM.
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
// portalTokenInvalidatedAt + portalPasswordMustChange sind jetzt in
// SENSITIVE_CUSTOMER_FIELDS (immer raus), nicht mehr nur für Portal.
'portalLastLogin',
'lastBirthdayGreetingYear',
// privacyPolicyPath etc. sind interne Datei-Pfade Portal nutzt
// dedizierte PDF-Endpoints, nicht den Pfad direkt
'privacyPolicyPath',
'businessRegistrationPath',
'commercialRegisterPath',
// Pentest Runde 10 (2026-05-17): notes sind interne CRM-Vermerke
// ("Kunde ist schwierig" etc.) und gehören nicht in die Portal-Sicht.
'notes',
] as const;
// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
// (mit Audit-Log) abrufbar im /contracts/:id selbst nutzlos.
const SENSITIVE_CONTRACT_FIELDS = [
'portalPasswordEncrypted',
] as const;
// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
'commission',
'notes',
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
] as const;
const SENSITIVE_USER_FIELDS = [
@@ -22,29 +70,41 @@ const SENSITIVE_USER_FIELDS = [
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
* es standardmäßig.
* es standardmäßig. Embedded `contracts[]` werden ebenfalls sanitisiert
* (Pentest Runde 10 DTO-Leak in eingebetteten Objekten).
*/
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = { ...customer };
const copy: Record<string, unknown> = { ...customer };
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
}
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
// auf 'customers:update' implizit gesichert.
return copy;
return copy as T;
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
* Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
* zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
* Admin ist (z.B. Portal-Kunde). Embedded `contracts[]` werden mit der
* Strict-Variante sanitisiert.
*/
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
if (!copy) return null;
delete copy.portalPasswordEncrypted;
for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
delete copy[field];
}
if (Array.isArray(copy.contracts)) {
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContractStrict(c));
}
return copy as T;
}
@@ -55,6 +115,49 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
* Audit-Log abrufbar) und sanitisiert das embedded customer.
*/
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy: Record<string, unknown> = { ...contract };
for (const field of SENSITIVE_CONTRACT_FIELDS) {
delete copy[field];
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
}
return copy as T;
}
/**
* Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
* (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
* mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
*/
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
if (!contract) return contract;
const copy = sanitizeContract(contract) as Record<string, unknown> | null;
if (!copy) return null;
for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
delete copy[field];
}
if (copy.customer && typeof copy.customer === 'object') {
copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
}
return copy as T;
}
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
}
export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize User-Objekt für API-Responses.
*/
@@ -109,38 +212,195 @@ const USER_UPDATABLE_FIELDS = [
'telegramUsername',
'signalNumber',
'roleIds',
'password', // nur Admin, wird im Service gehashed
// hasGdprAccess + hasDeveloperAccess sind keine User-Spalten der Service
// mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
// setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
// stehen, damit pick() sie nicht aus dem Request entfernt.
'hasGdprAccess',
'hasDeveloperAccess',
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
// Nicht: password wird über dedizierten Endpoint POST /users/:id/password
// gesetzt (Pentest Runde 12 (2026-05-18) MITTEL: generisches User-Update
// hatte password in der Whitelist, ein Admin konnte stillschweigend ohne
// dedizierten Audit-Trail Passwörter überschreiben).
] as const;
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
// Bei CREATE braucht's das initial-Passwort
const USER_CREATE_FIELDS = [
...USER_UPDATABLE_FIELDS,
'password',
] as const;
/**
* Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein
* gespeicherter Wert nicht später irgendwo zum aktiven XSS-Vektor wird
* (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML
* im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber
* Defense-in-Depth speichert lieber gleich nichts Bösartiges.
*
* Verlauf:
* - Pentest Runde 11 (2026-05-18): <script> in companyName.
* - Pentest 2026-05-20 (28.1/28.2): URI-Schema-Liste + Entity-Decoding.
* - Pentest 2026-05-20 (29.1/29.2/29.3): Zero-Width-Chars,
* Percent-Encoding und Cyrillic-Homoglyph-Bypass. Reihenfolge ist
* wichtig: zuerst Unicode-Müll raus, dann percent + entity dekodieren,
* dann Homoglyphe normalisieren, ZULETZT Tag-/Scheme-Strip.
* - Pentest 2026-05-20 (28.1 Restarbeit): blob:/about:/ws:/wss:/
* ldap:/dict: ergänzt.
*/
// Schemes die wir aktiv blocken durch "blocked:" ersetzen statt
// löschen, damit legitimer Text drumherum erhalten bleibt.
// Bewusst nicht in der Liste: http(s):, mailto:, tel: (legitime URLs in
// Notizfeldern). Alles andere geht selten in einem Plain-Text-Feld vor
// und kann im worst case immer noch durch JS interpretiert werden.
const DANGEROUS_URI_SCHEMES =
/(?:javascript|data|vbscript|file|ftp|blob|about|ws|wss|ldap|dict)\s*:/gi;
// Unsichtbare Unicode-Steuerzeichen, die wie Whitespace fehlen, aber
// Regex-Matches auf "javascript:" zerteilen können. Plain-Text-Felder
// enthalten diese nie legitim. Pentest 29.3.
// U+200BU+200F (ZWSP, ZWNJ, ZWJ, LRM, RLM), U+202AU+202E (Embedding/
// Override), U+2060U+2064 (Word-Joiner & co.), U+FEFF (BOM).
const ZERO_WIDTH_CHARS = /[---]/g;
// Cyrillic/Greek-Homoglyphe, die in URL-Schemes als Spoofing taugen
// (Pentest 29.1: "jаvascript:" mit kyrillischem а = U+0430).
// Bewusst eng gehalten: nur die Buchstaben, die in JS-/HTML-Schlüssel-
// wörtern vorkommen wir wollen legitimes Russisch/Griechisch (z.B. in
// einem Notizfeld) nicht komplett zerlegen. Das Risiko, dass ein
// einzelnes "а" in einem ru-Wort versehentlich zu "a" wird, ist
// akzeptabel die häufiger genutzten cyrillischen Buchstaben (б, в,
// г, …) sind nicht in der Map.
const HOMOGLYPH_TO_ASCII: Record<string, string> = {
'а': 'a', 'А': 'A', // U+0430 / U+0410
'е': 'e', 'Е': 'E', // U+0435 / U+0415
'о': 'o', 'О': 'O', // U+043E / U+041E
'р': 'p', 'Р': 'P', // U+0440 / U+0420
'с': 'c', 'С': 'C', // U+0441 / U+0421
'у': 'y', 'У': 'Y', // U+0443 / U+0423
'х': 'x', 'Х': 'X', // U+0445 / U+0425
'і': 'i', 'І': 'I', // U+0456 / U+0406 (Ukr.)
'ј': 'j', 'Ј': 'J', // U+0458 / U+0408 (Mac.)
'ѕ': 's', 'Ѕ': 'S', // U+0455 / U+0405
'ο': 'o', 'Ο': 'O', // U+03BF / U+039F (Greek)
'α': 'a', // U+03B1
};
const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_TO_ASCII).join('|'), 'g');
function decodeHtmlEntities(s: string): string {
return s
.replace(/&#(\d+);?/g, (_m, code) => {
const n = parseInt(code, 10);
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
})
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
const n = parseInt(code, 16);
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
})
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#?apos;/gi, "'")
.replace(/&amp;/gi, '&');
}
// Percent-decoded String. Iterativ bis stabil ein Angreifer kann
// "java%2573cript:" schreiben (`%25` ist `%`), das nach einer
// Iteration zu "java%73cript:" wird, was wiederum zu "javascript:".
// Max 5 Iterationen reichen für realistische Verschachtelungen.
// `decodeURIComponent` würde bei ungültigen Sequenzen werfen, deshalb
// machen wir es per Regex.
function percentDecode(s: string): string {
let prev = s;
for (let i = 0; i < 5; i++) {
const next = prev.replace(/%([0-9A-Fa-f]{2})/g, (_m, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
if (next === prev) return next;
prev = next;
}
return prev;
}
/**
* Strikte Email-Validierung. Wichtig vor allem für **SMTP-Header-
* Injection**: ein gespeicherter Wert wie `test@x.de\nBcc:attacker@evil.de`
* würde, wenn er in einen To-Header geschrieben wird, einen zusätzlichen
* Bcc-Empfänger einschleusen (Pentest 29.4). Wir verbieten daher:
* - Whitespace, Newlines, Tabs, Steuerzeichen
* - mehr als ein `@`
* - Domain ohne Punkt
* - Länge > 254 (RFC 5321)
* Format: `local@domain.tld`, local 164 ASCII, domain DNS-konform.
*
* `null`/leer ist erlaubt (Email ist oft optional). Aufrufer entscheidet,
* ob `null` ok ist.
*/
export function isValidEmail(value: unknown): boolean {
if (value === null || value === undefined || value === '') return true;
if (typeof value !== 'string') return false;
if (value.length > 254) return false;
// Newline/Tab/Steuerzeichen explizit ablehnen das ist der
// Header-Injection-Vektor.
if (/[\r\n\t\0\v\f]/.test(value)) return false;
// Basic RFC-5322-ähnlich, ohne quoted-string-Local-Part.
// Local-Part: 164 Zeichen aus [a-z0-9._%+-], muss nicht mit Punkt
// beginnen/enden. Domain: Labels mit Punkten, TLD mind. 2 Zeichen.
return /^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(value);
}
export function stripHtml(value: unknown): unknown {
if (typeof value !== 'string') return value;
let s = value;
// 1) Unicode-Steuerzeichen raus, sonst zerteilen sie Regex-Matches.
s = s.replace(ZERO_WIDTH_CHARS, '');
// 2) Percent-Encoding auflösen (iterativ bis stabil).
s = percentDecode(s);
// 3) Homoglyphe normalisieren, damit "jаvascript:" zu "javascript:" wird.
s = s.replace(HOMOGLYPH_RE, (m) => HOMOGLYPH_TO_ASCII[m] || m);
// 4) HTML-Entities dekodieren.
s = decodeHtmlEntities(s);
// 5) Tags + dangerous Schemes strippen.
s = s
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(DANGEROUS_URI_SCHEMES, 'blocked:');
return s;
}
/**
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
* Optional werden alle String-Werte durch stripHtml geschickt.
*/
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
function pick<T extends object>(obj: T, allowed: readonly string[], options: { stripHtmlFromStrings?: boolean } = {}): Partial<T> {
const result: Partial<T> = {};
for (const key of allowed) {
if (key in obj) {
(result as any)[key] = (obj as any)[key];
let v = (obj as any)[key];
if (options.stripHtmlFromStrings && typeof v === 'string') {
v = stripHtml(v);
}
(result as any)[key] = v;
}
}
return result;
}
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
return pick((body as object) || {}, USER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_CREATE_FIELDS);
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
}
+32
View File
@@ -28,6 +28,32 @@ const BLOCKED_PATTERNS: RegExp[] = [
/^ff/i, // IPv6 Multicast
];
// Opt-in für Cloud-Deployments: ALLE privaten IP-Ranges blocken, nicht
// nur Cloud-Metadata. On-Prem-Default ist `false`, weil On-Prem-Setups
// häufig Plesk/Dovecot/Postfix auf 127.0.0.1 oder im internen Netz
// laufen lassen. (Pentest 2026-05-20 INFO 30.14.) Aktivieren mit
// `SSRF_BLOCK_PRIVATE_IPS=true` in der Umgebung.
const BLOCK_PRIVATE_IPS = (process.env.SSRF_BLOCK_PRIVATE_IPS || '').toLowerCase() === 'true';
const PRIVATE_IP_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8
/^192\.168\./, // 192.168.0.0/16
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
/^::1$/, // IPv6 Loopback
/^::ffff:127\./i, // IPv4-mapped Loopback
/^::ffff:10\./i, // IPv4-mapped 10/8
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
/^f[cd]/i, // fc00::/7 Unique-Local
];
const PRIVATE_HOSTNAMES = new Set([
'localhost',
'ip6-localhost',
'ip6-loopback',
]);
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
@@ -43,6 +69,12 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
if (BLOCK_PRIVATE_IPS) {
if (PRIVATE_HOSTNAMES.has(h)) return true;
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(h)) return true;
}
}
return false;
}
View File
View File
View File
View File

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