Commit Graph

31 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 0cf3dd6a7b Security-Hardening Runde 10: Security-Monitoring + Alerting
Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code
verhindert wurde: zumindest gesehen + alarmiert werden.

📊 SecurityEvent-Tabelle (Prisma)
- Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection
- Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet,
  SecurityEvent ist optimiert für Realtime-Alerting + Aggregation.

🪝 Hooks an kritischen Stellen
- Login (Success/Failed) – auth.controller
- Logout, Password-Reset (Request + Confirm) – auth.controller
- Rate-Limit-Hit – middleware/rateLimit
- IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract)
- SSRF-Block – emailProvider.controller (test-connection + test-mail-access)
- JWT-Reject (alg=none, expired, manipuliert) – middleware/auth

🚨 Threshold-Detection + Alerting (securityAlert.service.ts)
- Cron jede Minute: prüft Brute-Force-Patterns je IP
  - 10× LOGIN_FAILED in 60 min  → CRITICAL Brute-Force-Verdacht
  -  5× ACCESS_DENIED in 5 min  → CRITICAL IDOR-Probing-Verdacht
  -  3× SSRF_BLOCKED in 60 min  → CRITICAL SSRF-Probing
  -  3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
- CRITICAL-Events: Sofort-Alert per E-Mail (debounced)
- Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert)
- Sofort-Alert + Digest laufen über System-E-Mail-Provider
  (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset)

🖥 Frontend: Settings → "Sicherheits-Monitoring"
- Alert-E-Mail-Adresse + Digest-Toggle
- Test-Alert-Button + Digest-jetzt-Button
- Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO)
- Filter (Type/Severity/Search/IP) + Pagination
- Auto-Refresh alle 30 s
- Verlinkt aus Settings-Übersicht (settings:read Permission)

🧪 Live-verifiziert
- Login-Fehlversuch → LOGIN_FAILED Event
- Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED
- SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event
- 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s
- CRITICAL-Sofort-Alert binnen 30s zugestellt
- Test-Alert-Button: E-Mail zugestellt
- Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:25:47 +02:00
duffyduck 6b804cdc82 Security-Hardening Runde 8: DNS-Rebinding + Per-File-Ownership
Loose Ends aus Runde 5/7 abgearbeitet.

🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
  gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
  servername=hostname an die Mail-Services. Damit kann ein zweiter
  DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
  umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
  erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.

🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
  mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
  invoices, cancellation-*, authorizations, business-/commercial-/
  privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
  Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
  er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
  intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...

Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
  Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
  DNS-Lookup geblockt; gmail.com (legitim) geht durch.

Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
  invasiv für <a href>-Flows ohne JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:59:19 +02:00
duffyduck 0c0cecdbbd Security-Hardening Runde 6: Customer-Liste-Leak + XFF-Bypass + Vollmacht-Validation
Tiefer Live-Pentest deckte 3 weitere Schwachstellen:

🚨 CRITICAL: GET /api/customers leakte komplette Kundendatenbank
- Stage-4 hatte canAccessCustomer auf den Single-Endpoint angewendet,
  der List-Endpoint hatte nur den Daten-Sanitizer (filtert Passwort-Hashes)
  aber keinen Portal-Filter. Folge: Portal-Kunde sah ALLE Kunden mit Namen,
  E-Mails, customerNumber etc. – DSGVO-relevant.
- Fix: getCustomers filtert für Portal-User auf eigene + vertretene IDs.

🚨 HIGH: Rate-Limit-Bypass via X-Forwarded-For
- `trust proxy = 1` hat jedem XFF-Wert vertraut. 12+ Logins mit
  rotierender XFF-IP gingen ohne 429 durch.
- Fix: `trust proxy = 'loopback'` – XFF nur noch von 127.0.0.1 / ::1
  akzeptiert (= lokaler Reverse-Proxy).
- Plus: LISTEN_ADDR-Default 127.0.0.1 in Production, damit das Backend
  nicht von außen direkt ansprechbar ist.

🛡 MEDIUM: Self-Grant + Existence-Disclosure in toggleMyAuthorization
- Portal-User konnte:
  a) sich selbst Vollmacht erteilen (customerId=representativeId=1)
  b) Authorization-Records für nicht-existierende customerIds anlegen
     (scheitert erst am DB-Constraint mit vollem Prisma-Stack-Leak)
  c) Customer-IDs durch 404-vs-403-Differenzen enumerieren.
- Fix: Self-Grant 400. Existenz + aktive CustomerRepresentative-Beziehung
  in einem Query – Non-Existent / Non-Related geben identisch 403.
  Prisma-Error-Stacks generisch ersetzt.

Live-verifiziert: Customer-Liste filtert, Self-Grant 400, Existence-Probing
dicht.

Geprüft + sauber (Runde 6, kein Bug):
- Prototype Pollution Login-Body
- HTTP-Method-Override-Header
- Path-Traversal Backup-Name (Regex blockt)
- Developer-Routes existieren nicht
- Email-Endpoints mit fremder StressfreiEmail-ID → 403
- /api/customers/:id GET liefert 403 statt 404 (kein Leak)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:14:20 +02:00
duffyduck 35745ce3bb Security-Hardening Runde 5: Hack-Das-Ding (DSGVO-GAU + Timing + XSS)
Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents.

🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar
- express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL
  sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten,
  Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden
  ohne Login geladen.
- Fix: authenticate-Middleware vor static-Handler (req.query.token
  unterstützung war schon da, jetzt aktiv genutzt).
- Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte
  /api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail,
  ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard).

🚨 HIGH: Login-Timing User-Enumeration
- bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms
  vs 10ms Differenz, Email-Enumeration trivial messbar.
- Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy-
  Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus
  Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und
  Echt-Hash-Cost zusammenpassen.
- Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid)
  – Side-Channel dicht.

🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML
- 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify
  (PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint).
  Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden-
  Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite.
- Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config.

🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints
- canAccessContract jetzt in: uploadContractDocument,
  deleteContractDocument, handleContractDocumentUpload (Kündigungs-
  Letter+Confirmation), handleContractDocumentDelete,
  saveAttachmentAsContractDocument.
- Defense-in-Depth: aktuell durch requirePermission abgesichert,
  schützt auch gegen künftige Staff-Scoping-Rollen.

Offen für v1.1:
- Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup
  welche Ressource zur Datei gehört)
- TipTap-Link-Tool javascript:-Protokoll blockieren
- Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:21:37 +02:00
duffyduck 4e680a36e7 Auto-Vertragsstatus: nightly EXPIRED + Kündigungsbestätigung → CANCELLED
- Neuer Scheduler (02:00 + Catch-up 60s nach Start): alle ACTIVE-Verträge mit
  endDate < heute werden auf EXPIRED umgestellt. Audit-Log pro Vertrag.
- Upload cancellationConfirmationPath: Vertrag wechselt von ACTIVE → CANCELLED
  (mit Audit-Log). "Options"-Upload triggert bewusst nicht, da für
  Vertragsänderungen gedacht, nicht für echte Kündigungen.
- Keine neuen Statuswerte. "Kündigung gesendet vs. bestätigt" bleibt über die
  getrennten Felder cancellationSentDate / cancellationConfirmationDate lesbar,
  Status bleibt bis zur Bestätigung auf ACTIVE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:08:58 +02:00
duffyduck 4ca91eb710 Security-Hardening Runde 4: 9 Live-IDORs + Error-Handler
Live-Pentest gegen Dev-Server mit Portal-Token deckte auf, dass customer.* und
gdpr.* Endpoints nur den Data-Sanitizer, aber KEINEN canAccessCustomer-Check
hatten. Ein Portal-Kunde mit customers:read konnte per ID-Manipulation komplette
Fremddatensätze auslesen.

- customer.controller.getCustomer + getAddresses + getBankCards + getDocuments
  + getMeters + getRepresentatives + getPortalSettings: canAccessCustomer
- gdpr.controller.getCustomerConsents + getAuthorizations + checkConsentStatus:
  canAccessCustomer
- createAddress/createBankCard/createDocument/createMeter (customerId aus URL):
  canAccessCustomer (Defense-in-Depth – wird aktuell schon per Permission
  geblockt, aber im Controller ungeschützt)
- Global Error-Handler: err.status respektieren (PayloadTooLargeError → 413
  "Anfrage zu groß", SyntaxError → 400 "Ungültiges JSON" statt pauschal 500)

Live-verifiziert:
  ✓ /api/customers/4 als Portal → 200 VORHER, 403 NACHHER
  ✓ 9 andere IDOR-Endpoints gleiches Muster
  ✓ Eigene Daten (/api/customers/1) weiter 200
  ✓ 12 MB Body → 413, malformed JSON → 400

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:59:37 +02:00
duffyduck 8aead8c2f6 Security-Hardening Runde 3: JWT, trust-proxy, weitere IDORs, Attachment-Härtung
- JWT-Algorithmus fest auf HS256 (Defense-in-Depth gegen alg-confusion)
- app.set('trust proxy', 1) – Rate-Limiter wirkt jetzt auch hinter Reverse-Proxy
- IDOR-Fix: Invoice-ECD-Endpoints + PDF-Template-Generierung (canAccessContract/ECD)
- Email-Anhang-Download: Content-Type-Safelist, SVG nie inline, nosniff, Filename-CRLF-Sanitize
- Provider/Tariff-GET-Routen: requirePermission('providers:read') (Portal-Kunden raus)
- SMTP-Header-Injection zentral in sendEmail blockiert (schützt alle Caller)
- bcrypt-Cost 10 → 12 (OWASP 2026)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:38:25 +02:00
duffyduck 1c46d7345c Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure
Umfassender Security-Review vor öffentlichem Deployment.
Detaillierter Report in docs/SECURITY-REVIEW.md.

🔴 KRITISCHE FIXES:

1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env),
   in Production per default komplett aus (gleiche Origin erzwingt Browser).

2. Keine Security-Headers → helmet-Middleware hinzugefügt.
   X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP.

3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob
   JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind,
   sonst Fail-Fast mit klarer Fehlermeldung.

4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read'
   konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK,
   Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen).
   Neuer Helper canAccessContract() in utils/accessControl.ts in allen
   betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten.

5. XSS via Email-Body. email.htmlBody wurde ungefiltert via
   dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script>
   schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify
   sanitized: verbietet script/iframe/form/inline-handler, erlaubt
   normale Formatierung + Bilder.

6. Customer-API leakte sensible Felder:
   - portalPasswordHash (bcrypt-Hash)
   - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar)
   - portalPasswordResetToken (gültig 2h)
   Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers.
   Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige),
   alle anderen Rollen nicht.

🟡 WICHTIGE FIXES:

7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld
   Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt.
   Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden
   dadurch invalidiert.

8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar).

Neue Files:
- backend/src/utils/accessControl.ts - IDOR-Schutz
- backend/src/utils/sanitize.ts - Response-Sanitizer
- docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:06:16 +02:00
duffyduck 0764bc6ddf Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße
Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0:

## 1. Passwort vergessen-Flow

Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch
für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht
mehr manuell eingreifen.

- Neues Link "Passwort vergessen?" auf Login-Seite
- PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal)
- PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen)
- Token ist 2 Stunden gültig, dann muss neu angefordert werden
- Token ist kryptografisch sicher (crypto.randomBytes(32))
- User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert
- Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt
  (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war

DB:
- User.passwordResetToken + passwordResetExpiresAt
- Customer.portalPasswordResetToken + portalPasswordResetExpiresAt

## 2. Rate-Limiting gegen Brute-Force

Mit express-rate-limit:
- Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit.
- Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern)

Sowohl Mitarbeiter-Login als auch Portal-Login geschützt.

## 3. Auto-Geburtstagsgrüße per Cron

Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der
ihn wirklich abschickt. Jetzt:

- Läuft täglich um 08:00 Uhr
- Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Nur Email-Kanal (Messenger brauchen Browser-Klick)
- Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird
  beim nächsten Boot nachgeholt
- lastBirthdayGreetingYear verhindert Doppelversand

Dependencies: node-cron, @types/node-cron, express-rate-limit

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00
duffyduck c3edb8ad2e gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery 2026-03-21 11:59:53 +01:00
duffyduck 09e87c951b added docker setup 2026-02-08 19:59:49 +01:00
duffyduck e0fc26795e added contract history 2026-02-08 19:24:37 +01:00
duffyduck ad2b8ea5b6 added invoices and status in cockpit, created info button for contract status types 2026-02-08 01:18:12 +01:00
duffyduck 8c9e61cf17 added backup and email client 2026-02-01 00:02:35 +01:00
Stefan Hacker e209e9bbca first commit 2026-01-29 01:16:54 +01:00