Compare commits
150 Commits
08310ac302
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b23ebeefc3 | |||
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe | |||
| a4895374b9 | |||
| ebaee024b6 | |||
| f1b05c56e5 | |||
| 5293af18a5 | |||
| 4ab0340473 | |||
| f1102a24b7 | |||
| c013e1e747 | |||
| caa283e66f | |||
| 18a2e1173b | |||
| 993f2d10f0 | |||
| f02824fe7d | |||
| 8b10316683 | |||
| 26959ec909 | |||
| c8b86ca9a7 | |||
| 0b7bb89ebc | |||
| 9274c0adaf | |||
| dfe2a4b241 | |||
| 194c86409f | |||
| 2becf6cb6a | |||
| 24e152b201 | |||
| a83358bbe6 | |||
| 5bb048c534 | |||
| b3469483ca | |||
| 8992bb7a5d | |||
| 246999be01 | |||
| 96a054aa1a | |||
| 36beac98c9 | |||
| 60851450f6 | |||
| 523eab30d5 | |||
| 2fee13d09e | |||
| 84cbf01706 | |||
| fcc3b04725 | |||
| 101369c205 | |||
| e792fe4185 | |||
| 7c18343a95 | |||
| 5508d59652 | |||
| 431792e8d9 | |||
| d5dd3f5e7f | |||
| a235c43f40 | |||
| 9cfd2e4a64 | |||
| ec577e6d76 | |||
| 30f528596c | |||
| 358688db9e | |||
| ffb0d81b6a | |||
| 25681075b4 | |||
| ad81a7c93e | |||
| 518139438e | |||
| 5fa9d4d4f3 | |||
| f4ac1c29db | |||
| 6b1d493f0b | |||
| 9482424ade | |||
| a023e96012 | |||
| 72de2f00f3 | |||
| da1934aa2d | |||
| 0f4ffe3c32 | |||
| 71d3ea7a2e | |||
| c3321a2aa9 | |||
| 61daff8df9 | |||
| 57eb29c2a6 | |||
| 5d21574c81 | |||
| 2c0166ed99 | |||
| d0d2715baa | |||
| c58a60db23 | |||
| 9519f0dbca | |||
| cd7075e96f | |||
| 4acfd9de1c | |||
| 9e3bce85f0 | |||
| 4fb700cf57 | |||
| 5269092d2a | |||
| 83f1984f12 | |||
| b9a6d99d50 | |||
| a20e331f83 | |||
| 43aaf697a1 | |||
| b0e45c0ea0 | |||
| 95b7261227 | |||
| 0d024b94c2 | |||
| b4b0dbb004 | |||
| 2ee06630b9 | |||
| 3a9cece929 | |||
| e527aebb84 | |||
| 13213846f4 | |||
| 61ce35821d | |||
| 34e106f253 | |||
| ad4c2bae1d | |||
| 6f378d750c | |||
| c099b41796 | |||
| d92d0b1eaf | |||
| a3fef8891a | |||
| 617022e492 | |||
| c93d4375ab | |||
| 0bd2f9be7e | |||
| 6a670df1c4 | |||
| 7dcdf9d6ef | |||
| 100147107c | |||
| 2d4e4cdcc7 | |||
| ee4ca9df07 | |||
| 9385fc0f11 | |||
| c9f4fcf8de | |||
| f41d1843e4 | |||
| aa0900410b | |||
| 897abc7b21 | |||
| 69a52ffe03 | |||
| 771f46d2ac | |||
| 20d42c5270 | |||
| 92c3b0dc95 | |||
| 83cd737e81 | |||
| a95aa384a2 | |||
| 9cf8c505af | |||
| 65ec07e274 | |||
| 8e48d3b432 | |||
| adc3b70492 | |||
| bf7afdd9a6 | |||
| b3a6620da6 | |||
| 8ee5c9b07a | |||
| 37df8c0c4a | |||
| 95541e8ac4 | |||
| 6ae815393e | |||
| 06c427ee39 | |||
| 2cb6f172c9 | |||
| 0f2dc44e45 | |||
| b87a2a3d08 | |||
| 48fe69cdab | |||
| 373fab8e83 | |||
| 5ffd1a4d2c | |||
| 7b9a1981a7 | |||
| 3dda83314a | |||
| 3e1fc3eab2 | |||
| cf8c6c84c2 | |||
| d545790a69 | |||
| ef238b0145 | |||
| 7b6b586033 | |||
| 3dea381983 | |||
| 28c91759df | |||
| c744eebfa3 | |||
| 956bc394b8 | |||
| 69b9a35674 | |||
| a982795388 | |||
| 38c2d82c02 | |||
| 75c833500e | |||
| a7d12b8540 | |||
| 8534be22d0 | |||
| f0c97cd46d | |||
| 8a5ffbb563 | |||
| 6af1a4bbd4 | |||
| 92d2e62e79 |
+20
-2
@@ -66,6 +66,16 @@ LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127
|
||||
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
|
||||
HTTPS_ENABLED=false
|
||||
|
||||
# SSRF-Schutz: private IP-Ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
|
||||
# 192.168.0.0/16, ::1, fc00::/7, localhost) bei Provider/SMTP-Test-Connection
|
||||
# blockieren. Default `false` damit On-Prem-Setups Plesk/Dovecot/Postfix auf
|
||||
# 127.0.0.1 oder im internen Netz nutzen können. Für Cloud-Deployments
|
||||
# (öffentlich erreichbares Backend) auf `true` setzen, sonst kann ein
|
||||
# eingeloggter Admin via /email-providers/test-connection interne Services
|
||||
# anpingen. Cloud-Metadata-Endpoints (169.254.169.254 etc.) sind UNABHÄNGIG
|
||||
# vom Flag immer geblockt.
|
||||
SSRF_BLOCK_PRIVATE_IPS=false
|
||||
|
||||
# ============== ADMINER (DB-UI) ==============
|
||||
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
|
||||
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
|
||||
@@ -76,9 +86,17 @@ HTTPS_ENABLED=false
|
||||
ADMINER_DESIGN=dracula
|
||||
|
||||
# ============== SEED ==============
|
||||
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
|
||||
# + Stammdaten an) – nichts zu konfigurieren.
|
||||
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com +
|
||||
# Stammdaten an) – nichts zu konfigurieren.
|
||||
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
|
||||
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
|
||||
# wieder zurück.
|
||||
RUN_SEED=false
|
||||
|
||||
# Initial-Passwort für admin@admin.com beim Seed. Mindestens 25 Zeichen,
|
||||
# sonst wird der Wert ignoriert und stattdessen ein 28-Zeichen-Zufalls-
|
||||
# passwort erzeugt und EINMAL nach stdout geschrieben:
|
||||
# docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||
# Wer keine Lust auf die Log-Suche hat, setzt hier ein eigenes starkes
|
||||
# Passwort vor dem ersten `docker compose up -d`.
|
||||
# SEED_ADMIN_PASSWORD=
|
||||
|
||||
@@ -10,7 +10,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Adressen**: Mehrere Liefer-/Melde- und Rechnungsadressen pro Kunde
|
||||
- **Bankkarten**: Mit Ablaufdatum, Aktiv-Status und Dokument-Upload (PDF)
|
||||
- **Ausweise**: Personalausweis, Reisepass, etc. mit Ablaufdatum und Dokument-Upload (PDF)
|
||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie,
|
||||
Pflicht-Zuordnung zur Lieferadresse und Folgezähler-Kette
|
||||
(Auto-Propagation auf alle Verträge mit dem Vorgänger)
|
||||
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
||||
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
||||
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT` → `ACTIVE` (mit Vertragsbeginn),
|
||||
@@ -61,7 +63,7 @@ docker compose up -d
|
||||
```
|
||||
|
||||
Browser:
|
||||
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
|
||||
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` – Initial-Passwort siehe unten)
|
||||
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
|
||||
|
||||
Alle persistenten Daten liegen in `./data/`:
|
||||
@@ -75,9 +77,22 @@ Alle persistenten Daten liegen in `./data/`:
|
||||
|
||||
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
|
||||
|
||||
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
|
||||
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
|
||||
> der initiale Admin-User `admin@admin.com` / `admin`.
|
||||
> **Erste Inbetriebnahme:** Bei leerer DB läuft der Seed automatisch
|
||||
> (kein Eingriff nötig). Nur wenn du eine bereits befüllte DB erneut
|
||||
> seeden willst, einmalig `RUN_SEED=true` in der `.env` setzen,
|
||||
> `docker compose up -d` ausführen, dann wieder auf `false`.
|
||||
>
|
||||
> Der initiale Admin-User ist **`admin@admin.com`**. Das Passwort wird
|
||||
> beim Seed zufällig generiert (28 Zeichen) und genau einmal in den
|
||||
> Container-Logs ausgegeben:
|
||||
> ```bash
|
||||
> docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||
> ```
|
||||
> Alternativ kannst du **vor dem ersten Start** ein eigenes Passwort
|
||||
> setzen, indem du `SEED_ADMIN_PASSWORD=…` (mindestens 25 Zeichen, sonst
|
||||
> wird's ignoriert) in der `.env` einträgst. Nach dem ersten Login
|
||||
> sollte das Passwort über *Einstellungen → Mein Profil* geändert
|
||||
> werden.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -186,11 +201,33 @@ Das Frontend läuft auf `http://localhost:5173`
|
||||
|
||||
Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
||||
|
||||
- **E-Mail:** admin@admin.com
|
||||
- **Passwort:** admin
|
||||
- **E-Mail:** `admin@admin.com`
|
||||
- **Passwort:** wird beim Seed zufällig generiert (28 Zeichen) und einmal
|
||||
in den Container-Logs ausgegeben:
|
||||
```bash
|
||||
docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||
```
|
||||
Beispiel-Ausgabe:
|
||||
```
|
||||
========================================================
|
||||
Admin-User: admin@admin.com
|
||||
Initial-Passwort: <28-zeichen-zufalls-passwort>
|
||||
⚠️ Dieses Passwort wird hier EINMAL ausgegeben!
|
||||
Bitte sofort nach dem ersten Login ändern.
|
||||
========================================================
|
||||
```
|
||||
Wer das Log nicht mehr findet (z.B. Container schon länger gerollt),
|
||||
setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` in der `.env` und startet
|
||||
einmalig mit `RUN_SEED=true` neu – der Seed ist idempotent und
|
||||
überschreibt das Admin-Passwort. Danach `RUN_SEED` wieder auf `false`.
|
||||
|
||||
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
|
||||
> ändern und Secrets rotieren – siehe [Production-Deployment](#production-deployment).
|
||||
> **Tipp:** Wer von Anfang an ein eigenes Admin-Passwort haben möchte,
|
||||
> setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` **bevor** der erste
|
||||
> `docker compose up -d` läuft. Der Wert wird dann statt des Zufalls-
|
||||
> passworts verwendet (siehe `.env.example`).
|
||||
|
||||
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
||||
> siehe [Production-Deployment](#production-deployment).
|
||||
|
||||
## Production-Deployment
|
||||
|
||||
@@ -210,6 +247,25 @@ LISTEN_ADDR=127.0.0.1
|
||||
CORS_ORIGINS=https://crm.deine-domain.de
|
||||
```
|
||||
|
||||
### Deployment-Modus: On-Prem vs. Cloud
|
||||
|
||||
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
|
||||
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
|
||||
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
|
||||
SSRF-Schalter:
|
||||
|
||||
```env
|
||||
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
|
||||
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
|
||||
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
|
||||
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
|
||||
SSRF_BLOCK_PRIVATE_IPS=true
|
||||
```
|
||||
|
||||
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
||||
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
||||
AWS/GCP/Azure-IMDS-Diebstahl.
|
||||
|
||||
Plus:
|
||||
|
||||
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
|
||||
@@ -221,7 +277,9 @@ Plus:
|
||||
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
|
||||
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
|
||||
auf derselben Domain via Proxy-Path.
|
||||
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
|
||||
- **Admin-Initial-Passwort ändern**: das beim Seed generierte Zufalls-Passwort
|
||||
(siehe [Erster Login](#erster-login)) im Profil überschreiben – oder gleich
|
||||
per `SEED_ADMIN_PASSWORD` in der `.env` vor dem ersten Start setzen.
|
||||
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
||||
durchklicken.
|
||||
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
||||
@@ -668,11 +726,15 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
|
||||
|
||||
#### Strom & Gas (ELECTRICITY, GAS)
|
||||
|
||||
- Zähler-Auswahl
|
||||
- Jahresverbrauch (kWh/m³)
|
||||
- Grundpreis, Arbeitspreis
|
||||
- Bonus
|
||||
- Zähler-Auswahl (gefiltert auf die Lieferadresse des Vertrags)
|
||||
- Jahresverbrauch (kWh/m³) – bei Folgeverträgen mit Schätzwert
|
||||
aus dem Vorvertrag und 1-Klick-Übernahme
|
||||
- Grundpreis, Arbeitspreis (HT/NT bei Zweitarif)
|
||||
- Sofort-Bonus, Neukunden-Bonus
|
||||
- Vorversorger, Kundennummer beim Vorversorger
|
||||
- Folgezähler-Workflow (Zählerwechsel): Wechseldatum + optionaler
|
||||
Endstand des alten Zählers, der automatisch als Zählerstand
|
||||
erfasst wird und in die Verbrauchsberechnung einfließt
|
||||
|
||||
#### Internet (DSL, CABLE, FIBER)
|
||||
|
||||
@@ -696,6 +758,8 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
|
||||
- Rufnummer, SIM-Kartennummer (ICCID)
|
||||
- PIN, PUK (verschlüsselt)
|
||||
- Multisim-Flag, Hauptkarte-Flag
|
||||
- Kartennutzer (optional) – tatsächlicher Nutzer bei Firmen-/
|
||||
Familienverträgen, kann vom Vertragsinhaber abweichen
|
||||
|
||||
> **Hinweis Multisim:** Nicht buchbar bei Klarmobil, Congstar, Otelo. Benötigt Freenet oder vergleichbar.
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
# Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
|
||||
JWT_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
# Encryption (for portal credentials)
|
||||
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
||||
|
||||
@@ -46,6 +46,12 @@ COPY --from=backend-builder /build/backend/dist ./dist
|
||||
COPY --from=backend-builder /build/backend/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=backend-builder /build/backend/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY backend/prisma ./prisma
|
||||
# src/ mitkopieren, damit prisma/*.ts-Wartungsskripte (cleanup, reset-admin-
|
||||
# password etc.) auch im Production-Container via `npx tsx` laufen können –
|
||||
# die importieren über '../src/lib/prisma.js' den shared Prisma-Client.
|
||||
# Server selbst läuft weiter aus dist/.
|
||||
COPY --from=backend-builder /build/backend/src ./src
|
||||
COPY backend/tsconfig.json ./tsconfig.json
|
||||
|
||||
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
|
||||
COPY --from=frontend-builder /build/frontend/dist ./public
|
||||
|
||||
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
|
||||
fi
|
||||
echo "[entrypoint] DB-Schema aktuell"
|
||||
|
||||
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
|
||||
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
|
||||
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
|
||||
# generate` am Start regeneriert den Client gegen das aktuelle Schema
|
||||
# und kostet ~5–10 s – tradeoff für Robustheit.
|
||||
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
|
||||
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen – nicht kritisch, Client bleibt aus Image"
|
||||
|
||||
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
||||
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||
USER_COUNT=$(node -e "
|
||||
@@ -122,5 +130,22 @@ if [ "$RAN_SEED" = "true" ] && [ -d /app/factory-defaults-builtin ] \
|
||||
|| echo "[entrypoint] Factory-Defaults-Seed fehlgeschlagen – ignoriert"
|
||||
fi
|
||||
|
||||
# Permissions + Rollen-Sync: Stellt sicher, dass nachträglich hinzugefügte
|
||||
# Permissions (z.B. audit:read auf der DSGVO-Rolle) auch auf bestehenden
|
||||
# DBs ankommen. Seed läuft NICHT auf nicht-leeren DBs, daher würden alte
|
||||
# Installationen sonst mit unvollständigen Role-Perms laufen. Idempotent,
|
||||
# fasst keine Stammdaten / User / Verträge an.
|
||||
echo "[entrypoint] Rollen + Permissions synchronisieren…"
|
||||
npx tsx prisma/sync-roles.ts \
|
||||
|| echo "[entrypoint] Role-Sync fehlgeschlagen – nicht kritisch"
|
||||
|
||||
# Datenbereinigung: XSS-Strings aus Customer/User-Stringfeldern strippen,
|
||||
# nicht-whitelisted AppSettings entfernen, Pentest-Marker melden (Default
|
||||
# nur warnen; CLEANUP_PURGE_PENTEST=true löscht markierte Records).
|
||||
# Idempotent – läuft bei jedem Container-Start ohne Risiko.
|
||||
echo "[entrypoint] Datenbereinigung läuft…"
|
||||
npx tsx prisma/cleanup-xss-and-mass-assignment.ts \
|
||||
|| echo "[entrypoint] Cleanup übersprungen / fehlgeschlagen – nicht kritisch"
|
||||
|
||||
echo "[entrypoint] Starte Backend…"
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
|
||||
*
|
||||
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
|
||||
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
|
||||
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
|
||||
*
|
||||
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
|
||||
* mehrfach aufrufbar.
|
||||
*/
|
||||
import prisma from '../src/lib/prisma.js';
|
||||
import { stripHtml, isValidDocumentPath as isValidDocumentPathShared } from '../src/utils/sanitize.js';
|
||||
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||||
|
||||
const CUSTOMER_STRING_FIELDS = [
|
||||
'salutation', 'firstName', 'lastName', 'companyName',
|
||||
'birthPlace', 'email', 'phone', 'mobile',
|
||||
'taxNumber', 'commercialRegisterNumber', 'notes',
|
||||
];
|
||||
|
||||
const USER_STRING_FIELDS = [
|
||||
'firstName', 'lastName', 'email',
|
||||
'whatsappNumber', 'telegramUsername', 'signalNumber',
|
||||
];
|
||||
|
||||
async function cleanupXss() {
|
||||
const customers = await prisma.customer.findMany();
|
||||
let touched = 0;
|
||||
for (const c of customers) {
|
||||
const updates: Record<string, string> = {};
|
||||
for (const field of CUSTOMER_STRING_FIELDS) {
|
||||
const v = (c as any)[field];
|
||||
if (typeof v === 'string') {
|
||||
const cleaned = stripHtml(v) as string;
|
||||
if (cleaned !== v) updates[field] = cleaned;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||||
await prisma.customer.update({ where: { id: c.id }, data: updates });
|
||||
touched++;
|
||||
}
|
||||
}
|
||||
console.log(` → Customer bereinigt: ${touched}`);
|
||||
|
||||
const users = await prisma.user.findMany();
|
||||
let userTouched = 0;
|
||||
for (const u of users) {
|
||||
const updates: Record<string, string> = {};
|
||||
for (const field of USER_STRING_FIELDS) {
|
||||
const v = (u as any)[field];
|
||||
if (typeof v === 'string') {
|
||||
const cleaned = stripHtml(v) as string;
|
||||
if (cleaned !== v) updates[field] = cleaned;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||||
await prisma.user.update({ where: { id: u.id }, data: updates });
|
||||
userTouched++;
|
||||
}
|
||||
}
|
||||
console.log(` → User bereinigt: ${userTouched}`);
|
||||
}
|
||||
|
||||
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
|
||||
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
|
||||
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
|
||||
const HTML_ALLOWED_SETTING_KEYS = new Set([
|
||||
'authorizationTemplateHtml',
|
||||
'imprintHtml',
|
||||
'privacyPolicyHtml',
|
||||
'websitePrivacyPolicyHtml',
|
||||
]);
|
||||
|
||||
function stripHtmlString(s: string): string {
|
||||
return s
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
|
||||
}
|
||||
|
||||
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
|
||||
// auf 'unknown' normalisiert. Pentest 2026-05-20.
|
||||
const ALLOWED_CONSENT_SOURCES = new Set([
|
||||
'portal',
|
||||
'public-link',
|
||||
'telefon',
|
||||
'papier',
|
||||
'email',
|
||||
'crm-backend',
|
||||
]);
|
||||
|
||||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize – wir
|
||||
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
|
||||
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
|
||||
const isValidDocumentPath = isValidDocumentPathShared;
|
||||
|
||||
async function cleanupConsents() {
|
||||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||
// source: Whitelist erzwingen.
|
||||
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
|
||||
let versionStripped = 0;
|
||||
let pathNulled = 0;
|
||||
let sourceFixed = 0;
|
||||
const consents = await prisma.customerConsent.findMany({
|
||||
select: { id: true, source: true, documentPath: true, version: true },
|
||||
});
|
||||
for (const c of consents) {
|
||||
const data: Record<string, string | null> = {};
|
||||
if (c.version && c.version !== stripHtmlString(c.version)) {
|
||||
data.version = stripHtmlString(c.version);
|
||||
versionStripped++;
|
||||
}
|
||||
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
|
||||
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
|
||||
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
|
||||
data.documentPath = null;
|
||||
pathNulled++;
|
||||
}
|
||||
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
|
||||
data.source = 'unknown';
|
||||
sourceFixed++;
|
||||
}
|
||||
if (Object.keys(data).length > 0) {
|
||||
await prisma.customerConsent.update({ where: { id: c.id }, data });
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
|
||||
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
|
||||
);
|
||||
}
|
||||
|
||||
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
|
||||
// server-seitig vom multer-Upload erzeugt – falls dort doch mal ein
|
||||
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
|
||||
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
|
||||
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
|
||||
// löschen aber nicht (Records müssten manuell angeschaut werden).
|
||||
async function cleanupDocumentPaths() {
|
||||
const findings: { table: string; id: number; value: string }[] = [];
|
||||
|
||||
const optional: Array<{
|
||||
label: string;
|
||||
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
|
||||
update: (id: number) => Promise<unknown>;
|
||||
}> = [
|
||||
{
|
||||
label: 'BankCard',
|
||||
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
|
||||
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
|
||||
},
|
||||
{
|
||||
label: 'IdentityDocument',
|
||||
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
|
||||
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
|
||||
},
|
||||
{
|
||||
label: 'Invoice',
|
||||
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
|
||||
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
|
||||
},
|
||||
{
|
||||
label: 'RepresentativeAuthorization',
|
||||
fetch: () => prisma.representativeAuthorization.findMany({
|
||||
select: { id: true, documentPath: true },
|
||||
}),
|
||||
update: (id) => prisma.representativeAuthorization.update({
|
||||
where: { id }, data: { documentPath: null },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
let nulled = 0;
|
||||
for (const t of optional) {
|
||||
const rows = await t.fetch();
|
||||
for (const r of rows) {
|
||||
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
|
||||
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
|
||||
await t.update(r.id);
|
||||
nulled++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
|
||||
const contractDocs = await prisma.contractDocument.findMany({
|
||||
select: { id: true, documentPath: true },
|
||||
});
|
||||
let contractDocsDirty = 0;
|
||||
for (const d of contractDocs) {
|
||||
if (!isValidDocumentPath(d.documentPath)) {
|
||||
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
|
||||
contractDocsDirty++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
|
||||
for (const f of findings.slice(0, 10)) {
|
||||
console.log(` [${f.table}#${f.id}] "${f.value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reportOrphanedUsers() {
|
||||
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
|
||||
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
|
||||
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
|
||||
// Spezial-User treffen) – nur warnen.
|
||||
const orphans = await prisma.user.findMany({
|
||||
where: { roles: { none: {} } },
|
||||
select: { id: true, email: true, createdAt: true },
|
||||
});
|
||||
if (orphans.length === 0) {
|
||||
console.log(' → Keine User ohne Rollenzuordnung.');
|
||||
return;
|
||||
}
|
||||
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
|
||||
for (const u of orphans.slice(0, 10)) {
|
||||
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
|
||||
}
|
||||
console.log(' → Rolle zuweisen oder User löschen.');
|
||||
}
|
||||
|
||||
async function cleanupAppSettings() {
|
||||
const settings = await prisma.appSetting.findMany();
|
||||
const removed: string[] = [];
|
||||
let stripped = 0;
|
||||
for (const s of settings) {
|
||||
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||
removed.push(s.key);
|
||||
await prisma.appSetting.delete({ where: { key: s.key } });
|
||||
continue;
|
||||
}
|
||||
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||
const cleaned = stripHtmlString(s.value);
|
||||
if (cleaned !== s.value) {
|
||||
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
|
||||
stripped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
|
||||
if (stripped > 0) {
|
||||
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
|
||||
// gefasst – legitime Kunden mit "Hacker" als Nachnamen sollen nicht
|
||||
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
|
||||
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
|
||||
// `hacker@familie-hacker.de` o.ä. fängen.
|
||||
const PENTEST_MARKERS = [
|
||||
/@evil\./i,
|
||||
/^attacker@/i,
|
||||
/^pentest@/i,
|
||||
/<script\b/i, // unverwechselbarer XSS-Marker
|
||||
/\bonerror\s*=/i, // <img onerror=…>
|
||||
/javascript:/i, // javascript:-URL
|
||||
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
|
||||
/\.\.\/.*etc\/passwd/i, // Path-Traversal
|
||||
];
|
||||
|
||||
function looksLikePentestData(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
return PENTEST_MARKERS.some((re) => re.test(value));
|
||||
}
|
||||
|
||||
async function findOrPurgePentestRecords() {
|
||||
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
|
||||
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
|
||||
|
||||
const customers = await prisma.customer.findMany();
|
||||
for (const c of customers) {
|
||||
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
|
||||
if (looksLikePentestData((c as any)[f])) {
|
||||
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const users = await prisma.user.findMany();
|
||||
for (const u of users) {
|
||||
for (const f of ['email', 'firstName', 'lastName']) {
|
||||
if (looksLikePentestData((u as any)[f])) {
|
||||
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suspect.length === 0) {
|
||||
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` → ${suspect.length} verdächtige Records (Pentest-Marker):`);
|
||||
for (const s of suspect) {
|
||||
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
|
||||
}
|
||||
|
||||
if (!purge) {
|
||||
console.log(' ℹ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
|
||||
console.log(' oder Records manuell über adminer entfernen.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of suspect) {
|
||||
if (s.kind === 'Customer') {
|
||||
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
|
||||
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||||
});
|
||||
} else if (s.kind === 'User') {
|
||||
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
|
||||
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` → ${suspect.length} verdächtige Records gelöscht.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
|
||||
await cleanupXss();
|
||||
await cleanupAppSettings();
|
||||
await cleanupConsents();
|
||||
await cleanupDocumentPaths();
|
||||
await reportOrphanedUsers();
|
||||
await findOrPurgePentestRecords();
|
||||
console.log('=== Fertig. ===');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Cleanup fehlgeschlagen:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
-- IF NOT EXISTS: macht das Hochziehen auf prod-DBs sicher, die das Feld
|
||||
-- über `prisma db push` schon erhalten haben (vor dem Migrations-Workflow).
|
||||
-- MariaDB unterstützt das seit 10.0.2, MySQL 8 ebenfalls.
|
||||
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
|
||||
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
|
||||
--
|
||||
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
|
||||
-- jemand vorher manuell `prisma db push` gefahren hat.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `BackupLog` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
|
||||
`backupName` VARCHAR(191) NULL,
|
||||
`success` BOOLEAN NOT NULL,
|
||||
`durationMs` INTEGER NOT NULL DEFAULT 0,
|
||||
`summary` TEXT NOT NULL,
|
||||
`fullLog` LONGTEXT NOT NULL,
|
||||
`userId` INTEGER NULL,
|
||||
`userEmail` VARCHAR(191) NULL,
|
||||
`ipAddress` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
|
||||
INDEX `BackupLog_createdAt_idx`(`createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen.
|
||||
-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei
|
||||
-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus).
|
||||
-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||
-- `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `EnergyContractDetails`
|
||||
ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL,
|
||||
ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL;
|
||||
|
||||
-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'EnergyContractDetails'
|
||||
AND COLUMN_NAME = 'bonus'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col_exists > 0,
|
||||
'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL',
|
||||
'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Alte Spalte droppen, falls vorhanden
|
||||
ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Zähler bekommen eine Lieferadresse, an der sie installiert sind.
|
||||
-- Optional, damit Bestandszähler (vor diesem Feature) nicht brechen –
|
||||
-- die werden manuell nachgepflegt. ON DELETE SET NULL, damit ein
|
||||
-- gelöschter Adresseintrag den Zähler nicht killt.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||
-- `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `Meter`
|
||||
ADD COLUMN IF NOT EXISTS `addressId` INT NULL;
|
||||
|
||||
-- Index nur anlegen, wenn er noch nicht da ist
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'Meter'
|
||||
AND INDEX_NAME = 'Meter_addressId_fkey'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@idx_exists = 0,
|
||||
'CREATE INDEX `Meter_addressId_fkey` ON `Meter`(`addressId`)',
|
||||
'SELECT "Index existiert bereits"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Foreign Key nur anlegen, wenn er noch nicht da ist
|
||||
SET @fk_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'Meter'
|
||||
AND CONSTRAINT_NAME = 'Meter_addressId_fkey'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@fk_exists = 0,
|
||||
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||
'SELECT "FK existiert bereits"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Folgezähler-Kette: Meter zeigt optional auf den Vorgänger.
|
||||
-- Beim Wechsel können wir dann sowohl die Kette für die UI anzeigen
|
||||
-- als auch alle Verträge mit dem Vorgänger automatisch auf den
|
||||
-- Nachfolger umstellen.
|
||||
--
|
||||
-- ON DELETE SET NULL, damit ein versehentlich gelöschter Vorgänger
|
||||
-- den Nachfolger nicht killt.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
|
||||
-- schon `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `Meter`
|
||||
ADD COLUMN IF NOT EXISTS `predecessorMeterId` INT NULL;
|
||||
|
||||
-- Index nur anlegen, wenn er noch nicht da ist
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'Meter'
|
||||
AND INDEX_NAME = 'Meter_predecessorMeterId_fkey'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@idx_exists = 0,
|
||||
'CREATE INDEX `Meter_predecessorMeterId_fkey` ON `Meter`(`predecessorMeterId`)',
|
||||
'SELECT "Index existiert bereits"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Foreign Key nur anlegen, wenn er noch nicht da ist
|
||||
SET @fk_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'Meter'
|
||||
AND CONSTRAINT_NAME = 'Meter_predecessorMeterId_fkey'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@fk_exists = 0,
|
||||
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_predecessorMeterId_fkey` FOREIGN KEY (`predecessorMeterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||
'SELECT "FK existiert bereits"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- SIM-Karte bekommt einen optionalen "Kartennutzer" – relevant bei Firmen-
|
||||
-- und Familienverträgen, wo der Vertragsinhaber (Firma/Eltern) nicht
|
||||
-- gleich dem tatsächlichen Nutzer (Mitarbeiter/Kind) ist.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||
-- `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `SimCard`
|
||||
ADD COLUMN IF NOT EXISTS `cardUser` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Telefonnummern bekommen eine zusätzliche Spalte `areaCode`, damit
|
||||
-- Wechselauftrag-PDFs die Vorwahl verlässlich befüllen können, ohne
|
||||
-- den heuristischen String-Split, der bei Sonderformaten danebenfasst.
|
||||
--
|
||||
-- `phoneNumber` bleibt unverändert die komplette Nummer (Vorwahl +
|
||||
-- Anschlussnummer), damit Reports/Listen/Suchen weiter funktionieren.
|
||||
-- `areaCode` ist optional, weil Bestandsnummern erst beim nächsten
|
||||
-- Edit nachgepflegt werden.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
|
||||
-- schon `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `PhoneNumber`
|
||||
ADD COLUMN IF NOT EXISTS `areaCode` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Consent-Hash bekommt eine Ablauffrist (Pentest 57.7 MEDIUM).
|
||||
-- Public-Consent-Links liefen vorher nie ab – DSGVO-Risiko, weil ein
|
||||
-- weitergegebener Link Jahre später noch Einwilligungen erteilen konnte.
|
||||
-- 30 Tage Default; nach Ablauf liefert getCustomerByConsentHash null.
|
||||
-- Bestandsdaten ohne Ablaufzeit bekommen `NOW() + 30 Tage` als Frist,
|
||||
-- damit existierende, frisch versendete Links nicht sofort tot sind.
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher.
|
||||
|
||||
ALTER TABLE `Customer`
|
||||
ADD COLUMN IF NOT EXISTS `consentHashExpiresAt` DATETIME(3) NULL;
|
||||
|
||||
UPDATE `Customer`
|
||||
SET `consentHashExpiresAt` = DATE_ADD(NOW(), INTERVAL 30 DAY)
|
||||
WHERE `consentHash` IS NOT NULL
|
||||
AND `consentHashExpiresAt` IS NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- SIM-Karte bekommt ein optionales `isEsim`-Flag – Hardware-Plastikkarte
|
||||
-- vs. eSIM-Profil. UI-Position: zwischen "Hauptkarte" und "Multisim".
|
||||
--
|
||||
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||
-- `prisma db push` gefahren hat.
|
||||
|
||||
ALTER TABLE `SimCard`
|
||||
ADD COLUMN IF NOT EXISTS `isEsim` BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
|
||||
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
|
||||
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
|
||||
|
||||
ALTER TABLE `Contract`
|
||||
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Zusätzliche Weiterleitungs-E-Mails pro StressfreiEmail-Adresse.
|
||||
-- JSON-Array (z.B. `["info@partner.de","cc@kanzlei.de"]`), wird beim
|
||||
-- Sync zusammen mit customer.email + config.defaultForwardEmail an den
|
||||
-- Provider gepusht (`set:`-Befehl überschreibt die Liste).
|
||||
|
||||
ALTER TABLE `StressfreiEmail`
|
||||
ADD COLUMN IF NOT EXISTS `additionalForwardingEmails` TEXT NULL;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- Zusätzliches optionales Feld unter "Anbieter & Tarif": Auftragsnummer bei
|
||||
-- der Vertriebsplattform (vor der Kundennummer). Plattformen liefern beim
|
||||
-- Abschluss oft eine eigene Auftrags-/Vorgangsnummer, die fürs Reklamations-
|
||||
-- handling gebraucht wird.
|
||||
|
||||
ALTER TABLE `Contract`
|
||||
ADD COLUMN IF NOT EXISTS `orderNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz.
|
||||
-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter
|
||||
-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt.
|
||||
-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191).
|
||||
|
||||
ALTER TABLE `Provider`
|
||||
ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB.
|
||||
* Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein
|
||||
* Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte
|
||||
* E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift).
|
||||
*
|
||||
* Aufruf:
|
||||
* npx tsx prisma/reset-admin-password.ts <email> # generiert PW
|
||||
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # eigenes PW
|
||||
*
|
||||
* Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions
|
||||
* dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung
|
||||
* gestohlener Tokens).
|
||||
*/
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../src/lib/prisma.js';
|
||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js';
|
||||
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
function generateRescuePassword(): string {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||
const digits = '23456789';
|
||||
const special = '!@#$%&*+=?';
|
||||
const all = upper + lower + digits + special;
|
||||
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
|
||||
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
|
||||
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2];
|
||||
const providedPw = process.argv[3];
|
||||
|
||||
if (!email) {
|
||||
console.error('Aufruf: npx tsx prisma/reset-admin-password.ts <email> [passwort]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, email: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (!user) {
|
||||
console.error(`User "${email}" nicht gefunden.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let plain: string;
|
||||
if (providedPw) {
|
||||
const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||
if (!c.ok) {
|
||||
console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:');
|
||||
for (const e of c.errors) console.error(' - ' + e);
|
||||
process.exit(3);
|
||||
}
|
||||
plain = providedPw;
|
||||
} else {
|
||||
plain = generateRescuePassword();
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(plain, BCRYPT_COST);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
passwordResetToken: null,
|
||||
passwordResetExpiresAt: null,
|
||||
tokenInvalidatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('========================================================');
|
||||
console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`);
|
||||
console.log(` Neues Passwort: ${plain}`);
|
||||
console.log(' ⚠️ Wird hier EINMAL ausgegeben – sofort kopieren!');
|
||||
console.log(' Alle bestehenden Sessions wurden invalidiert.');
|
||||
console.log('========================================================');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Reset fehlgeschlagen:', e);
|
||||
process.exit(99);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -157,7 +157,8 @@ model Customer {
|
||||
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
||||
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
||||
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
|
||||
consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
||||
consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
||||
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
|
||||
notes String? @db.Text
|
||||
|
||||
// ===== Portal-Zugangsdaten =====
|
||||
@@ -172,6 +173,10 @@ model Customer {
|
||||
portalPasswordResetExpiresAt DateTime?
|
||||
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
|
||||
portalTokenInvalidatedAt DateTime?
|
||||
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
|
||||
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
|
||||
// Frontend in Force-Change-Password-Flow geleitet.
|
||||
portalPasswordMustChange Boolean @default(false)
|
||||
|
||||
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
||||
lastBirthdayGreetingYear Int?
|
||||
@@ -277,6 +282,7 @@ model Address {
|
||||
ownerEmail String?
|
||||
contractsAsDelivery Contract[] @relation("DeliveryAddress")
|
||||
contractsAsBilling Contract[] @relation("BillingAddress")
|
||||
meters Meter[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -396,6 +402,12 @@ model StressfreiEmail {
|
||||
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
||||
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
||||
|
||||
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
|
||||
// hinaus). Wird beim Sync zusammen mit customer.email +
|
||||
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
|
||||
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
|
||||
additionalForwardingEmails String? @db.Text
|
||||
|
||||
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
||||
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
||||
createdAt DateTime @default(now())
|
||||
@@ -475,11 +487,21 @@ model Meter {
|
||||
id Int @id @default(autoincrement())
|
||||
customerId Int
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
|
||||
// vor dem Feature noch keine Adresse haben (werden manuell nachgepflegt).
|
||||
addressId Int?
|
||||
address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull)
|
||||
meterNumber String
|
||||
type MeterType
|
||||
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
|
||||
location String?
|
||||
isActive Boolean @default(true)
|
||||
// Folgezähler-Kette: zeigt auf den Vorgänger, den dieser Zähler abgelöst hat.
|
||||
// Wird beim Anlegen als Folgezähler gesetzt; informational + zum Anzeigen
|
||||
// der Kette. Auto-Propagation auf Verträge passiert beim Create.
|
||||
predecessorMeterId Int?
|
||||
predecessor Meter? @relation("MeterSuccessor", fields: [predecessorMeterId], references: [id], onDelete: SetNull)
|
||||
successors Meter[] @relation("MeterSuccessor")
|
||||
readings MeterReading[]
|
||||
energyDetails EnergyContractDetails[]
|
||||
contractMeters ContractMeter[] @relation("ContractMeters")
|
||||
@@ -554,6 +576,15 @@ model Provider {
|
||||
portalUrl String? // Kundenkontourl (Login-Seite)
|
||||
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
||||
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
||||
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
|
||||
contactEmail String? // Allgemeine Kontakt-Emailadresse
|
||||
contactPhone String? // Kontakt-Telefonnummer
|
||||
contactFax String? // Kontakt-Faxnummer
|
||||
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
|
||||
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
|
||||
cancellationEmail String? // Kündigungs-Emailadresse
|
||||
cancellationFax String? // Kündigungs-Faxnummer
|
||||
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
|
||||
isActive Boolean @default(true)
|
||||
tariffs Tariff[]
|
||||
contracts Contract[]
|
||||
@@ -671,6 +702,9 @@ model Contract {
|
||||
tariffName String?
|
||||
customerNumberAtProvider String?
|
||||
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
||||
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
|
||||
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
||||
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
||||
priceFirst12Months String? // Preis erste 12 Monate
|
||||
priceFrom13Months String? // Preis ab 13. Monat
|
||||
priceAfter24Months String? // Preis nach 24 Monaten
|
||||
@@ -801,7 +835,14 @@ model EnergyContractDetails {
|
||||
basePrice Float? // €/Monat
|
||||
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||
bonus Float?
|
||||
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
|
||||
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
|
||||
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
|
||||
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
|
||||
// dargestellt. Migration kopiert das alte `bonus` nach
|
||||
// `instantBonus` (Annahme: meistgenutzte Variante).
|
||||
instantBonus Float? // Sofort-Bonus
|
||||
newCustomerBonus Float? // Neukunden-Bonus
|
||||
previousProviderName String?
|
||||
previousCustomerNumber String?
|
||||
invoices Invoice[] // Rechnungen
|
||||
@@ -870,7 +911,12 @@ model PhoneNumber {
|
||||
id Int @id @default(autoincrement())
|
||||
internetContractDetailsId Int
|
||||
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
|
||||
// phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
|
||||
// bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
|
||||
// damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
|
||||
// befüllen können – ohne heuristischen String-Split.
|
||||
phoneNumber String
|
||||
areaCode String?
|
||||
isMain Boolean @default(false)
|
||||
// SIP-Zugangsdaten
|
||||
sipUsername String?
|
||||
@@ -906,6 +952,10 @@ model SimCard {
|
||||
puk String? // PUK (verschlüsselt gespeichert)
|
||||
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
||||
isMain Boolean @default(false) // Ist dies die Hauptkarte?
|
||||
isEsim Boolean @default(false) // Ist dies eine eSIM?
|
||||
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
|
||||
// Kind bei Eltern-Vertrag) – kann vom Vertragsinhaber abweichen.
|
||||
cardUser String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -1142,6 +1192,33 @@ enum SecuritySeverity {
|
||||
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
||||
}
|
||||
|
||||
enum BackupOperation {
|
||||
CREATE
|
||||
RESTORE
|
||||
}
|
||||
|
||||
// Persistiertes Log für Backup-Vorgänge.
|
||||
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
|
||||
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
|
||||
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
|
||||
// geschrieben.
|
||||
model BackupLog {
|
||||
id Int @id @default(autoincrement())
|
||||
operation BackupOperation
|
||||
backupName String?
|
||||
success Boolean
|
||||
durationMs Int @default(0)
|
||||
summary String @db.Text
|
||||
fullLog String @db.LongText
|
||||
userId Int?
|
||||
userEmail String?
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([operation, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model SecurityEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
type SecurityEventType
|
||||
|
||||
+48
-3
@@ -221,8 +221,41 @@ async function main() {
|
||||
|
||||
console.log('Roles created');
|
||||
|
||||
// Create admin user
|
||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||
// Komplexitätspolicy). Stattdessen:
|
||||
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
|
||||
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
|
||||
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
|
||||
// Passwort-vergessen-Flow nutzen.
|
||||
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
|
||||
function generateInitialPassword(): string {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||
const digits = '23456789';
|
||||
const special = '!@#$%&*+=?';
|
||||
const all = upper + lower + digits + special;
|
||||
// Kryptografisch sichere Auswahl – Math.random() ist vorhersagbar
|
||||
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
|
||||
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||
// mind. einen aus jeder Klasse + Rest zufällig
|
||||
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
|
||||
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
|
||||
// Mitarbeiter-Schwellwert (Pentest Runde 13).
|
||||
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
|
||||
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = crypto.randomInt(0, i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
const envPassword = process.env.SEED_ADMIN_PASSWORD;
|
||||
const adminPlainPassword = envPassword && envPassword.length >= 25
|
||||
? envPassword
|
||||
: generateInitialPassword();
|
||||
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@admin.com' },
|
||||
@@ -238,7 +271,19 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Admin user created: admin@admin.com / admin');
|
||||
console.log('========================================================');
|
||||
console.log(' Admin-User: admin@admin.com');
|
||||
if (envPassword && envPassword.length >= 25) {
|
||||
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
|
||||
} else {
|
||||
if (envPassword && envPassword.length < 25) {
|
||||
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
|
||||
}
|
||||
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||
}
|
||||
console.log('========================================================');
|
||||
|
||||
// Create some sales platforms
|
||||
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
||||
*
|
||||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
||||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
||||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
||||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
||||
*
|
||||
* Dieses Skript synchronisiert ausschließlich:
|
||||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
||||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
||||
* Mitarbeiter (Nur-Lesen), Kunde)
|
||||
*
|
||||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
||||
* laufenden Prod-DBs sicher.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
||||
customers: ['create', 'read', 'update', 'delete'],
|
||||
contracts: ['create', 'read', 'update', 'delete'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
platforms: ['create', 'read', 'update', 'delete'],
|
||||
providers: ['create', 'read', 'update', 'delete'],
|
||||
tariffs: ['create', 'read', 'update', 'delete'],
|
||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
||||
settings: ['read', 'update'],
|
||||
developer: ['access'],
|
||||
emails: ['delete'],
|
||||
audit: ['read', 'export', 'admin'],
|
||||
gdpr: ['export', 'delete', 'admin'],
|
||||
};
|
||||
|
||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
const existing = await prisma.rolePermission.findMany({
|
||||
where: { roleId },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
||||
const targetIds = new Set(permissionIds);
|
||||
|
||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
||||
}
|
||||
|
||||
const excess = existing
|
||||
.filter((e) => !targetIds.has(e.permissionId))
|
||||
.map((e) => e.permissionId);
|
||||
if (excess.length > 0) {
|
||||
await prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId: { in: excess } },
|
||||
});
|
||||
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[sync-roles] Permissions-Katalog upserten…');
|
||||
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
||||
for (const action of actions) {
|
||||
await prisma.permission.upsert({
|
||||
where: { resource_action: { resource, action } },
|
||||
update: {},
|
||||
create: { resource, action },
|
||||
});
|
||||
}
|
||||
}
|
||||
const allPermissions = await prisma.permission.findMany();
|
||||
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
||||
|
||||
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
||||
// sind separate hidden roles, über Checkboxen zugewiesen)
|
||||
const adminPermIds = allPermissions
|
||||
.filter(
|
||||
(p) =>
|
||||
!(p.resource === 'developer' && p.action === 'access') &&
|
||||
p.resource !== 'audit' &&
|
||||
p.resource !== 'gdpr'
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Developer: alles
|
||||
const developerPermIds = allPermissions.map((p) => p.id);
|
||||
|
||||
// DSGVO: audit + gdpr komplett
|
||||
const gdprPermIds = allPermissions
|
||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
||||
.map((p) => p.id);
|
||||
|
||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
||||
const employeePermIds = allPermissions
|
||||
.filter(
|
||||
(p) =>
|
||||
p.resource === 'customers' ||
|
||||
p.resource === 'contracts' ||
|
||||
(p.action === 'read' &&
|
||||
[
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
].includes(p.resource))
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
||||
const readOnlyResources = [
|
||||
'customers',
|
||||
'contracts',
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
];
|
||||
const readOnlyPermIds = allPermissions
|
||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
||||
.map((p) => p.id);
|
||||
|
||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
||||
];
|
||||
|
||||
for (const r of rolesSpec) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { name: r.name },
|
||||
update: { description: r.description },
|
||||
create: { name: r.name, description: r.description },
|
||||
});
|
||||
await syncRolePermissions(role.id, r.permIds);
|
||||
}
|
||||
|
||||
console.log('[sync-roles] fertig.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('[sync-roles] Fehler:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -41,10 +41,32 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitelist-Check (Pentest Runde 11, M1)
|
||||
if (!appSettingService.isAllowedSettingKey(key)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unbekannter Setting-Key: ${key}`,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||
const oldValue = before?.value ?? '-';
|
||||
const newValue = String(value);
|
||||
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
||||
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
||||
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
||||
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||
// Schema-spezifische Validierung (URL/Email/Int/Bool). Pentest
|
||||
// 2026-05-28, LOW 34.5: portalLoginUrl nahm `/relative/path` und
|
||||
// `http://192.168.1.1` ungefiltert entgegen → Open-Redirect /
|
||||
// SSRF in der versendeten Mail.
|
||||
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||
if (!validation.ok) {
|
||||
res.status(400).json({ success: false, error: validation.error } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const newValue = validation.value;
|
||||
|
||||
await appSettingService.setSetting(key, newValue);
|
||||
|
||||
@@ -78,12 +100,35 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorherige Werte laden für Audit
|
||||
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
|
||||
const unknownKeys = Object.keys(settings).filter(
|
||||
(k) => !appSettingService.isAllowedSettingKey(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorherige Werte laden für Audit. Validierung erfolgt vor dem
|
||||
// ersten Schreibzugriff, damit ein Bulk-PUT mit einem ungültigen
|
||||
// Wert nicht die anderen Werte halb-committed liegen lässt.
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
const sanitizedEntries: Array<{ key: string; oldValue: string; newValue: string }> = [];
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||
const oldValue = before?.value ?? '-';
|
||||
const newValue = String(value);
|
||||
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||
if (!validation.ok) {
|
||||
res.status(400).json({ success: false, error: `${key}: ${validation.error}` } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
sanitizedEntries.push({ key, oldValue, newValue: validation.value });
|
||||
}
|
||||
for (const { key, oldValue, newValue } of sanitizedEntries) {
|
||||
if (oldValue !== newValue) {
|
||||
changes[key] = { von: oldValue, nach: newValue };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as authService from '../services/auth.service.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||
|
||||
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
||||
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
||||
@@ -26,6 +27,26 @@ function clearRefreshCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
}
|
||||
|
||||
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
|
||||
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
|
||||
// wird als generisches "Anmeldung fehlgeschlagen" maskiert – die Original-
|
||||
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
|
||||
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
|
||||
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
|
||||
const SAFE_LOGIN_ERRORS = new Set([
|
||||
'Ungültige Anmeldedaten',
|
||||
'E-Mail und Passwort erforderlich',
|
||||
]);
|
||||
function safeLoginError(err: unknown): string {
|
||||
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
|
||||
return err.message;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
|
||||
}
|
||||
return 'Anmeldung fehlgeschlagen';
|
||||
}
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
@@ -67,7 +88,7 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
error: safeLoginError(error),
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -111,7 +132,7 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
error: safeLoginError(error),
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -223,10 +244,15 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
// Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen
|
||||
// verlangt und Portal-Customer-Reset weiterhin 12 reicht.
|
||||
const audience = await authService.getPasswordResetAudience(token);
|
||||
const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH;
|
||||
const complexity = validatePasswordComplexity(password, { minLength });
|
||||
if (!complexity.ok) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
@@ -355,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mitarbeiter-Anlage: 25-Zeichen-Schwellwert
|
||||
const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||
if (!complexity.ok) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await authService.createUser({
|
||||
email,
|
||||
password,
|
||||
@@ -374,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
|
||||
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
|
||||
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
|
||||
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
|
||||
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const payload: any = {
|
||||
email: req.user.email,
|
||||
permissions: req.user.permissions,
|
||||
isCustomerPortal: !!req.user.isCustomerPortal,
|
||||
};
|
||||
if (req.user.userId) payload.userId = req.user.userId;
|
||||
if (req.user.customerId) payload.customerId = req.user.customerId;
|
||||
if ((req.user as any).representedCustomerIds) {
|
||||
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
|
||||
}
|
||||
const token = authService.signDownloadToken(payload);
|
||||
res.json({ success: true, data: { token } } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Erstellen des Download-Tokens',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
|
||||
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
|
||||
// loggt aus und schickt zurück zum Login.
|
||||
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Nur für Kundenportal-Login',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
|
||||
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
|
||||
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) – KRITISCH.
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: req.user.customerId },
|
||||
select: { portalPasswordMustChange: true },
|
||||
});
|
||||
if (!customer?.portalPasswordMustChange) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Nicht erlaubt',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { newPassword } = req.body || {};
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Neues Passwort erforderlich',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const complexity = validatePasswordComplexity(newPassword);
|
||||
if (!complexity.ok) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
|
||||
clearRefreshCookie(res);
|
||||
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as backupService from '../services/backup.service.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
/**
|
||||
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
||||
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
|
||||
}
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
|
||||
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
|
||||
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
|
||||
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
|
||||
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
|
||||
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
|
||||
// process-globale Patch-Variante.
|
||||
function startLogCapture(): { lines: string[]; restore: () => void } {
|
||||
const lines: string[] = [];
|
||||
const orig = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
function fmt(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
if (typeof a === 'object') {
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
}
|
||||
return String(a);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
|
||||
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
|
||||
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
|
||||
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
|
||||
return {
|
||||
lines,
|
||||
restore: () => {
|
||||
console.log = orig.log;
|
||||
console.info = orig.info;
|
||||
console.warn = orig.warn;
|
||||
console.error = orig.error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function recordBackupLog(opts: {
|
||||
req: Request;
|
||||
operation: 'CREATE' | 'RESTORE';
|
||||
backupName: string | null;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
summary: string;
|
||||
fullLog: string;
|
||||
}) {
|
||||
try {
|
||||
const user = (opts.req as any).user;
|
||||
await prisma.backupLog.create({
|
||||
data: {
|
||||
operation: opts.operation,
|
||||
backupName: opts.backupName,
|
||||
success: opts.success,
|
||||
durationMs: opts.durationMs,
|
||||
summary: opts.summary.slice(0, 2000),
|
||||
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
|
||||
fullLog: opts.fullLog.slice(0, 1_000_000),
|
||||
userId: user?.userId ?? null,
|
||||
userEmail: user?.email ?? null,
|
||||
ipAddress:
|
||||
(opts.req as any).socket?.remoteAddress ||
|
||||
(opts.req.headers?.['x-forwarded-for'] as string) ||
|
||||
null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste aller Backups abrufen
|
||||
* GET /api/settings/backups
|
||||
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
|
||||
* POST /api/settings/backup
|
||||
*/
|
||||
export async function createBackup(req: Request, res: Response) {
|
||||
const start = Date.now();
|
||||
const capture = startLogCapture();
|
||||
try {
|
||||
const result = await backupService.createBackup();
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (result.success) {
|
||||
capture.restore();
|
||||
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
|
||||
await recordBackupLog({
|
||||
req, operation: 'CREATE', backupName: result.backupName ?? null,
|
||||
success: true, durationMs, summary,
|
||||
fullLog: capture.lines.join('\n') || summary,
|
||||
});
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Backup',
|
||||
label: `Backup ${result.backupName} erstellt`,
|
||||
});
|
||||
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
||||
} else {
|
||||
capture.restore();
|
||||
await recordBackupLog({
|
||||
req, operation: 'CREATE', backupName: null,
|
||||
success: false, durationMs,
|
||||
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
|
||||
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||
});
|
||||
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
const durationMs = Date.now() - start;
|
||||
capture.restore();
|
||||
await recordBackupLog({
|
||||
req, operation: 'CREATE', backupName: null,
|
||||
success: false, durationMs,
|
||||
summary: `Fehler: ${error?.message || 'unbekannt'}`,
|
||||
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||
});
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
||||
}
|
||||
}
|
||||
@@ -50,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
|
||||
* Backup wiederherstellen
|
||||
* POST /api/settings/backup/:name/restore
|
||||
*/
|
||||
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
|
||||
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
|
||||
// "Cannot read properties of undefined" → "Interner Code-Fehler".
|
||||
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
|
||||
function makeRestoreErrorReadable(raw: unknown): string {
|
||||
if (!raw) return 'Unbekannter Fehler';
|
||||
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
|
||||
// Stack-Frames " at …(…:123:45)" abschneiden
|
||||
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
|
||||
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
|
||||
// alles auf "Operation fehlgeschlagen" maskiert.
|
||||
s = s
|
||||
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
|
||||
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
|
||||
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
|
||||
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
|
||||
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
|
||||
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
|
||||
.replace(/is not defined$/i, '(Wert nicht definiert)')
|
||||
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
|
||||
return s.slice(0, 500); // Längenlimit für UI
|
||||
}
|
||||
|
||||
export async function restoreBackup(req: Request, res: Response) {
|
||||
const start = Date.now();
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name || !isValidBackupName(name)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||
}
|
||||
|
||||
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
|
||||
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
|
||||
// sofort den destruktiven Restore aus – ein versehentlicher
|
||||
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
|
||||
// Klick) konnte die DB ungewollt überschreiben. Der String ist
|
||||
// bewusst ein unique Magic-Value, kein Boolean.
|
||||
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
|
||||
if (confirm !== 'RESTORE-BESTAETIGT') {
|
||||
return res.status(400).json({
|
||||
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
|
||||
});
|
||||
}
|
||||
|
||||
const capture = startLogCapture();
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name || !isValidBackupName(name)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||
}
|
||||
|
||||
const result = await backupService.restoreBackup(name);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (result.success) {
|
||||
capture.restore();
|
||||
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
|
||||
await recordBackupLog({
|
||||
req, operation: 'RESTORE', backupName: name,
|
||||
success: true, durationMs, summary,
|
||||
fullLog: capture.lines.join('\n') || summary,
|
||||
});
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'Backup',
|
||||
label: `Backup ${name} wiederhergestellt`,
|
||||
@@ -73,10 +222,35 @@ export async function restoreBackup(req: Request, res: Response) {
|
||||
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
|
||||
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
|
||||
capture.restore();
|
||||
await recordBackupLog({
|
||||
req, operation: 'RESTORE', backupName: name,
|
||||
success: false, durationMs,
|
||||
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
|
||||
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||
});
|
||||
res.status(500).json({
|
||||
error: 'Wiederherstellung fehlgeschlagen',
|
||||
details: makeRestoreErrorReadable(result.error),
|
||||
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
|
||||
const durationMs = Date.now() - start;
|
||||
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
|
||||
capture.restore();
|
||||
await recordBackupLog({
|
||||
req, operation: 'RESTORE', backupName: name,
|
||||
success: false, durationMs,
|
||||
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
|
||||
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||
});
|
||||
res.status(500).json({
|
||||
error: 'Fehler bei der Wiederherstellung',
|
||||
details: makeRestoreErrorReadable(error),
|
||||
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +350,22 @@ export async function uploadBackup(req: Request, res: Response) {
|
||||
*/
|
||||
export async function factoryReset(req: Request, res: Response) {
|
||||
try {
|
||||
// Bestätigung erforderlich: client MUSS explizit
|
||||
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
|
||||
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
|
||||
// POST plätten (Pentest Runde 11 (2026-05-18) – C2 KRITISCH:
|
||||
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
|
||||
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
|
||||
// Replay-Angriff aus Versehen triggern kann.
|
||||
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
|
||||
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backupService.factoryReset();
|
||||
|
||||
if (result.success) {
|
||||
@@ -190,6 +380,65 @@ export async function factoryReset(req: Request, res: Response) {
|
||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
|
||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
|
||||
console.error('factoryReset error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste der Backup-Logs (CREATE oder RESTORE)
|
||||
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
|
||||
* Liefert die Übersichtsdaten OHNE den großen fullLog.
|
||||
*/
|
||||
export async function listBackupLogs(req: Request, res: Response) {
|
||||
try {
|
||||
const op = String(req.query.operation || '').toUpperCase();
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
|
||||
|
||||
const where: any = {};
|
||||
if (op === 'CREATE' || op === 'RESTORE') {
|
||||
where.operation = op;
|
||||
}
|
||||
|
||||
const logs = await prisma.backupLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
operation: true,
|
||||
backupName: true,
|
||||
success: true,
|
||||
durationMs: true,
|
||||
summary: true,
|
||||
userEmail: true,
|
||||
ipAddress: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ data: logs });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail eines Backup-Logs inkl. fullLog
|
||||
* GET /api/settings/backup-logs/:id
|
||||
*/
|
||||
export async function getBackupLogDetail(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id < 1) {
|
||||
return res.status(400).json({ error: 'Ungültige ID' });
|
||||
}
|
||||
const log = await prisma.backupLog.findUnique({ where: { id } });
|
||||
if (!log) {
|
||||
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
|
||||
}
|
||||
res.json({ data: log });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ import { createAuditLog } from '../services/audit.service.js';
|
||||
*/
|
||||
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
|
||||
// Telefon und Geburtsdatum ALLER Kunden – ausschließlich Mitarbeiter-UI.
|
||||
// Pentest Runde 6 (2026-05-16) – HOCH.
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
|
||||
return;
|
||||
}
|
||||
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
|
||||
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||
import { generateEmailPdf } from '../services/pdfService.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
import { DocumentType } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import {
|
||||
canAccessCustomer,
|
||||
canAccessContract,
|
||||
@@ -40,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
|
||||
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
|
||||
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
|
||||
// Postfächern.
|
||||
//
|
||||
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
|
||||
// Endpunkten reichte das silent-undefined nicht – die Mailbox-Isolation
|
||||
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
|
||||
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
|
||||
//
|
||||
// Helper hat zwei Modi:
|
||||
// - default (optional): fehlend/leer → undefined (kein Filter)
|
||||
// invalid → 400
|
||||
// - { required: true }: fehlend/leer → 400
|
||||
// invalid → 400
|
||||
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
|
||||
// zurück; der Caller bricht dann mit `return` ab.
|
||||
function parsePositiveIntQuery(
|
||||
v: unknown,
|
||||
fieldLabel: string,
|
||||
res: Response,
|
||||
options?: { required?: boolean },
|
||||
): number | undefined | null {
|
||||
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
|
||||
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
|
||||
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
|
||||
// Trennung jetzt strikt:
|
||||
// - Param fehlt komplett (`undefined`) → "absent"
|
||||
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
|
||||
if (v === undefined) {
|
||||
if (options?.required) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
|
||||
} as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof v !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `${fieldLabel} muss als Zahl übergeben werden.`,
|
||||
} as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `${fieldLabel} darf nicht leer sein – bitte weglassen oder positive Ganzzahl angeben.`,
|
||||
} as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
const n = parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
|
||||
} as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// E-Mails für einen Kunden abrufen
|
||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
|
||||
// ist erwünscht), aber invalide Werte → 400.
|
||||
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||
if (stressfreiEmailId === null) return;
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
@@ -80,17 +149,26 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mails für einen Vertrag abrufen
|
||||
// E-Mails für einen Vertrag abrufen.
|
||||
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
|
||||
// ein – ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
|
||||
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
|
||||
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
// Vertrags-Endpunkte sind per Design IMMER pro Postfach – fehlt
|
||||
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
|
||||
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
|
||||
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
|
||||
const emails = await cachedEmailService.getCachedEmails({
|
||||
contractId,
|
||||
stressfreiEmailId,
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
@@ -139,9 +217,10 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// E-Mail als gelesen/ungelesen markieren
|
||||
export async function markAsRead(req: Request, res: Response): Promise<void> {
|
||||
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
const { isRead } = req.body;
|
||||
|
||||
if (isRead) {
|
||||
@@ -161,9 +240,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// E-Mail Stern umschalten
|
||||
export async function toggleStar(req: Request, res: Response): Promise<void> {
|
||||
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||||
|
||||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||||
@@ -179,10 +259,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
|
||||
// ==================== CONTRACT ASSIGNMENT ====================
|
||||
|
||||
// E-Mail einem Vertrag zuordnen
|
||||
export async function assignToContract(req: Request, res: Response): Promise<void> {
|
||||
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { contractId } = req.body;
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||||
|
||||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||||
@@ -198,9 +280,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
// Vertragszuordnung aufheben
|
||||
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
|
||||
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||||
|
||||
@@ -232,12 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
||||
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach)
|
||||
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
|
||||
// Badge eine andere Zahl als die Liste).
|
||||
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||
|
||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
|
||||
|
||||
res.json({ success: true, data: counts } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -255,6 +343,16 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
|
||||
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
// Mitarbeiter brauchen customers:update (wie früher), Portal-Kunden
|
||||
// brauchen keine Perm – nur Eigentum am Konto (Owner-Check unten).
|
||||
// Trennung der Threat-Modelle: Portal-User dürfen IHR eigenes
|
||||
// Postfach syncen, sollen aber nicht Mitarbeiter-Updates triggern.
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const hasUpdatePerm = req.user?.permissions?.includes('customers:update') ?? false;
|
||||
if (!isPortal && !hasUpdatePerm) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||
const fullSync = req.query.full === 'true';
|
||||
|
||||
@@ -294,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
|
||||
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
|
||||
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
|
||||
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
|
||||
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
|
||||
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
|
||||
const MAX_ATTACHMENT_COUNT = 25;
|
||||
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
interface AttachmentValidationError {
|
||||
status: 400;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function validateAttachments(
|
||||
attachments: unknown,
|
||||
): { ok: true } | AttachmentValidationError {
|
||||
if (attachments === undefined) return { ok: true };
|
||||
if (!Array.isArray(attachments)) {
|
||||
return { status: 400, error: 'attachments muss ein Array sein.' };
|
||||
}
|
||||
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
|
||||
};
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const a = attachments[i];
|
||||
const label = `Anhang ${i + 1}`;
|
||||
if (!a || typeof a !== 'object') {
|
||||
return { status: 400, error: `${label} hat das falsche Format.` };
|
||||
}
|
||||
const filename = (a as Record<string, unknown>).filename;
|
||||
const content = (a as Record<string, unknown>).content;
|
||||
const contentType = (a as Record<string, unknown>).contentType;
|
||||
if (typeof filename !== 'string' || filename.trim() === '') {
|
||||
return { status: 400, error: `${label} hat keinen Dateinamen.` };
|
||||
}
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
|
||||
};
|
||||
}
|
||||
if (!BASE64_RE.test(content)) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
|
||||
};
|
||||
}
|
||||
if (contentType !== undefined && typeof contentType !== 'string') {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
|
||||
};
|
||||
}
|
||||
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
|
||||
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
|
||||
const approxBytes = Math.ceil(content.length * 0.75);
|
||||
if (approxBytes > MAX_PER_FILE_BYTES) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
|
||||
};
|
||||
}
|
||||
totalBytes += approxBytes;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -310,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
|
||||
// (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler
|
||||
// in die Response und Limits waren nur Frontend-Doku.
|
||||
const attachmentCheck = validateAttachments(attachments);
|
||||
if (!('ok' in attachmentCheck)) {
|
||||
res.status(attachmentCheck.status).json({
|
||||
success: false,
|
||||
error: attachmentCheck.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail laden
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
@@ -563,24 +754,44 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
|
||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
||||
const INLINE_SAFE_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
||||
'text/plain',
|
||||
]);
|
||||
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
||||
// SVG kann Skripte enthalten → niemals inline
|
||||
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
||||
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
|
||||
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
|
||||
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
|
||||
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
|
||||
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
|
||||
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
|
||||
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
|
||||
const buf: Buffer = attachment.content;
|
||||
let detectedType: string | null = null;
|
||||
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||
detectedType = 'application/pdf';
|
||||
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
detectedType = 'image/png';
|
||||
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
detectedType = 'image/jpeg';
|
||||
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
|
||||
detectedType = 'image/gif';
|
||||
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||||
detectedType = 'image/webp';
|
||||
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
|
||||
// text/plain hat keine eindeutige Magic-Byte – akzeptieren wenn
|
||||
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
|
||||
// Konservative Prüfung: keine HTML-Tag-Anfänge.
|
||||
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
|
||||
if (!/<[a-z!\/?]/i.test(sample)) {
|
||||
detectedType = 'text/plain; charset=utf-8';
|
||||
}
|
||||
}
|
||||
const isSafeInline = detectedType !== null;
|
||||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
||||
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
|
||||
// eventuell falsches application/octet-stream aus IMAP). Sonst
|
||||
// octet-stream erzwingen, damit der Browser nichts erraten kann.
|
||||
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
||||
res.setHeader('Content-Length', attachment.size);
|
||||
res.send(attachment.content);
|
||||
@@ -611,9 +822,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
// ==================== MAILBOX ACCOUNTS ====================
|
||||
|
||||
// Mailbox-Konten eines Kunden abrufen
|
||||
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
|
||||
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
|
||||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||||
|
||||
@@ -686,9 +898,10 @@ export async function syncMailboxStatus(req: AuthRequest, res: Response): Promis
|
||||
}
|
||||
|
||||
// E-Mail-Thread abrufen
|
||||
export async function getThread(req: Request, res: Response): Promise<void> {
|
||||
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const thread = await cachedEmailService.getEmailThread(id);
|
||||
|
||||
@@ -780,7 +993,7 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
|
||||
}
|
||||
|
||||
// Ungelesene E-Mails zählen
|
||||
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
|
||||
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||||
@@ -788,8 +1001,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
|
||||
let count = 0;
|
||||
|
||||
if (customerId) {
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||||
} else if (contractId) {
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||||
}
|
||||
|
||||
@@ -804,9 +1019,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// E-Mail in Papierkorb verschieben (nur Admin)
|
||||
export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
// Prüfen ob E-Mail existiert
|
||||
const email = await cachedEmailService.getCachedEmailById(id);
|
||||
@@ -840,12 +1056,25 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||
|
||||
// ==================== TRASH OPERATIONS ====================
|
||||
|
||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
|
||||
// Papierkorb-E-Mails für einen Kunden abrufen.
|
||||
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
|
||||
// – beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
|
||||
// vorher: alle gelöschten E-Mails des Kunden.
|
||||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
|
||||
// View ist legitim), invalide Werte → 400.
|
||||
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||
if (stressfreiEmailId === null) return;
|
||||
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||
if (contractId === null) return;
|
||||
|
||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||||
const emails = await cachedEmailService.getTrashEmails(customerId, {
|
||||
stressfreiEmailId,
|
||||
contractId,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: emails } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -857,12 +1086,20 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// Papierkorb-Anzahl für einen Kunden
|
||||
export async function getTrashCount(req: Request, res: Response): Promise<void> {
|
||||
// Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
|
||||
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||
if (stressfreiEmailId === null) return;
|
||||
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||
if (contractId === null) return;
|
||||
|
||||
const count = await cachedEmailService.getTrashCount(customerId);
|
||||
const count = await cachedEmailService.getTrashCount(customerId, {
|
||||
stressfreiEmailId,
|
||||
contractId,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: { count } } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -875,9 +1112,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
||||
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||||
|
||||
@@ -900,9 +1138,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// E-Mail endgültig löschen (aus Papierkorb)
|
||||
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
|
||||
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||||
|
||||
@@ -927,9 +1166,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
|
||||
// ==================== ATTACHMENT TARGETS ====================
|
||||
|
||||
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
||||
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
|
||||
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
// E-Mail mit StressfreiEmail laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
@@ -1109,9 +1349,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
|
||||
}
|
||||
|
||||
// E-Mail-Anhang in ein Dokumentenfeld speichern
|
||||
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> {
|
||||
export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { entityType, entityId, targetKey } = req.body;
|
||||
|
||||
@@ -1235,6 +1476,9 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
||||
|
||||
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
|
||||
assertSafePdf(attachment.content);
|
||||
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
@@ -1385,8 +1629,9 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentTo error:', error);
|
||||
// Detailliertere Fehlermeldung für Debugging
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
@@ -1396,9 +1641,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
|
||||
// ==================== SAVE EMAIL AS PDF ====================
|
||||
|
||||
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
||||
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
|
||||
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { entityType, entityId, targetKey } = req.body;
|
||||
|
||||
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
||||
@@ -1643,9 +1889,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
|
||||
// ==================== SAVE EMAIL AS INVOICE ====================
|
||||
|
||||
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { invoiceDate, invoiceType, notes } = req.body;
|
||||
|
||||
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||||
@@ -1766,12 +2013,120 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ====================
|
||||
|
||||
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen.
|
||||
// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) – damit
|
||||
// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/
|
||||
// Lieferbestätigung etc. an einem Vertrag landen können.
|
||||
export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||
let validatedType: string;
|
||||
try {
|
||||
validatedType = validateContractDocumentType(documentType);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger documentType' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||
let deliveryDate: string | null;
|
||||
try {
|
||||
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!email.contractId) {
|
||||
res.status(400).json({ success: false, error: 'E-Mail ist keinem Vertrag zugeordnet' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
select: { id: true, contractNumber: true, customerId: true },
|
||||
});
|
||||
if (!contract) {
|
||||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
||||
|
||||
// Empfänger-Adressen parsen
|
||||
let toAddresses: string[] = [];
|
||||
let ccAddresses: string[] = [];
|
||||
try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; }
|
||||
try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ }
|
||||
|
||||
const pdfBuffer = await generateEmailPdf({
|
||||
from: email.fromAddress,
|
||||
to: toAddresses.join(', '),
|
||||
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||||
subject: email.subject || '(Kein Betreff)',
|
||||
date: email.receivedAt,
|
||||
bodyText: email.textBody || undefined,
|
||||
bodyHtml: email.htmlBody || undefined,
|
||||
});
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||
|
||||
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
|
||||
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
|
||||
// Datei-Müll bei Race-Reject.
|
||||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||
fs.writeFileSync(filePath, pdfBuffer);
|
||||
return prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType: validatedType,
|
||||
documentPath: relativePath,
|
||||
originalName: `${email.subject || 'email'}.pdf`,
|
||||
notes: sanitizeNotes(notes),
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveEmailAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||
// Lock) statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||
|
||||
// E-Mail-Anhang als Rechnung speichern
|
||||
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { invoiceDate, invoiceType, notes } = req.body;
|
||||
|
||||
@@ -1896,6 +2251,9 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/invoices/${newFilename}`;
|
||||
|
||||
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||
assertSafePdf(attachment.content);
|
||||
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
@@ -1920,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsInvoice error:', error);
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
@@ -1932,20 +2291,34 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
||||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||||
*/
|
||||
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
|
||||
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!documentType || typeof documentType !== 'string') {
|
||||
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||
let validatedType: string;
|
||||
try {
|
||||
validatedType = validateContractDocumentType(documentType);
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'documentType ist erforderlich',
|
||||
error: err instanceof Error ? err.message : 'Ungültiger documentType',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||
let deliveryDate: string | null;
|
||||
try {
|
||||
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||
@@ -2031,33 +2404,40 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
|
||||
|
||||
const ext = path.extname(filename) || '.pdf';
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||
assertSafePdf(attachment.content);
|
||||
|
||||
const doc = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType,
|
||||
documentPath: relativePath,
|
||||
originalName: filename,
|
||||
notes: notes || null,
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
|
||||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
return prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType: validatedType,
|
||||
documentPath: relativePath,
|
||||
originalName: filename,
|
||||
notes: sanitizeNotes(notes),
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
|
||||
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: results });
|
||||
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
|
||||
// keine internen IDs – das war früher der volle CustomerConsent-Record und
|
||||
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
|
||||
res.json({
|
||||
success: true,
|
||||
data: { granted: results.length },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
||||
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
||||
|
||||
@@ -1,13 +1,76 @@
|
||||
import { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as contractService from '../services/contract.service.js';
|
||||
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
/**
|
||||
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
||||
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
|
||||
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
|
||||
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
|
||||
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
|
||||
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
|
||||
* Strip safe.
|
||||
*
|
||||
* AUSNAHME: Passwort-/Secret-Felder. `stripHtml` filtert `<…>`-Sequenzen
|
||||
* und URI-Schemata wie `data:`, also würde ein PW wie `Pass<TAG>word!`
|
||||
* zu `Password!` mutilieren oder `data:secret` zu `blocked:secret`.
|
||||
* Das Passwort wird sowieso verschlüsselt persistiert (`encrypt()`),
|
||||
* niemals als HTML ausgegeben – also kein XSS-Risk, und die Mangling
|
||||
* ist ein Bug (2026-05-27, intern gemeldet: "Portal-Passwörter werden
|
||||
* nicht gespeichert").
|
||||
*/
|
||||
const PASSTHROUGH_KEYS = new Set(['portalPassword', 'password']);
|
||||
|
||||
function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
|
||||
if (body === null || body === undefined) return body;
|
||||
if (typeof body === 'string') {
|
||||
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
|
||||
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
|
||||
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
|
||||
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
|
||||
//
|
||||
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
|
||||
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
|
||||
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
|
||||
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
|
||||
// sogar zu `null` und damit den vorherigen Wert überschreiben.
|
||||
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
|
||||
// Bypässe ab, die stripHtml normalerweise auffangen würde
|
||||
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding) –
|
||||
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
|
||||
if (parentKey && isContractIdentifierField(parentKey)) {
|
||||
return validateContractIdentifier(body, parentKey);
|
||||
}
|
||||
// Pentest 95.1/95.3/95.4 (LOW–MEDIUM, 2026-06-21): portalUsername
|
||||
// (Manual-Modus) hatte gar keine Validierung – CRLF/Header-Injection,
|
||||
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
|
||||
// Raw-Input-Pattern wie R87.
|
||||
if (parentKey === 'portalUsername') {
|
||||
return validatePortalUsername(body, parentKey);
|
||||
}
|
||||
return stripHtml(body);
|
||||
}
|
||||
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
||||
if (typeof body === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
||||
out[k] = sanitizeContractBody(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -46,9 +109,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
});
|
||||
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
||||
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const data = isPortal
|
||||
? sanitizeContractsStrict(result.contracts as any[])
|
||||
: sanitizeContracts(result.contracts as any[]);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.contracts,
|
||||
data,
|
||||
pagination: result.pagination,
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -89,7 +158,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: contract } as ApiResponse);
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const data = isPortal
|
||||
? sanitizeContractStrict(contract as any)
|
||||
: sanitizeContract(contract as any);
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -98,16 +171,30 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function createContract(req: Request, res: Response): Promise<void> {
|
||||
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contract = await contractService.createContract(req.body);
|
||||
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
|
||||
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
|
||||
const body = (req.body || {}) as Record<string, unknown>;
|
||||
if (!body.type || typeof body.type !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!body.customerId || typeof body.customerId !== 'number') {
|
||||
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const sanitizedBody = sanitizeContractBody(body);
|
||||
const contract = await contractService.createContract(sanitizedBody as any);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Contract',
|
||||
resourceId: contract.id.toString(),
|
||||
label: `Vertrag ${contract.contractNumber} angelegt`,
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -119,19 +206,30 @@ export async function createContract(req: Request, res: Response): Promise<void>
|
||||
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth –
|
||||
// canAccessContract explizit aufrufen, statt sich nur auf die
|
||||
// Route-Permission zu verlassen. Portal-User mit kompromittierter
|
||||
// Token-Permission würden sonst beliebige Verträge editieren können.
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
// Vorherigen Stand laden für Audit-Vergleich
|
||||
const before = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
|
||||
});
|
||||
|
||||
const contract = await contractService.updateContract(contractId, req.body);
|
||||
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
|
||||
const sanitizedBody = sanitizeContractBody(req.body);
|
||||
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
||||
|
||||
// Geänderte Felder ermitteln
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
||||
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
||||
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
|
||||
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
||||
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
||||
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||
@@ -140,11 +238,14 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
};
|
||||
const energyLabels: Record<string, string> = {
|
||||
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
|
||||
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
|
||||
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
|
||||
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
|
||||
};
|
||||
|
||||
// Hauptfelder vergleichen
|
||||
const body = req.body;
|
||||
// Hauptfelder vergleichen – gegen die SANITISIERTE Version, damit
|
||||
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
|
||||
// rohen Request-Body mit ggf. gestrippter HTML.
|
||||
const body = sanitizedBody as any;
|
||||
if (before) {
|
||||
for (const [key, newVal] of Object.entries(body)) {
|
||||
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
|
||||
@@ -179,7 +280,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
customerId: before?.customerId,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: contract } as ApiResponse);
|
||||
// Response sanitisieren – sonst leakt portalPasswordEncrypted etc.
|
||||
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const sanitized = isPortal
|
||||
? sanitizeContractStrict(contract as any)
|
||||
: sanitizeContract(contract as any);
|
||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -191,6 +298,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
// Pentest 56.3 (latent): Defense-in-Depth – Ownership-Check vor Delete.
|
||||
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
|
||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||
await contractService.deleteContract(contractId);
|
||||
await logChange({
|
||||
@@ -211,6 +320,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
||||
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const previousContractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, previousContractId))) return;
|
||||
|
||||
// Vorgängervertrag laden für Vertragsnummer
|
||||
const previousContract = await prisma.contract.findUnique({
|
||||
@@ -247,7 +357,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -264,6 +376,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const previousContractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, previousContractId))) return;
|
||||
|
||||
const previousContract = await prisma.contract.findUnique({
|
||||
where: { id: previousContractId },
|
||||
@@ -299,7 +412,9 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -429,7 +544,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
|
||||
|
||||
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const cockpitData = await contractCockpitService.getCockpitData();
|
||||
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
|
||||
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
|
||||
// (Pentest Runde 4, 2026-05-16: HOCH).
|
||||
let customerIds: number[] | undefined;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerIds = [req.user.customerId];
|
||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||
if (hasAuth) {
|
||||
customerIds.push(repCustId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
|
||||
res.json({ success: true, data: cockpitData } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Cockpit error:', error);
|
||||
@@ -445,7 +575,7 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
|
||||
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
const { meterId, installedAt, finalReadingPrevious } = req.body;
|
||||
const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body;
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
@@ -458,19 +588,72 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
||||
}
|
||||
|
||||
const ecdId = contract.energyDetails.id;
|
||||
const existingMeters = contract.energyDetails.contractMeters;
|
||||
const existingMeters = [...contract.energyDetails.contractMeters];
|
||||
const switchAt = installedAt ? new Date(installedAt) : new Date();
|
||||
|
||||
// Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag)
|
||||
const predecessorMeterId = existingMeters.length > 0
|
||||
? existingMeters[existingMeters.length - 1].meterId
|
||||
: contract.energyDetails.meterId;
|
||||
|
||||
// Endstand bereits hier validieren (monoton-steigend gegen vorhandene
|
||||
// Zählerstände des Vorgängers), damit wir nicht halb-geschriebene
|
||||
// Zustände hinterlassen.
|
||||
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
|
||||
const finalReadingValue = parseFloat(finalReadingPrevious);
|
||||
// recordPredecessorFinalReading läuft erst NACH den Writes – Pre-Check
|
||||
// ohne Write hier separat über die Service-Validierung (idempotent, weil
|
||||
// sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert).
|
||||
// Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler
|
||||
// beim Reading die Kette nicht zerreißt.
|
||||
const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
const sameDay = await prisma.meterReading.findFirst({
|
||||
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
|
||||
});
|
||||
if (!sameDay) {
|
||||
const lastBefore = await prisma.meterReading.findFirst({
|
||||
where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } },
|
||||
orderBy: { readingDate: 'desc' },
|
||||
});
|
||||
if (lastBefore && finalReadingValue < lastBefore.value) {
|
||||
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE');
|
||||
throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
|
||||
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
|
||||
// Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
|
||||
// Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen
|
||||
// gedreht wird.
|
||||
if (existingMeters.length === 0 && contract.energyDetails.meterId) {
|
||||
const backfilled = await prisma.contractMeter.create({
|
||||
data: {
|
||||
energyContractDetailsId: ecdId,
|
||||
meterId: contract.energyDetails.meterId,
|
||||
position: 0,
|
||||
installedAt: null,
|
||||
},
|
||||
});
|
||||
existingMeters.push(backfilled);
|
||||
}
|
||||
|
||||
const nextPosition = existingMeters.length > 0
|
||||
? Math.max(...existingMeters.map(m => m.position)) + 1
|
||||
: 0;
|
||||
|
||||
// Vorherigen Zähler als gewechselt markieren
|
||||
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
|
||||
if (existingMeters.length > 0) {
|
||||
const prevMeter = existingMeters[existingMeters.length - 1];
|
||||
await prisma.contractMeter.update({
|
||||
where: { id: prevMeter.id },
|
||||
data: {
|
||||
removedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
finalReading: parseFloat(finalReadingPrevious),
|
||||
removedAt: switchAt,
|
||||
finalReading: finalReadingPrevious !== undefined
|
||||
? parseFloat(finalReadingPrevious)
|
||||
: prevMeter.finalReading,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -480,7 +663,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
||||
energyContractDetailsId: ecdId,
|
||||
meterId: parseInt(meterId),
|
||||
position: nextPosition,
|
||||
installedAt: installedAt ? new Date(installedAt) : new Date(),
|
||||
installedAt: switchAt,
|
||||
},
|
||||
include: { meter: { include: { readings: true } } },
|
||||
});
|
||||
@@ -491,6 +674,25 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
||||
data: { meterId: parseInt(meterId) },
|
||||
});
|
||||
|
||||
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in
|
||||
// die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht.
|
||||
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
|
||||
await recordPredecessorFinalReading(
|
||||
predecessorMeterId,
|
||||
switchAt,
|
||||
parseFloat(finalReadingPrevious),
|
||||
);
|
||||
}
|
||||
|
||||
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
|
||||
// explizit auf false setzt – ein-klick-fähiger Zählerwechsel.
|
||||
if (predecessorMeterId && deactivatePredecessor !== false) {
|
||||
await prisma.meter.update({
|
||||
where: { id: predecessorMeterId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'ContractMeter',
|
||||
resourceId: contractMeter.id.toString(),
|
||||
@@ -511,6 +713,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
||||
try {
|
||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'ContractMeter',
|
||||
@@ -547,7 +750,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { documentType, notes, deliveryDate } = req.body;
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||
@@ -559,33 +762,62 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
return;
|
||||
}
|
||||
|
||||
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
|
||||
let deliveryDate: string | null;
|
||||
try {
|
||||
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
||||
const doc = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId,
|
||||
documentType,
|
||||
documentPath,
|
||||
originalName: req.file.originalname,
|
||||
notes: notes || null,
|
||||
uploadedBy: req.user?.email,
|
||||
},
|
||||
});
|
||||
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
||||
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
||||
let cleanType: string;
|
||||
try {
|
||||
cleanType = validateContractDocumentType(documentType);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Pentest 55.4: Race-Schutz – Lock + Recent-Duplicate-Check.
|
||||
const doc = await withContractDocumentLock(contractId, cleanType, () =>
|
||||
prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId,
|
||||
documentType: cleanType,
|
||||
documentPath,
|
||||
originalName: req.file!.originalname,
|
||||
notes: sanitizeNotes(notes),
|
||||
uploadedBy: req.user?.email,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'ContractDocument',
|
||||
resourceId: doc.id.toString(),
|
||||
label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||
details: { typ: documentType, datei: req.file.originalname },
|
||||
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||
details: { typ: cleanType, datei: req.file.originalname },
|
||||
customerId: contract?.customerId,
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
||||
await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate);
|
||||
|
||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||
// Schreibfehler.
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||
} as ApiResponse);
|
||||
@@ -634,9 +866,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
||||
|
||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
||||
|
||||
export async function snoozeContract(req: Request, res: Response): Promise<void> {
|
||||
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, id))) return;
|
||||
const { nextReviewDate, months } = req.body;
|
||||
|
||||
let reviewDate: Date | null = null;
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
||||
res.json({ success: true, data: entries } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -19,6 +21,7 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
|
||||
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
@@ -54,6 +57,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
const { title, description } = req.body;
|
||||
|
||||
@@ -80,6 +84,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
|
||||
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
||||
|
||||
@@ -5,19 +5,30 @@ import * as customerService from '../services/customer.service.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
|
||||
|
||||
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
||||
|
||||
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { status, customerId } = req.query;
|
||||
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
|
||||
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
let customerPortalCustomerIds: number[] | undefined;
|
||||
let customerPortalEmails: string[] | undefined;
|
||||
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
if (allowedIds) {
|
||||
// Wenn der Portal-User explizit nach einer customerId filtert, die er
|
||||
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
|
||||
// (Pentest Runde 10 – LOW: konsistentes Response-Verhalten nach
|
||||
// Vollmacht-Widerruf).
|
||||
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
customerPortalCustomerIds = allowedIds;
|
||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
@@ -26,7 +37,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
const tasks = await contractTaskService.getAllTasks({
|
||||
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
||||
customerId: customerIdNum,
|
||||
customerPortalCustomerIds,
|
||||
customerPortalEmails,
|
||||
});
|
||||
@@ -42,12 +53,13 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
let customerPortalCustomerIds: number[] | undefined;
|
||||
let customerPortalEmails: string[] | undefined;
|
||||
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
if (allowedIds) {
|
||||
customerPortalCustomerIds = allowedIds;
|
||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
@@ -75,33 +87,17 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const { status } = req.query;
|
||||
|
||||
// Prüfe Zugriff auf den Vertrag
|
||||
const contract = await contractService.getContractById(contractId);
|
||||
if (!contract) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Vertrag nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
|
||||
// hasAuthorization (Pentest Runde 6 – HOCH-04: widerrufene Vollmachten
|
||||
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
|
||||
// konsultiert wurde, ohne Status-Check).
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
// Für Kundenportal: Zugriffsprüfung
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
if (!allowedCustomerIds.includes(contract.customerId)) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diesen Vertrag',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden
|
||||
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
|
||||
let customerPortalEmails: string[] | undefined;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
if (allowedIds) {
|
||||
const customers = await customerService.getCustomersByIds(allowedIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.filter((email: string | null): email is string => !!email);
|
||||
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe Zugriff auf den Vertrag
|
||||
const contract = await contractService.getContractById(contractId);
|
||||
if (!contract) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Vertrag nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Zugriffsprüfung für Kundenportal
|
||||
if (req.user?.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
if (!allowedCustomerIds.includes(contract.customerId)) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diesen Vertrag',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const createdBy = req.user?.email;
|
||||
|
||||
@@ -376,24 +353,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
const allowedEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.filter((email: string | null): email is string => !!email);
|
||||
|
||||
// Task muss entweder visibleInPortal sein ODER vom Kunden erstellt worden sein
|
||||
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
|
||||
if (!task.visibleInPortal && !isOwnTask) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diese Anfrage',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!req.user?.isCustomerPortal || !req.user.customerId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Nur für Kundenportal-Benutzer',
|
||||
@@ -401,6 +361,27 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||
if (!(await canAccessContract(req, res, task.contractId))) return;
|
||||
|
||||
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
|
||||
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
const allowedEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.filter((email: string | null): email is string => !!email);
|
||||
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
|
||||
if (!task.visibleInPortal && !isOwnTask) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diese Anfrage',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdBy = req.user?.email;
|
||||
|
||||
const subtask = await contractTaskService.createSubtask({
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
||||
import * as customerService from '../services/customer.service.js';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import {
|
||||
sanitizeCustomer,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
sanitizeCustomerStrict,
|
||||
pickCustomerCreate,
|
||||
pickCustomerUpdate,
|
||||
sanitizePhoneField,
|
||||
isValidEmail,
|
||||
} from '../utils/sanitize.js';
|
||||
import {
|
||||
canAccessMeter,
|
||||
@@ -17,30 +20,28 @@ import {
|
||||
canAccessBankCard,
|
||||
canAccessIdentityDocument,
|
||||
canAccessCustomer,
|
||||
getPortalAllowedCustomerIds,
|
||||
} from '../utils/accessControl.js';
|
||||
|
||||
// Customer CRUD
|
||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { search, type, page, limit } = req.query;
|
||||
|
||||
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
|
||||
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
|
||||
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
|
||||
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
|
||||
const result = await customerService.getAllCustomers({
|
||||
search: search as string,
|
||||
type: type as 'PRIVATE' | 'BUSINESS',
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
allowedIds: allowedIds ?? undefined,
|
||||
});
|
||||
let customers = result.customers as any[];
|
||||
|
||||
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
|
||||
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
|
||||
// an einen einzelnen Portal-Account preisgeben.
|
||||
if (req.user?.isCustomerPortal) {
|
||||
const allowedIds = new Set<number>();
|
||||
if (req.user.customerId) allowedIds.add(req.user.customerId);
|
||||
const represented = (req.user as any).representedCustomerIds || [];
|
||||
for (const id of represented) allowedIds.add(id);
|
||||
customers = customers.filter((c) => allowedIds.has(c.id));
|
||||
}
|
||||
const customers = result.customers as any[];
|
||||
|
||||
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||
@@ -80,6 +81,30 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
try {
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||
const data: any = pickCustomerCreate(req.body);
|
||||
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||
if (data.email && !isValidEmail(data.email)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// 60.3: Phone/Mobile auch beim Create gegen Header-Injection sichern.
|
||||
try {
|
||||
if ('phone' in data) {
|
||||
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||
data.phone = cleaned ?? null;
|
||||
}
|
||||
if ('mobile' in data) {
|
||||
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||
data.mobile = cleaned ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Convert birthDate string to Date if present
|
||||
if (data.birthDate) {
|
||||
data.birthDate = new Date(data.birthDate);
|
||||
@@ -91,7 +116,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
|
||||
customerId: customer.id,
|
||||
});
|
||||
res.status(201).json({ success: true, data: customer } as ApiResponse);
|
||||
// Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
|
||||
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
|
||||
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
|
||||
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
|
||||
const sanitized = canSeePasswords
|
||||
? sanitizeCustomer(customer as any)
|
||||
: sanitizeCustomerStrict(customer as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -104,8 +136,34 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
try {
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
|
||||
if (req.body?.email && !isValidEmail(req.body.email)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const data: any = pickCustomerUpdate(req.body);
|
||||
|
||||
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||
// jetzt zusätzlich durch sanitizePhoneField (Allowlist).
|
||||
try {
|
||||
if ('phone' in data) {
|
||||
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||
data.phone = cleaned ?? null;
|
||||
}
|
||||
if ('mobile' in data) {
|
||||
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||
data.mobile = cleaned ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
|
||||
@@ -170,7 +228,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: customer } as ApiResponse);
|
||||
// Response sanitisieren – sonst leakt portalPasswordHash +
|
||||
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
|
||||
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
|
||||
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
|
||||
const sanitized = canSeePasswords
|
||||
? sanitizeCustomer(customer as any)
|
||||
: sanitizeCustomerStrict(customer as any);
|
||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Update customer error:', error);
|
||||
res.status(400).json({
|
||||
@@ -232,9 +297,10 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAddress(req: Request, res: Response): Promise<void> {
|
||||
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const addressId = parseInt(req.params.id);
|
||||
if (!(await canAccessAddress(req, res, addressId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -295,9 +361,10 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAddress(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const addressId = parseInt(req.params.id);
|
||||
if (!(await canAccessAddress(req, res, addressId))) return;
|
||||
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
|
||||
const customerId = addr?.customerId;
|
||||
await customerService.deleteAddress(addressId);
|
||||
@@ -349,9 +416,10 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBankCard(req: Request, res: Response): Promise<void> {
|
||||
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const cardId = parseInt(req.params.id);
|
||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -407,9 +475,10 @@ export async function updateBankCard(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBankCard(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const cardId = parseInt(req.params.id);
|
||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
|
||||
const customerId = card?.customerId;
|
||||
await customerService.deleteBankCard(cardId);
|
||||
@@ -461,9 +530,10 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDocument(req: Request, res: Response): Promise<void> {
|
||||
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const docId = parseInt(req.params.id);
|
||||
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -525,9 +595,10 @@ export async function updateDocument(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDocument(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const docId = parseInt(req.params.id);
|
||||
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
||||
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
|
||||
const customerId = doc?.customerId;
|
||||
await customerService.deleteDocument(docId);
|
||||
@@ -564,10 +635,13 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const meter = await customerService.createMeter(customerId, req.body);
|
||||
const successorLabel = meter.predecessor
|
||||
? ` als Folgezähler von ${meter.predecessor.meterNumber}`
|
||||
: '';
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Meter',
|
||||
resourceId: meter.id.toString(),
|
||||
label: `Zähler angelegt für Kunde #${customerId}`,
|
||||
label: `Zähler angelegt${successorLabel} für Kunde #${customerId}`,
|
||||
customerId,
|
||||
});
|
||||
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
||||
@@ -579,9 +653,10 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMeter(req: Request, res: Response): Promise<void> {
|
||||
export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.id);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -595,7 +670,7 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell',
|
||||
location: 'Standort', isActive: 'Aktiv',
|
||||
location: 'Standort', isActive: 'Aktiv', addressId: 'Lieferadresse',
|
||||
};
|
||||
for (const [key, newVal] of Object.entries(data)) {
|
||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||
@@ -636,9 +711,10 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMeter(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.id);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
await customerService.deleteMeter(meterId);
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'Meter',
|
||||
@@ -666,10 +742,11 @@ export async function getMeterReadings(req: AuthRequest, res: Response): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function addMeterReading(req: Request, res: Response): Promise<void> {
|
||||
export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const reading = await customerService.addMeterReading(meterId, {
|
||||
readingDate: new Date(readingDate),
|
||||
value: parseFloat(value),
|
||||
@@ -702,8 +779,10 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
|
||||
export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
||||
@@ -713,7 +792,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const reading = await customerService.updateMeterReading(
|
||||
parseInt(req.params.meterId),
|
||||
meterId,
|
||||
parseInt(req.params.readingId),
|
||||
updateData as any
|
||||
);
|
||||
@@ -731,13 +810,12 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMeterReading(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const readingId = parseInt(req.params.readingId);
|
||||
await customerService.deleteMeterReading(
|
||||
parseInt(req.params.meterId),
|
||||
readingId
|
||||
);
|
||||
await customerService.deleteMeterReading(meterId, readingId);
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'MeterReading',
|
||||
resourceId: readingId.toString(),
|
||||
@@ -838,6 +916,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
|
||||
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const readingId = parseInt(req.params.readingId);
|
||||
|
||||
const reading = await prisma.meterReading.update({
|
||||
@@ -897,7 +976,27 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
|
||||
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const { portalEnabled, portalEmail } = req.body;
|
||||
// `password` (oder password-ähnliche Felder) gehören NICHT in den
|
||||
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
|
||||
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
|
||||
// ein Passwort setzen will, nutzt POST /portal/password mit
|
||||
// Komplexitäts-Check. (Pentest-Befund.)
|
||||
const body = req.body || {};
|
||||
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
|
||||
const offending = forbidden.filter((k) => k in body);
|
||||
if (offending.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { portalEnabled, portalEmail } = body;
|
||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
|
||||
if (portalEmail && !isValidEmail(portalEmail)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.customer.findUnique({
|
||||
@@ -957,13 +1056,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
|
||||
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
|
||||
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
|
||||
*/
|
||||
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const password = generateSecurePassword({ length: 16 });
|
||||
res.json({ success: true, data: { password } } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
|
||||
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
|
||||
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
|
||||
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
|
||||
*/
|
||||
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: {
|
||||
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
|
||||
email: true, portalEmail: true, portalEnabled: true,
|
||||
portalPasswordEncrypted: true, portalPasswordHash: true,
|
||||
},
|
||||
});
|
||||
if (!customer) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!customer.portalEnabled) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Portal ist für diesen Kunden nicht aktiviert',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!customer.portalPasswordHash) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Es ist noch kein Portal-Passwort gesetzt',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const targetEmail = customer.email || customer.portalEmail;
|
||||
if (!targetEmail) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const loginEmail = customer.portalEmail || customer.email!;
|
||||
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
|
||||
if (!plaintextPassword) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
await authService.sendPortalCredentialsEmail({
|
||||
to: targetEmail,
|
||||
customer,
|
||||
loginEmail,
|
||||
password: plaintextPassword,
|
||||
});
|
||||
|
||||
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
|
||||
// der Kunde sich ein eigenes setzen.
|
||||
await authService.markPortalPasswordForChange(customerId);
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'PortalSettings',
|
||||
resourceId: customerId.toString(),
|
||||
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
|
||||
customerId,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 6) {
|
||||
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
|
||||
const complexity = validatePasswordComplexity(password);
|
||||
if (!complexity.ok) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
@@ -984,9 +1185,10 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
|
||||
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const password = await authService.getCustomerPortalPassword(customerId);
|
||||
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
||||
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
||||
@@ -1025,9 +1227,10 @@ export async function getRepresentatives(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRepresentative(req: Request, res: Response): Promise<void> {
|
||||
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const { representativeId, notes } = req.body;
|
||||
const representative = await customerService.addRepresentative(
|
||||
customerId,
|
||||
@@ -1049,9 +1252,10 @@ export async function addRepresentative(req: Request, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRepresentative(req: Request, res: Response): Promise<void> {
|
||||
export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
await customerService.removeRepresentative(
|
||||
customerId,
|
||||
parseInt(req.params.representativeId)
|
||||
@@ -1070,8 +1274,13 @@ export async function removeRepresentative(req: Request, res: Response): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchForRepresentative(req: Request, res: Response): Promise<void> {
|
||||
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
|
||||
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
|
||||
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const { search } = req.query;
|
||||
if (!search || typeof search !== 'string' || search.length < 2) {
|
||||
res.json({ success: true, data: [] } as ApiResponse);
|
||||
@@ -1079,7 +1288,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
|
||||
}
|
||||
const customers = await customerService.searchCustomersForRepresentative(
|
||||
search,
|
||||
parseInt(req.params.customerId)
|
||||
customerId,
|
||||
);
|
||||
res.json({ success: true, data: customers } as ApiResponse);
|
||||
} catch (error) {
|
||||
|
||||
@@ -123,10 +123,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
||||
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass
|
||||
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
||||
// Pentest 50.1: strict=true – test-connection darf NIE auf private IPs,
|
||||
// Loopback oder Cloud-Metadata zeigen, unabhängig von
|
||||
// SSRF_BLOCK_PRIVATE_IPS. On-Prem mit echtem internen Plesk kann das
|
||||
// per SSRF_ALLOW_INTERNAL_TESTING=true opt-outen (Default: blockiert).
|
||||
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
|
||||
if (testData?.apiUrl) {
|
||||
try {
|
||||
const url = new URL(testData.apiUrl);
|
||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||
await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||
const ctx = contextFromRequest(req);
|
||||
@@ -247,12 +252,14 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
||||
// Pentest 50.1 analog testConnection: strict, opt-out via env.
|
||||
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
|
||||
let smtpResolved: { ip: string; servername: string };
|
||||
let imapResolved: { ip: string; servername: string };
|
||||
try {
|
||||
[smtpResolved, imapResolved] = await Promise.all([
|
||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||
safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }),
|
||||
safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }),
|
||||
]);
|
||||
} catch (err) {
|
||||
const ctx = contextFromRequest(req);
|
||||
|
||||
@@ -78,7 +78,81 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
||||
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
|
||||
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
|
||||
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
|
||||
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
||||
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
||||
// Extension – also `text/html` – und der Browser hätte das als
|
||||
// Stored-XSS gerendert.
|
||||
//
|
||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||||
//
|
||||
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||||
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||||
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||||
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||||
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const wantsInline = req.query.disposition === 'inline';
|
||||
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||
|
||||
if (wantsInline && !safeContentType) {
|
||||
console.warn(
|
||||
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (safeContentType) {
|
||||
res.setHeader('Content-Type', safeContentType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||||
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||||
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||||
*
|
||||
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||||
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||||
*/
|
||||
function detectSafeContentType(absolute: string): string | null {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(absolute, 'r');
|
||||
const head = Buffer.alloc(12);
|
||||
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||||
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||||
if (bytesRead >= 8
|
||||
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) return 'image/png';
|
||||
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||
if (bytesRead >= 6
|
||||
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||||
) return 'image/gif';
|
||||
if (bytesRead >= 12
|
||||
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||||
) return 'image/webp';
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
|
||||
import * as consentService from '../services/consent.service.js';
|
||||
import * as consentPublicService from '../services/consent-public.service.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { getPublicUrl } from '../services/auth.service.js';
|
||||
import { canAccessCustomer } from '../utils/accessControl.js';
|
||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||
@@ -13,6 +14,7 @@ import fs from 'fs';
|
||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { stripHtml } from '../utils/sanitize.js';
|
||||
|
||||
/**
|
||||
* Kundendaten exportieren (DSGVO Art. 15)
|
||||
@@ -64,7 +66,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
|
||||
// aus dem Body kommen (Route hat kein :id-Segment) – und der Caller
|
||||
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
|
||||
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
|
||||
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
|
||||
const bodyCustomerId = req.body?.customerId;
|
||||
const customerId = typeof bodyCustomerId === 'number'
|
||||
? bodyCustomerId
|
||||
: parseInt(bodyCustomerId);
|
||||
if (!Number.isFinite(customerId) || customerId < 1) {
|
||||
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const { requestSource } = req.body;
|
||||
|
||||
const request = await gdprService.createDeletionRequest({
|
||||
@@ -269,7 +284,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const consentType = req.params.consentType as ConsentType;
|
||||
const { status, source, documentPath, version } = req.body;
|
||||
// BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
|
||||
// und `version` darf der Portal-User NICHT setzen – Pentest 2026-05-20
|
||||
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
|
||||
// landeten vorher ungefiltert in der DB. source ist für diesen
|
||||
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
|
||||
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
|
||||
// (falls überhaupt) später nach.
|
||||
const { status } = req.body;
|
||||
|
||||
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
|
||||
if (!(req.user as any)?.isCustomerPortal) {
|
||||
@@ -279,22 +301,26 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
// Portal: nur eigene + vertretene Kunden
|
||||
const allowed = [
|
||||
(req.user as any).customerId,
|
||||
...((req.user as any).representedCustomerIds || []),
|
||||
];
|
||||
if (!allowed.includes(customerId)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Keine Berechtigung für diesen Kunden',
|
||||
});
|
||||
}
|
||||
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
|
||||
// widerrufene Vollmachten hatten vorher noch Zugriff)
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
|
||||
if (!Object.values(ConsentType).includes(consentType)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||
}
|
||||
|
||||
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
|
||||
// mitschicken und damit den Consent-Status auf den initialen System-
|
||||
// Status zurücksetzen. PENDING ist nur intern (Default beim
|
||||
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
|
||||
// Verfälschte sonst die DSGVO-Auswertung.
|
||||
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
|
||||
});
|
||||
}
|
||||
|
||||
const consentLabels: Record<string, string> = {
|
||||
DATA_PROCESSING: 'Datenverarbeitung',
|
||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||
@@ -304,9 +330,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
|
||||
const consent = await consentService.updateConsent(customerId, consentType, {
|
||||
status,
|
||||
source: source || 'portal',
|
||||
documentPath,
|
||||
version,
|
||||
source: 'portal',
|
||||
ipAddress: req.socket.remoteAddress,
|
||||
createdBy: req.user?.email || 'unknown',
|
||||
});
|
||||
@@ -315,7 +339,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CustomerConsent',
|
||||
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
|
||||
details: { einwilligung: consentName, status, quelle: source || 'portal' },
|
||||
details: { einwilligung: consentName, status, quelle: 'portal' },
|
||||
customerId,
|
||||
});
|
||||
|
||||
@@ -561,7 +585,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
||||
|
||||
// ConsentHash sicherstellen
|
||||
const hash = await consentPublicService.ensureConsentHash(customerId);
|
||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
||||
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||
const baseUrl = await getPublicUrl();
|
||||
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
|
||||
|
||||
// Bei E-Mail: tatsächlich senden
|
||||
@@ -704,7 +731,10 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
||||
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
||||
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||
const baseUrl = await getPublicUrl();
|
||||
const portalUrl = `${baseUrl}/privacy`;
|
||||
|
||||
// E-Mail senden
|
||||
@@ -822,9 +852,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
||||
const representativeId = parseInt(req.params.representativeId);
|
||||
const { source, notes } = req.body;
|
||||
|
||||
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
|
||||
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
|
||||
// stripHtml geschickt (Plain-Text-Feld).
|
||||
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
|
||||
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
|
||||
|
||||
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
|
||||
source: source || 'crm-backend',
|
||||
notes,
|
||||
source: safeSource,
|
||||
notes: safeNotes as string | undefined,
|
||||
});
|
||||
|
||||
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
||||
@@ -887,6 +923,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
||||
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||
}
|
||||
|
||||
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
|
||||
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
|
||||
// hochladen. Wir verlangen:
|
||||
// 1) Magic-Bytes "%PDF-" am Anfang
|
||||
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
|
||||
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
|
||||
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
|
||||
// passierte die reine Magic-Byte-Prüfung).
|
||||
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
|
||||
// hier nicht erkannt – aber das ist Adobe-Acrobat-Risiko und nicht
|
||||
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
|
||||
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
|
||||
try {
|
||||
const stat = fs.statSync(req.file.path);
|
||||
const fd = fs.openSync(req.file.path, 'r');
|
||||
|
||||
// Header
|
||||
const head = Buffer.alloc(5);
|
||||
fs.readSync(fd, head, 0, 5, 0);
|
||||
if (!head.equals(PDF_MAGIC)) {
|
||||
fs.closeSync(fd);
|
||||
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
|
||||
});
|
||||
}
|
||||
|
||||
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
|
||||
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
|
||||
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
|
||||
// klare Spoof-Indikatoren.
|
||||
const headSize = Math.min(stat.size, 4096);
|
||||
const headBuf = Buffer.alloc(headSize);
|
||||
fs.readSync(fd, headBuf, 0, headSize, 0);
|
||||
const headStr = headBuf.toString('latin1').toLowerCase();
|
||||
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz | ||||