Commit Graph

189 Commits

Author SHA1 Message Date
duffyduck 6f378d750c Vertragsansicht: Standort + Inaktiv-Badge beim Zähler anzeigen
In der Zähler-Zelle der Strom/Gas-Details wird jetzt zusätzlich
"Inaktiv" (rot) neben der Zählernummer angezeigt und der
Standort als kleine Zeile darunter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:12 +02:00
duffyduck c099b41796 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration
  20260530100000_meter_address mit IF NOT EXISTS
- Service erzwingt beim Create: Lieferadresse vorhanden + zum
  Kunden gehörig + Typ DELIVERY_RESIDENCE
- MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled
  ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen
  "nicht zugeordnet – bitte über Bearbeiten nachpflegen"
- ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse;
  deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei
  Auswahl Toast-Warnung wegen möglichem Altvertrag

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:18:24 +02:00
duffyduck d92d0b1eaf Vertrags-Mail-Card: Link "Postfach öffnen" → Kunden-Postfach in neuem Tab
Neben dem "E-Mails"-Titel der Card im Vertragsdetail jetzt ein
kleiner Link mit ExternalLink-Icon, der den Tab "E-Mail-Postfach"
in der Kundenakte in einem neuen Tab öffnet (target="_blank",
rel="noopener noreferrer"). Greift auf das bereits unterstützte
?tab=emails-Deep-Link-Pattern in CustomerDetail zurück.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 12:23:29 +02:00
duffyduck a3fef8891a Pentest 2026-05-30 LOW 39.3 + INFO 39.4: Magic-Byte-Check + Endung-Normalisierung
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.

Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
  zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
  .webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
  file.originalname). Damit verschwindet `evil.gif.php` zu
  `<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
  konsistente Werte sehen.

Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).

Live-verifiziert:
- PHP-Datei als image/gif    → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:43:13 +02:00
duffyduck 617022e492 Multer-Upload-Errors: 415/413 statt 500
Pentest 2026-05-30 INFO: Upload-Endpoints lieferten 500 statt
sauberem 4xx, wenn der fileFilter den MIME-Type ablehnte
(z.B. WebP/GIF, die gar nicht in der Allowlist standen) oder
LIMIT_FILE_SIZE getroffen wurde.

Ursache: fileFilter rief cb(new Error(...)) – multer wirft das
weiter, und ohne dedizierten Error-Handler endete es als 500
"Interner Serverfehler" mit Stack-Trace im Log.

Fix:
- WebP + GIF in die Allowlist von upload.routes.ts (Bug-Pen-
  test-Erwartung des Reporters).
- Globaler Express-Error-Handler in index.ts unterscheidet jetzt:
    * MulterError code=LIMIT_FILE_SIZE → 413 "Datei ist zu groß"
    * andere MulterError                → 400 "Upload-Fehler: ..."
    * Error mit "...erlaubt"-Message    → 415 mit Original-Message
    * sonst                             → bisheriger 4xx/500-Pfad

Live-verifiziert:
  WebP/GIF/JPG → 200
  SVG / text/plain → 415 + klare Message
  11 MB PDF → 413 "Datei ist zu groß"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:59:06 +02:00
duffyduck c93d4375ab fix: Email-Anhang öffnet wieder zuverlässig im neuen Tab
Manche Mail-Clients setzen für PDF-Anhänge fälschlich
Content-Type: application/octet-stream (oder application/x-pdf,
"PDF Document" usw.). Der bisherige Whitelist-Check fiel dann
auf Content-Disposition: attachment zurück – der Browser hat
trotz target="_blank" am <a>-Tag KEINEN neuen Tab geöffnet,
sondern die Datei direkt im aktuellen Tab "geöffnet" (Download
oder native PDF-Anzeige), je nach Browser-Konfiguration. Effekt
für den User: Klick auf Vorschau-Icon → Vorschau ersetzt das
CRM-UI.

Fix: Magic-Byte-Detection direkt am Buffer (gleiche Logik wie
beim /api/files/download-Endpoint). PDF/PNG/JPEG/GIF/WebP werden
zuverlässig erkannt, der vom IMAP gemeldete Type wird ignoriert
(real-world unzuverlässig). Bei Match → inline mit erkanntem
Type; sonst attachment + octet-stream. text/plain bleibt durch
einen schwächeren Sniff-Check zugelassen, sofern keine HTML-
Tags am Anfang stehen.

Stored-XSS-Schutz unverändert: HTML-Anhang mit .pdf-Endung →
kein PDF-Magic → kein inline → attachment + octet-stream → kein
Browser-Rendern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:24:13 +02:00
duffyduck 0bd2f9be7e fix: "Anzeigen"-Buttons öffnen Datei wieder im Browser-Tab
Folge-Symptom des Pen-30.13-Fixes: alle file-downloads liefen mit
Content-Disposition: attachment – das ist gegen Stored-XSS richtig,
hat aber die "Anzeigen"-Buttons (Bankkarten / Ausweise /
Verträge / etc.) kaputtgemacht, weil der Browser jetzt
herunterlud statt im Tab zu öffnen.

Magic-Byte-basierter Whitelist-Pfad eingebaut: optional ?disposition=
inline am Download-Endpoint, ABER nur wenn die ersten Bytes der
Datei das Magic eines safe Typs zeigen (PDF, PNG, JPEG, GIF, WebP).
Bei Mismatch fällt's auf attachment zurück – Stored-XSS bleibt
weiterhin unmöglich, falls jemand HTML als .pdf hochlädt.

Frontend: neuer viewUrl(path)-Alias = fileUrl(path, {inline: true}).
Alle Stellen mit `<a href={fileUrl(...)} target="_blank">` oder
`window.open(fileUrl(...), '_blank')` (13 Stellen über CustomerDetail,
ContractDetail, PdfTemplates, GDPRDashboard, InvoicesSection)
nutzen jetzt viewUrl. Download-Stellen bleiben fileUrl
(= attachment, byte-genaues File-Save).

Live-verifiziert auf dev:
- ohne Param: attachment (default, Stored-XSS-Schutz)
- ?disposition=inline + echte PDF: inline + application/pdf
- ?disposition=inline + HTML als .pdf: attachment (Magic-Mismatch
  → Browser lädt herunter statt zu rendern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:19:04 +02:00
duffyduck 6a670df1c4 fix: 2x Portal-Bugs (Vertragsauswahl + Email-Sync)
Bug 1 — Support-Anfrage: ausgewaehlter Vertrag nicht erkennbar
Im Kundenportal beim Erstellen einer Support-Anfrage war der
Selected-State des Vertrags nur ein dezenter blau-grauer
Hintergrund + Border-Farbwechsel. Auf hellem Bildschirm / nicht-
perfekter Lichtsituation kaum zu sehen.

Fix: kraefigere Markierung mit linkem 4px-Akzent-Bar
(border-l-blue-600), kraefigerem Background (bg-blue-100),
Checkmark-Icon rechtsbuendig und blauer Titel-Text.

Bug 2 — Email-Sync im Portal: "Keine Berechtigung"
POST /api/stressfrei-emails/:id/sync hatte
requirePermission('customers:update') – die Portal-Kunden nicht
haben (nur customers:read fuer eigene Daten). Sie konnten ihr
eigenes Postfach nicht synchronisieren.

Fix: Perm-Middleware aus der Route raus, Mitarbeiter-Check +
Owner-Check in den Controller verlegt:
- isCustomerPortal: nur Owner-Check (canAccessStressfreiEmail)
- Mitarbeiter: muss customers:update haben
Trennung der Threat-Modelle – Portal-User darf sein Postfach
syncen, sonst aber nichts triggern; Mitarbeiter brauchen weiter
die Update-Perm.

Live-verifiziert:
- Portal-User 1 syncs eigenes Konto → Auth passiert (400 wegen
  fehlender IMAP-Config in dev-DB, NICHT 403)
- Portal-User 1 syncs Customer-3-Konto → 403 "Kein Zugriff"
- Mitarbeiter ohne customers:update → weiter 403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:25:16 +02:00
duffyduck 7dcdf9d6ef Pentest Runde 35 follow-up: portalLoginUrl blockt ALLE privaten IPs
Runde-35-Befund: 34.5 nur teilweise gefixt – Cloud-Metadata
(169.254.x.x) wurde blockiert, aber 10/8, 172.16/12, 192.168/16,
127/8 und localhost gingen weiter durch, weil isBlockedSsrfHost
diese Ranges nur mit SSRF_BLOCK_PRIVATE_IPS=true geprüft hat. Der
Flag steht aber bewusst auf false für on-prem (Plesk auf 127.0.0.1).

Threat-Modell-Unterschied: portalLoginUrl ist eine URL in
*Endkunden-Mails*. Kunden können 127.0.0.1/192.168.x.x ohnehin nicht
erreichen → kein legitimer Wert. Daher muss der Check hier strikt
sein, unabhängig vom on-prem-Flag (der gilt nur für ausgehende
Server-zu-Server-Verbindungen wie Provider-Test-Connection).

Neuer isPrivateOrBlockedHost() in ssrfGuard.ts: union aus
BLOCKED_PATTERNS (Metadata/Multicast/Reserved) und
PRIVATE_IP_PATTERNS (10/8, 172.16/12, 192.168/16, 127/8, ::1,
fc00::/7) + PRIVATE_HOSTNAMES (localhost, ip6-loopback), egal was
SSRF_BLOCK_PRIVATE_IPS sagt.

portalLoginUrl-Validator nutzt jetzt isPrivateOrBlockedHost +
strippt eckige Klammern aus IPv6-Hostnames (Node URL.hostname
liefert "[::1]" inkl. Brackets).

Live-verifiziert: 22 Test-Cases (9 Private/Loopback, 4 Schemes,
7 legitime). Auch CIDR-Grenzen (172.15 zulässig, 172.16/31
blockiert, 172.32 zulässig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:55:44 +02:00
duffyduck 100147107c Pentest 2026-05-28 LOW 34.5: Backend-Validierung für AppSettings
Schema-Whitelist und Trailing-Slash-Strip für portalLoginUrl standen
NUR im Frontend. Der API-Endpoint nahm sonst /relative/path,
javascript:/ftp:/data:-Schemata und private IPs ungeprüft entgegen –
das landet als toter / bösartiger Link in den an Kunden verschickten
Portal-Mails (Open-Redirect / SSRF-Vektor).

Neuer validateSettingValue(key, value) in appSetting.service mit
per-Key-Logik:
  - portalLoginUrl: absolute http(s)-URL, isBlockedSsrfHost-Check
    (Cloud-Metadata immer, private Ranges via SSRF_BLOCK_PRIVATE_IPS),
    Trailing-Slash-Strip.
  - Schwellenwerte (deadline*/documentExpiry*): positive Integer.
  - Bool-Settings: strict 'true'/'false'.
  - monitoringAlertEmail: RFC-5322-light gegen Header-Injection.
  - Andere Keys: kein Format-Check (Default).

Controller (updateSetting + updateSettings) rufen Validator nach
stripHtml; bei Fehler HTTP 400 mit klarer Message. Bulk-PUT
validiert ALLE Werte VOR dem ersten DB-Write – kein halb-committed
State bei einem ungültigen Eintrag.

Live-verifiziert auf dev: alle Test-Payloads aus dem Pentest
sauber abgelehnt, legitime Werte (https-URL, Trailing-Slash, Pfade)
korrekt akzeptiert + normalisiert.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:55 +02:00
duffyduck ee4ca9df07 Zugangsdaten-Card: Portal-Link des Anbieters anzeigen
Im Vertragsdetail unter "Zugangsdaten" zwischen Benutzername und
Passwort jetzt eine zusätzliche Zeile "Portal-Link" mit klickbarem
Link zum Anbieter-Portal (öffnet in neuem Tab, mit Copy-Button).
Greift auf das bestehende c.provider.portalUrl-Feld zurück (wird
auch schon für den Auto-Login-Button verwendet).

Schema und Host werden im Anzeigetext gestrippt, die volle URL
bleibt im href und im title-Attribut sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:27:02 +02:00
duffyduck 9385fc0f11 fix: Portal-E-Mail-Feld konnte nur per Paste befüllt werden
Folge-Symptom des Pentest-29.4-Email-Validators: das Portal-Email-
Input feuerte bei jedem Keystroke einen PUT
/customers/:id/portal mit dem Zwischenstand ("p", "po", "por@") –
der Backend-Validator lehnte das mit 400 ab, der Server-State blieb
unverändert, das Input re-renderte mit dem alten Wert. Effekt: man
konnte nichts tippen, nur per Paste in einem Event eine
vollständige Adresse setzen.

Fix: lokaler emailDraft-State. Während getippt wird, bleibt der
Wert nur im Client. Commit erfolgt erst onBlur oder bei Enter –
oder wird mit Escape verworfen. Bei Mutations-Error gibt's jetzt
auch einen toast statt stiller Revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:20:36 +02:00
duffyduck c9f4fcf8de fix: Portal-Passwort-Card im Vertragsdetail wieder sichtbar
Folge-Symptom zum PW-Save-Fix: das Speichern hat funktioniert,
aber die "Zugangsdaten"-Card im Read-Only-View hat das Passwort-
Feld nicht angezeigt. Ursache: das Frontend nutzte
`c.portalPasswordEncrypted` als Truthy-Check, aber
sanitizeContract strippt das Feld bewusst aus jeder Response
(Pentest Runde 15 - kein verschlüsselter Blob in /contracts/:id).

Fix: getContractById hängt jetzt ein virtuelles `hasPortalPassword`-
Bool-Flag an die Response. Frontend nutzt das statt
portalPasswordEncrypted. Der verschlüsselte Wert bleibt
server-seitig; der Klartext kommt weiterhin über
GET /contracts/:id/password mit Audit-Log.

Live-verifiziert: PUT setzt PW, GET liefert hasPortalPassword:true
+ portalPasswordEncrypted ist NICHT in der Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:30:54 +02:00
duffyduck f41d1843e4 fix: Portal-Passwörter im Vertrag wurden mutiliert
Folgefehler aus Pentest 31.1: die rekursive sanitizeContractBody()
lief auch über portalPassword. Passwörter mit HTML-Pattern
("Pass<TAG>word!" → "Password!") oder URI-Schema-Prefix
("data:secret" → "blocked:secret") wurden vom stripHtml-Strip
zerstört, bevor die Service-Schicht sie verschlüsseln konnte.

Fix: PASSTHROUGH_KEYS = {portalPassword, password}. Beim Walk
werden String-Werte unter diesen Keys NICHT gefiltert. Passwort
wird sowieso encrypt()-verschlüsselt in die DB geschrieben und
niemals als HTML ausgegeben – kein XSS-Risk.

Live-verifiziert:
- PUT portalPassword="MyP@ss<word>123!&data:foo"
  → GET /password decrypt liefert byte-identischen Wert
- PUT providerName="<script>...EvilProvider" → DB: "EvilProvider"
  (XSS-Schutz weiter aktiv)
- PUT portalUsername="u<test>" → DB: "u" (Plain-Text-User wird
  weiter gestrippt, ist kein Passwort)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:05:26 +02:00
duffyduck aa0900410b Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)
31.1 Stored XSS in Vertragsfeldern:
providerName, tariffName, priceFirst12Months, priceFrom13Months,
priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>,
<svg/onload>, <img onerror>, javascript:, HTML-Entities) an und
lieferten sie 1:1 an Portal-User zurueck.

Fix: rekursiver sanitizeContractBody()-Walker im contract.controller,
strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip +
URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen
HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die
sanitisierte Variante, sonst Audit ↔ DB-Drift.

31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere):
requireCustomerAccess short-circuitete auf customers:read. Portal-
User haben aber genau diese Perm im JWT (für eigene Daten) – damit
kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/
Stressfrei-Emails von Kunde 3.

Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene
IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle
sechs requireCustomerAccess-Routes dicht. Defense-in-Depth:
zusätzlicher canAccessCustomer-Call in
stressfreiEmail.getEmailsByCustomer analog zum POST-Handler.

Live-verifiziert auf dev:
- Portal-User 1 → Customer 3: alle 6 Routes 403
- XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:38:16 +02:00
duffyduck 897abc7b21 Datenschutzerklärung als unterschreibbare PDF-Vorlage
Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf
generiert eine PDF mit:
- Titel
- Personalisiertem Kopf (Name / Firma + Kundennummer + Datum)
- Voller Datenschutzerklärung (HTML → Text)
- Einwilligungsklausel
- Unterschriftenblock (Ort/Datum links, Unterschrift rechts,
  zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem
  Kundennamen)

Auth: customers:read + canAccessCustomer. Filename:
"datenschutzerklaerung-<kundennummer>.pdf".

Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt
direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben"
– Ausdrucken, unterschreiben lassen, scannen, wieder hochladen.

Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende,
2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift-
Linien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:30:11 +02:00
duffyduck 69a52ffe03 README: Admin-Initial-Passwort ist seit Pentest-12 zufällig
Die README versprach weiterhin "admin@admin.com / admin" als Default,
aber Pentest Runde 12 hat das hardcoded "admin" entfernt
(Komplexitäts-Policy-Verletzung). Der Seed generiert jetzt ein
28-Zeichen-Zufallspasswort und schreibt es einmal nach stdout.

Aktualisiert:
- Quick-Start-Header: Hinweis statt direktes Passwort
- "Erste Inbetriebnahme"-Block: docker-logs-Befehl + SEED_ADMIN_PASSWORD-Alternative
- "Erster Login"-Sektion: vollständige Anleitung inkl. Beispiel-Ausgabe
- "Production-Deployment"-Checkliste: aktualisiert
- .env.example: SEED_ADMIN_PASSWORD-Block dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:56:55 +02:00
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