Compare commits
85 Commits
4e91d96b5b
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 96feb6a663 | |||
| 49905aa97e | |||
| e2fdb069ac | |||
| 0cf3dd6a7b | |||
| 45fe270a38 | |||
| 73f271ae03 | |||
| 4385ae575d | |||
| 6b804cdc82 | |||
| df6eb9724d | |||
| 0c0cecdbbd | |||
| 35745ce3bb | |||
| dea2da0271 | |||
| 0a757d8e47 | |||
| 4e680a36e7 | |||
| a129781035 | |||
| 4ca91eb710 | |||
| 8aead8c2f6 | |||
| 301aafffd1 | |||
| 81f0e89058 | |||
| 1c46d7345c | |||
| 8fc050a282 | |||
| 0764bc6ddf | |||
| 8d113f4c6b | |||
| fd480113d0 | |||
| 95bf118fc2 | |||
| 075c095b8e | |||
| 3fa1dce2dc | |||
| b7d3654b72 | |||
| cdde7b4ab7 | |||
| cf4370c905 | |||
| 1de8fb9847 | |||
| fd55f3129f | |||
| 109f774d62 | |||
| 60dc98e265 | |||
| b78afce43c | |||
| 2879bd64d6 | |||
| aa2b5ce785 | |||
| 9d6bd68ddc | |||
| 2a3928d0e7 | |||
| ba29711ee7 | |||
| 888c75bb41 | |||
| 5adc71e52c | |||
| c93086059d | |||
| b47f33aaa5 | |||
| 018784cca6 | |||
| 2775e9d4dc | |||
| 0d58b79836 | |||
| eaf7d1eac3 | |||
| 9a84e2d3cb | |||
| 9fa1cbc591 | |||
| a0705b1a61 | |||
| 0e75e6c8e5 | |||
| a15772cb54 | |||
| fd55742c57 | |||
| 38b3b7da73 | |||
| eecc6cd73e | |||
| d7b42f64b1 | |||
| c3edb8ad2e | |||
| 09e87c951b | |||
| 468907c9c3 | |||
| e0fc26795e | |||
| 746706ef01 | |||
| 4f588015a4 | |||
| 55f257fffd | |||
| 2ab2bb7562 | |||
| 839bb40f5e | |||
| b397a974df | |||
| ad2b8ea5b6 | |||
| 89d528bb77 | |||
| e8919cfa81 | |||
| 80dd5cc157 | |||
| f33d157b9b | |||
| 8c65fecef0 | |||
| 866a285037 | |||
| c9d1ad7796 | |||
| f21eb20715 | |||
| 7a0f4461aa | |||
| d8ced5cb24 | |||
| b87053760e | |||
| 30103e6099 | |||
| bf068276b5 | |||
| 9256d9397b | |||
| 8c9e61cf17 | |||
| ef18381dd8 | |||
| e209e9bbca |
@@ -2,6 +2,8 @@
|
||||
|
||||
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
||||
|
||||
**Version: 1.1.0** ([Changelog](#changelog))
|
||||
|
||||
## Features
|
||||
|
||||
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
||||
@@ -11,6 +13,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||
- **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),
|
||||
Kündigungsbestätigung-Upload setzt `ACTIVE` → `CANCELLED` (mit Datum),
|
||||
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
|
||||
- **Verträge**:
|
||||
- Energie (Strom, Gas)
|
||||
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
||||
@@ -20,7 +25,14 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
||||
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
||||
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
||||
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
|
||||
Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
|
||||
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
|
||||
Sofort-E-Mail-Alerts und stündlichem Digest – siehe Einstellungen → Monitoring
|
||||
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
|
||||
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
|
||||
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
||||
|
||||
## Tech Stack
|
||||
@@ -140,6 +152,39 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
||||
- **E-Mail:** admin@admin.com
|
||||
- **Passwort:** admin
|
||||
|
||||
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
|
||||
> ändern und Secrets rotieren – siehe [Production-Deployment](#production-deployment).
|
||||
|
||||
## Production-Deployment
|
||||
|
||||
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
|
||||
# Pflicht-Rotation – per `openssl rand` neu generieren!
|
||||
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
|
||||
|
||||
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
|
||||
LISTEN_ADDR=127.0.0.1
|
||||
|
||||
# Bei separatem Frontend-Host: erlaubte Origins
|
||||
CORS_ORIGINS=https://crm.deine-domain.de
|
||||
```
|
||||
|
||||
Plus:
|
||||
|
||||
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
|
||||
die echte Client-IP gesetzt wird (nicht nur angefügt) – sonst Rate-Limit-Bypass möglich.
|
||||
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
|
||||
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
||||
durchklicken.
|
||||
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
||||
hinterlegen, Test-Alert senden, Digest aktivieren.
|
||||
- Vollständige Hardening-Story + restliche Trade-offs:
|
||||
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||
|
||||
## Developer-Tools aktivieren
|
||||
|
||||
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
||||
@@ -1105,6 +1150,50 @@ ersetzt.
|
||||
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
||||
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.1.0 (2026-05-01)
|
||||
|
||||
**Production-readiness** – die Version, die wirklich öffentlich gehen darf.
|
||||
|
||||
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
|
||||
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
|
||||
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
|
||||
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
|
||||
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
|
||||
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
|
||||
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
|
||||
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
|
||||
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
|
||||
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
|
||||
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
|
||||
- 🔄 **Auto-Vertragsstatus**:
|
||||
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
|
||||
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
|
||||
(mit Datums-Modal beim Upload)
|
||||
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
|
||||
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
|
||||
Login transparent auf Cost 12 geupgradet.
|
||||
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
|
||||
über `tokenInvalidatedAt`.
|
||||
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
|
||||
path-to-regexp, undici, minimatch).
|
||||
|
||||
### 1.0.0
|
||||
|
||||
Erste Release-Version.
|
||||
|
||||
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
|
||||
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
|
||||
- Vertrags-Cockpit mit Rechnungsprüfung
|
||||
- E-Mail-Client mit Anhang-Verwaltung
|
||||
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
|
||||
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
|
||||
- Factory-Defaults für Stammdaten-Kataloge
|
||||
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
|
||||
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Database - Root für Migrationen, opencrm-User für Runtime
|
||||
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
Generated
+135
-49
@@ -511,7 +511,8 @@
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -986,6 +987,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -1069,9 +1071,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
@@ -2003,19 +2006,31 @@
|
||||
]
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
|
||||
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
|
||||
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"@zone-eu/mailsplit": "5.4.9",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "7.0.13",
|
||||
"pino": "10.3.0",
|
||||
"socks": "2.8.7"
|
||||
"nodemailer": "8.0.7",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
|
||||
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||
@@ -2033,6 +2048,27 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/nodemailer": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2225,9 +2261,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
@@ -2270,18 +2307,19 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
||||
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.7",
|
||||
"libmime": "5.3.8",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "7.0.13",
|
||||
"nodemailer": "8.0.5",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
@@ -2301,6 +2339,27 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2364,11 +2423,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2483,6 +2543,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -2552,9 +2613,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
@@ -2601,9 +2663,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
@@ -2625,6 +2688,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
@@ -2632,7 +2696,8 @@
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
@@ -2684,7 +2749,8 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
@@ -2707,9 +2773,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -2723,7 +2790,8 @@
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
@@ -2775,9 +2843,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
@@ -2789,6 +2858,7 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
@@ -2830,6 +2900,7 @@
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -3010,17 +3081,19 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
|
||||
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3028,10 +3101,20 @@
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks/node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
@@ -3040,6 +3123,7 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
@@ -3193,6 +3277,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
@@ -3281,9 +3366,10 @@
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencrm-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "OpenCRM Backend API",
|
||||
"main": "dist/index.js",
|
||||
"prisma": {
|
||||
|
||||
@@ -1113,3 +1113,53 @@ model AuditRetentionPolicy {
|
||||
|
||||
@@unique([resourceType, sensitivity])
|
||||
}
|
||||
|
||||
// ==================== SECURITY MONITORING ====================
|
||||
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
|
||||
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
|
||||
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
|
||||
// effizient querybar). Threshold-Detection läuft per Cron.
|
||||
|
||||
enum SecurityEventType {
|
||||
LOGIN_FAILED // falsches Passwort / unbekannter User
|
||||
LOGIN_SUCCESS // erfolgreicher Login (informativ)
|
||||
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
|
||||
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
|
||||
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
|
||||
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
|
||||
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
|
||||
LOGOUT // expliziter Logout
|
||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||
SUSPICIOUS // generischer Catch-All
|
||||
}
|
||||
|
||||
enum SecuritySeverity {
|
||||
INFO // Login-Success, Logout
|
||||
LOW // Einzelner failed Login, einzelner 403
|
||||
MEDIUM // Rate-Limit-Hit, mehrere 403er
|
||||
HIGH // SSRF-Block, JWT-Manipulation
|
||||
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
||||
}
|
||||
|
||||
model SecurityEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
type SecurityEventType
|
||||
severity SecuritySeverity
|
||||
message String @db.Text
|
||||
ipAddress String?
|
||||
userId Int? // Mitarbeiter (falls eingeloggt)
|
||||
customerId Int? // Portal-Kunde (falls eingeloggt)
|
||||
userEmail String? // beste Schätzung – auch bei nicht eingeloggt
|
||||
endpoint String? // betroffener Endpoint
|
||||
details Json? // strukturierte Zusatzinfo
|
||||
alerted Boolean @default(false) // schon per Email versendet?
|
||||
alertedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([type, createdAt])
|
||||
@@index([severity, createdAt])
|
||||
@@index([ipAddress, createdAt])
|
||||
@@index([alerted, severity])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
const ctx = contextFromRequest(req);
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -16,8 +18,25 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
message: `Mitarbeiter-Login: ${email}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: result.user.id,
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
severity: 'LOW',
|
||||
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
@@ -27,9 +46,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
|
||||
// Kundenportal-Login
|
||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
const ctx = contextFromRequest(req);
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -39,8 +58,25 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
const result = await authService.customerLogin(email, password);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
severity: 'INFO',
|
||||
message: `Portal-Login: ${email}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
customerId: result.user.customerId,
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
emitSecurityEvent({
|
||||
type: 'LOGIN_FAILED',
|
||||
severity: 'LOW',
|
||||
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
@@ -114,6 +150,17 @@ export async function requestPasswordReset(req: Request, res: Response): Promise
|
||||
|
||||
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
|
||||
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PASSWORD_RESET_REQUEST',
|
||||
severity: 'MEDIUM',
|
||||
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userEmail: email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
|
||||
});
|
||||
|
||||
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -154,11 +201,28 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||
|
||||
await authService.confirmPasswordReset(token, password);
|
||||
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PASSWORD_RESET_CONFIRM',
|
||||
severity: 'HIGH',
|
||||
message: 'Passwort-Reset abgeschlossen',
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'TOKEN_REJECTED',
|
||||
severity: 'MEDIUM',
|
||||
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
||||
@@ -166,6 +230,53 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
|
||||
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
|
||||
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
|
||||
*
|
||||
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
|
||||
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
|
||||
* (auch andere Geräte) – akzeptabel für ein Sicherheits-Logout.
|
||||
*/
|
||||
export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const user = req.user as any;
|
||||
if (!user) {
|
||||
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (user.isCustomerPortal && user.customerId) {
|
||||
await prisma.customer.update({
|
||||
where: { id: user.customerId },
|
||||
data: { portalTokenInvalidatedAt: new Date() },
|
||||
});
|
||||
} else if (user.userId) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'LOGOUT',
|
||||
severity: 'INFO',
|
||||
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: ctx.userId,
|
||||
customerId: ctx.customerId,
|
||||
userEmail: user.email,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abmelden',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ApiResponse } from '../types/index.js';
|
||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -118,6 +120,33 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
domain: req.body.domain,
|
||||
} : undefined;
|
||||
|
||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
||||
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass
|
||||
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
||||
if (testData?.apiUrl) {
|
||||
try {
|
||||
const url = new URL(testData.apiUrl);
|
||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'SSRF_BLOCKED',
|
||||
severity: 'HIGH',
|
||||
message: err.message,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: ctx.userId,
|
||||
userEmail: ctx.userEmail,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { apiUrl: testData.apiUrl },
|
||||
});
|
||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// URL-Parse-Fehler ignorieren – Backend reagiert sowieso mit Fehler
|
||||
}
|
||||
}
|
||||
|
||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -214,24 +243,56 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
|
||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
||||
let smtpResolved: { ip: string; servername: string };
|
||||
let imapResolved: { ip: string; servername: string };
|
||||
try {
|
||||
[smtpResolved, imapResolved] = await Promise.all([
|
||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||
]);
|
||||
} catch (err) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'SSRF_BLOCKED',
|
||||
severity: 'HIGH',
|
||||
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: ctx.userId,
|
||||
userEmail: ctx.userEmail,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { smtpServer, imapServer },
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// IMAP testen
|
||||
const imapCredentials: ImapCredentials = {
|
||||
host: imapServer,
|
||||
host: imapResolved.ip,
|
||||
port: imapPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: imapEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: imapResolved.servername,
|
||||
};
|
||||
|
||||
// SMTP testen
|
||||
const smtpCredentials: SmtpCredentials = {
|
||||
host: smtpServer,
|
||||
host: smtpResolved.ip,
|
||||
port: smtpPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: smtpEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: smtpResolved.servername,
|
||||
};
|
||||
|
||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import { findUploadOwner } from '../services/fileDownload.service.js';
|
||||
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
/**
|
||||
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
|
||||
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
|
||||
*
|
||||
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
|
||||
*
|
||||
* Schritte:
|
||||
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
|
||||
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
|
||||
* 3. canAccessCustomer / canAccessContract / Permission-Check
|
||||
* 4. Datei senden (mit korrektem Content-Type)
|
||||
*
|
||||
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
|
||||
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
|
||||
* vertretenen Kunden mit Vollmacht) herunterladen – nicht mehr beliebige
|
||||
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
|
||||
* mitgeschnitten hätte.
|
||||
*/
|
||||
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
|
||||
const requested = typeof req.query.path === 'string' ? req.query.path : '';
|
||||
if (!requested) {
|
||||
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Format-Validierung (Traversal-Schutz)
|
||||
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Owner ermitteln
|
||||
const owner = await findUploadOwner(requested);
|
||||
if (!owner) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Access-Check je nach Owner-Typ
|
||||
if (owner.kind === 'customer') {
|
||||
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||||
} else if (owner.kind === 'contract') {
|
||||
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||||
} else if (owner.kind === 'admin') {
|
||||
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
} else if (owner.kind === 'gdpr-admin') {
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('gdpr:admin')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Datei vom Disk lesen
|
||||
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
|
||||
const relative = requested.substring('/uploads/'.length);
|
||||
const absolute = path.join(process.cwd(), 'uploads', relative);
|
||||
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
|
||||
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
|
||||
if (!absolute.startsWith(uploadsRoot)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(absolute)) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Response } from 'express';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
|
||||
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/events
|
||||
* Liste der Security-Events mit Filter + Pagination.
|
||||
*/
|
||||
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const page = parseInt((req.query.page as string) || '1');
|
||||
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
|
||||
const type = req.query.type as SecurityEventType | undefined;
|
||||
const severity = req.query.severity as SecuritySeverity | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
const since = req.query.since as string | undefined;
|
||||
const ip = req.query.ip as string | undefined;
|
||||
|
||||
const where: any = {};
|
||||
if (type) where.type = type;
|
||||
if (severity) where.severity = severity;
|
||||
if (ip) where.ipAddress = ip;
|
||||
if (since) where.createdAt = { gte: new Date(since) };
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ message: { contains: search } },
|
||||
{ userEmail: { contains: search } },
|
||||
{ endpoint: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const [events, total, byType, bySeverity] = await Promise.all([
|
||||
prisma.securityEvent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
}),
|
||||
prisma.securityEvent.count({ where }),
|
||||
prisma.securityEvent.groupBy({
|
||||
by: ['type'],
|
||||
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||
_count: true,
|
||||
}),
|
||||
prisma.securityEvent.groupBy({
|
||||
by: ['severity'],
|
||||
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: events,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
stats: {
|
||||
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
|
||||
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
|
||||
},
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error('listEvents error:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/settings
|
||||
*/
|
||||
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
alertEmail: alertEmail || '',
|
||||
digestEnabled,
|
||||
lastDigestAt: lastDigest || null,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/monitoring/settings
|
||||
*/
|
||||
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { alertEmail, digestEnabled } = req.body || {};
|
||||
if (typeof alertEmail === 'string') {
|
||||
// Email-Validierung minimal: muss @ enthalten oder leer sein
|
||||
if (alertEmail !== '' && !alertEmail.includes('@')) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
|
||||
}
|
||||
if (typeof digestEnabled === 'boolean') {
|
||||
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
|
||||
}
|
||||
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/monitoring/test-alert
|
||||
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
|
||||
*/
|
||||
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||
if (!alertEmail) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Alert-E-Mail konfiguriert',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const result = await sendAlertEmail(alertEmail, {
|
||||
subject: '[OpenCRM] Test-Alert',
|
||||
events: [{
|
||||
type: 'SUSPICIOUS' as any,
|
||||
severity: 'INFO' as any,
|
||||
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
|
||||
createdAt: new Date(),
|
||||
} as any],
|
||||
isDigest: false,
|
||||
});
|
||||
if (result.success) {
|
||||
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
|
||||
} else {
|
||||
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/monitoring/events
|
||||
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
|
||||
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
|
||||
* Audit-Trail erhalten bleibt.
|
||||
*/
|
||||
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const olderThanDays = req.query.olderThanDays
|
||||
? parseInt(req.query.olderThanDays as string)
|
||||
: undefined;
|
||||
|
||||
const where: any = {};
|
||||
if (olderThanDays && olderThanDays > 0) {
|
||||
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
||||
where.createdAt = { lt: cutoff };
|
||||
}
|
||||
|
||||
const result = await prisma.securityEvent.deleteMany({ where });
|
||||
|
||||
// Audit-Spur: Wer hat geleert
|
||||
const user = (req as any).user;
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'INFO',
|
||||
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
|
||||
userId: user?.userId || null,
|
||||
userEmail: user?.email || null,
|
||||
ipAddress: req.ip || 'unknown',
|
||||
endpoint: 'DELETE /api/monitoring/events',
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.count} Events gelöscht`,
|
||||
data: { deletedCount: result.count },
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error('clearEvents error:', error);
|
||||
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
|
||||
*/
|
||||
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await sendDigest({ force: true });
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
+27
-6
@@ -34,8 +34,11 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||
import birthdayRoutes from './routes/birthday.routes.js';
|
||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
import { authenticate } from './middleware/auth.js';
|
||||
@@ -101,12 +104,28 @@ app.use(express.json({ limit: '5mb' }));
|
||||
app.use(auditContextMiddleware);
|
||||
app.use(auditMiddleware);
|
||||
|
||||
// Statische Dateien für Uploads – NUR für authentifizierte User.
|
||||
// authenticate-Middleware unterstützt ?token=... Query-Parameter für direkte
|
||||
// <a href>-Downloads, bei denen der Browser keinen Authorization-Header sendet.
|
||||
// Ohne diesen Schutz könnte jeder per Datei-Name-Enumeration sensible PDFs
|
||||
// (Ausweise, Kündigungsbestätigungen, Bankkarten) abrufen – DSGVO-GAU.
|
||||
app.use('/api/uploads', authenticate as any, express.static(path.join(process.cwd(), 'uploads')));
|
||||
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
||||
// `/api/uploads/*` express.static).
|
||||
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
|
||||
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
|
||||
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
|
||||
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
|
||||
//
|
||||
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
|
||||
// Request über denselben Owner-Check (kein freier static-Handler mehr).
|
||||
|
||||
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
|
||||
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> – egal ob als
|
||||
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
|
||||
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
|
||||
app.get('/api/files/download', authenticate as any, downloadFile as any);
|
||||
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
|
||||
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
|
||||
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
||||
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
|
||||
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
|
||||
return (downloadFile as any)(req, res, next);
|
||||
});
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
@@ -141,6 +160,7 @@ app.use('/api/email-logs', emailLogRoutes);
|
||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||
app.use('/api/birthdays', birthdayRoutes);
|
||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
@@ -189,4 +209,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||
startBirthdayScheduler();
|
||||
startContractStatusScheduler();
|
||||
startSecurityMonitorScheduler();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
||||
|
||||
export async function authenticate(
|
||||
req: AuthRequest,
|
||||
@@ -81,7 +82,16 @@ export async function authenticate(
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
|
||||
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
|
||||
emitSecurityEvent({
|
||||
type: 'TOKEN_REJECTED',
|
||||
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
|
||||
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
|
||||
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
});
|
||||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
/**
|
||||
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
||||
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
||||
*
|
||||
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
|
||||
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
||||
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
||||
*/
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||||
return (req: any, _res: any) => {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'RATE_LIMIT_HIT',
|
||||
severity,
|
||||
message: `Rate-Limit überschritten: ${label}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userEmail: req.body?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { limiter: label },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
||||
@@ -19,6 +39,10 @@ export const loginRateLimiter = rateLimit({
|
||||
},
|
||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
||||
skipSuccessfulRequests: true,
|
||||
handler: (req, res, _next, options) => {
|
||||
onLimitReached('login', 'HIGH')(req, res);
|
||||
res.status(options.statusCode).json(options.message);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -34,4 +58,8 @@ export const passwordResetRateLimiter = rateLimit({
|
||||
success: false,
|
||||
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
||||
},
|
||||
handler: (req, res, _next, options) => {
|
||||
onLimitReached('password-reset', 'MEDIUM')(req, res);
|
||||
res.status(options.statusCode).json(options.message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = Router();
|
||||
router.post('/login', loginRateLimiter, authController.login);
|
||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||
router.get('/me', authenticate, authController.me);
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
||||
|
||||
// Passwort-Reset-Flow
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import * as monitoringController from '../controllers/monitoring.controller.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
|
||||
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
|
||||
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
|
||||
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
|
||||
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
|
||||
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
|
||||
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Pfad → Resource → Owner Mapping für `/api/files/download`.
|
||||
*
|
||||
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
|
||||
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
|
||||
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
|
||||
* canAccessContract entscheidet danach über Zugriff.
|
||||
*
|
||||
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
|
||||
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
|
||||
* automatisch ausgeschlossen – ein konstruierter Pfad findet keinen Record.
|
||||
*/
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
export type FileOwner =
|
||||
| { kind: 'customer'; customerId: number }
|
||||
| { kind: 'contract'; contractId: number }
|
||||
| { kind: 'admin' }
|
||||
| { kind: 'gdpr-admin' };
|
||||
|
||||
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
|
||||
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
|
||||
if (!uploadPath.startsWith('/uploads/')) return null;
|
||||
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
|
||||
|
||||
const parts = uploadPath.split('/');
|
||||
// ['', 'uploads', '<subDir>', '<filename...>']
|
||||
if (parts.length < 4) return null;
|
||||
const subDir = parts[2];
|
||||
|
||||
switch (subDir) {
|
||||
case 'bank-cards': {
|
||||
const r = await prisma.bankCard.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'documents': {
|
||||
const r = await prisma.identityDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'business-registrations': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { businessRegistrationPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'commercial-registers': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { commercialRegisterPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'privacy-policies': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { privacyPolicyPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'authorizations': {
|
||||
const r = await prisma.representativeAuthorization.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'contract-documents': {
|
||||
const r = await prisma.contractDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'invoices': {
|
||||
const r = await prisma.invoice.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'cancellation-letters':
|
||||
case 'cancellation-confirmations':
|
||||
case 'cancellation-letters-options':
|
||||
case 'cancellation-confirmations-options': {
|
||||
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
|
||||
'cancellation-letters': 'cancellationLetterPath',
|
||||
'cancellation-confirmations': 'cancellationConfirmationPath',
|
||||
'cancellation-letters-options': 'cancellationLetterOptionsPath',
|
||||
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
|
||||
};
|
||||
const field = fieldMap[subDir];
|
||||
const r = await prisma.contract.findFirst({
|
||||
where: { [field]: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'pdf-templates': {
|
||||
// Admin-only Resource: Vorlagen gehören keinem Customer.
|
||||
const r = await prisma.pdfTemplate.findFirst({
|
||||
where: { templatePath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'admin' } : null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export interface ImapCredentials {
|
||||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +32,12 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
||||
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
|
||||
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
|
||||
if (credentials.servername) {
|
||||
options.servername = credentials.servername;
|
||||
}
|
||||
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
options.minVersion = 'TLSv1';
|
||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Security-Alerting:
|
||||
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
|
||||
* Cron alle 60s gepollt) – z.B. Threshold-Überschreitungen.
|
||||
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
|
||||
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
|
||||
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
|
||||
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
|
||||
* Events wenn die Schwelle erreicht ist.
|
||||
*
|
||||
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
|
||||
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
|
||||
*/
|
||||
import cron from 'node-cron';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||
import * as appSettingService from './appSetting.service.js';
|
||||
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||
import type { SecurityEvent } from '@prisma/client';
|
||||
|
||||
interface AlertEmailParams {
|
||||
subject: string;
|
||||
events: SecurityEvent[];
|
||||
isDigest: boolean;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function severityIcon(s: string): string {
|
||||
switch (s) {
|
||||
case 'CRITICAL': return '🚨';
|
||||
case 'HIGH': return '⚠️';
|
||||
case 'MEDIUM': return '🟡';
|
||||
case 'LOW': return '🟢';
|
||||
default: return 'ℹ️';
|
||||
}
|
||||
}
|
||||
|
||||
function eventToHtmlRow(e: SecurityEvent): string {
|
||||
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const ip = e.ipAddress || '–';
|
||||
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–');
|
||||
const ep = e.endpoint || '–';
|
||||
return `<tr>
|
||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
|
||||
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
|
||||
<td style="padding:4px 8px">${e.type}</td>
|
||||
<td style="padding:4px 8px">${e.message}</td>
|
||||
<td style="padding:4px 8px">${who}</td>
|
||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
|
||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function buildHtmlEmail(params: AlertEmailParams): string {
|
||||
const rows = params.events.map(eventToHtmlRow).join('\n');
|
||||
const heading = params.isDigest
|
||||
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
|
||||
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
|
||||
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
|
||||
${heading}
|
||||
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
|
||||
<thead style="background:#f3f4f6">
|
||||
<tr>
|
||||
<th align="left" style="padding:6px 8px">Zeit</th>
|
||||
<th align="left" style="padding:6px 8px">Severity</th>
|
||||
<th align="left" style="padding:6px 8px">Typ</th>
|
||||
<th align="left" style="padding:6px 8px">Nachricht</th>
|
||||
<th align="left" style="padding:6px 8px">Wer</th>
|
||||
<th align="left" style="padding:6px 8px">IP</th>
|
||||
<th align="left" style="padding:6px 8px">Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
|
||||
Konfiguration: Einstellungen → Monitoring.</p>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
|
||||
*/
|
||||
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
|
||||
const sysEmail = await getSystemEmailCredentials();
|
||||
if (!sysEmail) {
|
||||
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
|
||||
}
|
||||
|
||||
const credentials: SmtpCredentials = {
|
||||
host: sysEmail.smtpServer,
|
||||
port: sysEmail.smtpPort,
|
||||
user: sysEmail.emailAddress,
|
||||
password: sysEmail.password,
|
||||
encryption: sysEmail.smtpEncryption,
|
||||
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
const result = await sendEmail(
|
||||
credentials,
|
||||
sysEmail.emailAddress,
|
||||
{
|
||||
to: toAddress,
|
||||
subject: params.subject,
|
||||
html: buildHtmlEmail(params),
|
||||
},
|
||||
{ context: 'security-alert', triggeredBy: 'monitor' },
|
||||
);
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
|
||||
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
|
||||
*
|
||||
* Regeln (alle pro IP):
|
||||
* - >= 10 LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht
|
||||
* - >= 5 ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht
|
||||
* - >= 3 SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing
|
||||
* - >= 3 TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
|
||||
*/
|
||||
export async function detectThresholds(): Promise<void> {
|
||||
const now = new Date();
|
||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
type Bucket = {
|
||||
windowStart: Date;
|
||||
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
|
||||
threshold: number;
|
||||
label: string;
|
||||
};
|
||||
const buckets: Bucket[] = [
|
||||
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
|
||||
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
|
||||
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
|
||||
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
|
||||
];
|
||||
|
||||
for (const b of buckets) {
|
||||
const grouped = await prisma.securityEvent.groupBy({
|
||||
by: ['ipAddress'],
|
||||
where: {
|
||||
type: b.type as any,
|
||||
createdAt: { gte: b.windowStart },
|
||||
},
|
||||
_count: true,
|
||||
});
|
||||
for (const g of grouped) {
|
||||
if ((g._count as number) < b.threshold) continue;
|
||||
// Prüfen ob wir für diese (IP+Type+Stunde) schon einen CRITICAL emittiert haben
|
||||
const hourBucket = new Date(now.getTime() - (now.getTime() % (60 * 60 * 1000)));
|
||||
const existing = await prisma.securityEvent.findFirst({
|
||||
where: {
|
||||
type: 'SUSPICIOUS',
|
||||
severity: 'CRITICAL',
|
||||
ipAddress: g.ipAddress,
|
||||
createdAt: { gte: hourBucket },
|
||||
},
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await emitSecurityEvent({
|
||||
type: 'SUSPICIOUS',
|
||||
severity: 'CRITICAL',
|
||||
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
|
||||
ipAddress: g.ipAddress,
|
||||
details: { rule: b.type, count: g._count, threshold: b.threshold },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
|
||||
* 1 Mail pro IP pro Stunde, damit nicht spammend).
|
||||
*/
|
||||
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
|
||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||
if (!alertEmail) return { sent: 0, skipped: 0 };
|
||||
|
||||
const pending = await prisma.securityEvent.findMany({
|
||||
where: { severity: 'CRITICAL', alerted: false },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
for (const ev of pending) {
|
||||
const result = await sendAlertEmail(alertEmail, {
|
||||
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
|
||||
events: [ev],
|
||||
isDigest: false,
|
||||
});
|
||||
if (result.success) {
|
||||
sent++;
|
||||
await prisma.securityEvent.update({
|
||||
where: { id: ev.id },
|
||||
data: { alerted: true, alertedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
skipped++;
|
||||
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
|
||||
}
|
||||
}
|
||||
return { sent, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
|
||||
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
|
||||
*/
|
||||
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
|
||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
|
||||
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
|
||||
|
||||
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
const events = await prisma.securityEvent.findMany({
|
||||
where: {
|
||||
severity: { in: ['HIGH', 'MEDIUM'] },
|
||||
alerted: false,
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
|
||||
}
|
||||
|
||||
const result = await sendAlertEmail(alertEmail, {
|
||||
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
|
||||
events,
|
||||
isDigest: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await prisma.securityEvent.updateMany({
|
||||
where: { id: { in: events.map((e) => e.id) } },
|
||||
data: { alerted: true, alertedAt: new Date() },
|
||||
});
|
||||
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||
return { sent: true, eventCount: events.length };
|
||||
}
|
||||
|
||||
return { sent: false, eventCount: events.length, reason: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron-Scheduler:
|
||||
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
|
||||
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
|
||||
*/
|
||||
export function startSecurityMonitorScheduler(): void {
|
||||
cron.schedule('* * * * *', async () => {
|
||||
try {
|
||||
await detectThresholds();
|
||||
await sendPendingCriticalAlerts();
|
||||
} catch (err) {
|
||||
console.error('[securityAlert] minute-cron failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
try {
|
||||
await sendDigest();
|
||||
} catch (err) {
|
||||
console.error('[securityAlert] hourly-digest failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
|
||||
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
|
||||
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
|
||||
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
|
||||
*
|
||||
* Hooks für die wichtigsten Klassen:
|
||||
* - LOGIN_FAILED → Login mit falschem Passwort
|
||||
* - LOGIN_SUCCESS → erfolgreicher Login (informativ)
|
||||
* - RATE_LIMIT_HIT → express-rate-limit hat zugeschlagen
|
||||
* - ACCESS_DENIED → 403 von canAccess* (versuchter IDOR)
|
||||
* - SSRF_BLOCKED → ssrfGuard hat geblockt
|
||||
* - PASSWORD_RESET_REQUEST → Reset angefordert
|
||||
* - PASSWORD_RESET_CONFIRM → Reset abgeschlossen
|
||||
* - LOGOUT → expliziter Logout
|
||||
* - TOKEN_REJECTED → JWT verify-Failure
|
||||
* - PERMISSION_CHANGED → Rolle/Permission-Update
|
||||
*
|
||||
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
|
||||
* konfiguriert), sonst Sammlung im stündlichen Digest.
|
||||
*/
|
||||
import prisma from '../lib/prisma.js';
|
||||
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||
|
||||
export interface SecurityEventInput {
|
||||
type: SecurityEventType;
|
||||
severity: SecuritySeverity;
|
||||
message: string;
|
||||
ipAddress?: string | null;
|
||||
userId?: number | null;
|
||||
customerId?: number | null;
|
||||
userEmail?: string | null;
|
||||
endpoint?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
|
||||
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
|
||||
*/
|
||||
export async function emit(event: SecurityEventInput): Promise<void> {
|
||||
try {
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: event.type,
|
||||
severity: event.severity,
|
||||
message: event.message,
|
||||
ipAddress: event.ipAddress || null,
|
||||
userId: event.userId || null,
|
||||
customerId: event.customerId || null,
|
||||
userEmail: event.userEmail || null,
|
||||
endpoint: event.endpoint || null,
|
||||
details: event.details ? (event.details as any) : undefined,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[securityMonitor] emit failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
|
||||
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
|
||||
* Requests (Login-Versuch etc.).
|
||||
*/
|
||||
export function contextFromRequest(req: any): {
|
||||
ipAddress: string;
|
||||
userId?: number;
|
||||
customerId?: number;
|
||||
userEmail?: string;
|
||||
endpoint: string;
|
||||
} {
|
||||
const user = req?.user;
|
||||
return {
|
||||
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
|
||||
userId: user?.userId,
|
||||
customerId: user?.customerId,
|
||||
userEmail: user?.email,
|
||||
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export interface SmtpCredentials {
|
||||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
// Anhang-Interface
|
||||
@@ -94,7 +98,7 @@ export async function sendEmail(
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
ignoreTLS?: boolean;
|
||||
requireTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
@@ -116,6 +120,11 @@ export async function sendEmail(
|
||||
// TLS-Optionen nur wenn nicht NONE
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
|
||||
// Hostname für SNI/Cert-Validation explizit setzen.
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||
transportOptions.tls.minVersion = 'TLSv1';
|
||||
@@ -303,7 +312,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
ignoreTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
@@ -321,6 +330,9 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
} else {
|
||||
transportOptions.ignoreTLS = true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,26 @@ import { Response } from 'express';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
/**
|
||||
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
|
||||
* Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection.
|
||||
*/
|
||||
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'ACCESS_DENIED',
|
||||
severity: 'MEDIUM',
|
||||
message: `Zugriff verweigert: ${label} #${targetId}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: ctx.userId,
|
||||
customerId: ctx.customerId,
|
||||
userEmail: ctx.userEmail,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { resource: label, targetId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
||||
@@ -54,6 +74,7 @@ export async function canAccessContract(
|
||||
// Fremde Verträge nur mit aktiver Vollmacht
|
||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||
if (!representedIds.includes(contract.customerId)) {
|
||||
emitAccessDenied(req, 'Contract', contractId);
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
||||
return false;
|
||||
}
|
||||
@@ -63,6 +84,7 @@ export async function canAccessContract(
|
||||
req.user.customerId,
|
||||
);
|
||||
if (!hasAuth) {
|
||||
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
|
||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||
return false;
|
||||
}
|
||||
@@ -93,12 +115,14 @@ export async function canAccessCustomer(
|
||||
|
||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||
if (!representedIds.includes(customerId)) {
|
||||
emitAccessDenied(req, 'Customer', customerId);
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
||||
if (!hasAuth) {
|
||||
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
|
||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
|
||||
* Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
|
||||
*
|
||||
* Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
|
||||
* 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
|
||||
* Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
|
||||
* blockieren wir nur:
|
||||
* - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
|
||||
* - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
|
||||
* - 0.0.0.0/8 (ungültiger Source/Routing-Range)
|
||||
* - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
|
||||
*
|
||||
* Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
|
||||
* Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es
|
||||
* legitimes Caching/CDN-Verhalten brechen kann.
|
||||
*/
|
||||
|
||||
const BLOCKED_PATTERNS: RegExp[] = [
|
||||
/^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA)
|
||||
/^0\./, // 0.0.0.0/8 reserved
|
||||
/^22[4-9]\./, // 224-229 Multicast
|
||||
/^23[0-9]\./, // 230-239 Multicast
|
||||
/^24[0-9]\./, // 240-249 reserved
|
||||
/^25[0-5]\./, // 250-255 reserved
|
||||
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
||||
/^fe80:/i, // IPv6 Link-Local
|
||||
/^ff/i, // IPv6 Multicast
|
||||
];
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'metadata.google.internal',
|
||||
'metadata.goog',
|
||||
'metadata',
|
||||
'169.254.169.254',
|
||||
]);
|
||||
|
||||
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||
if (!host) return false;
|
||||
const h = host.trim().toLowerCase();
|
||||
if (!h) return false;
|
||||
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
||||
for (const pattern of BLOCKED_PATTERNS) {
|
||||
if (pattern.test(h)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
||||
* Caller sollte den Fehler in 400er Response umsetzen.
|
||||
*/
|
||||
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
|
||||
if (isBlockedSsrfHost(host)) {
|
||||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
||||
}
|
||||
}
|
||||
|
||||
import { promises as dns } from 'dns';
|
||||
import net from 'net';
|
||||
|
||||
/**
|
||||
* DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
|
||||
* gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
|
||||
*
|
||||
* Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
|
||||
* als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
|
||||
* Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
|
||||
* ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
|
||||
*
|
||||
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
|
||||
*/
|
||||
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
|
||||
if (!host || !host.trim()) {
|
||||
throw new Error(`${label} fehlt`);
|
||||
}
|
||||
const trimmed = host.trim();
|
||||
|
||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||
if (net.isIP(trimmed)) {
|
||||
assertAllowedHost(trimmed, label);
|
||||
return { ip: trimmed, servername: trimmed };
|
||||
}
|
||||
|
||||
// Hostname → resolve to IPv4 + IPv6
|
||||
let ips: string[] = [];
|
||||
try {
|
||||
const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
|
||||
const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
|
||||
ips = [...v4, ...v6];
|
||||
} catch {
|
||||
throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
|
||||
}
|
||||
|
||||
if (ips.length === 0) {
|
||||
throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
|
||||
}
|
||||
|
||||
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
||||
for (const ip of ips) {
|
||||
if (isBlockedSsrfHost(ip)) {
|
||||
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ip: ips[0], servername: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
# 🛡️ Security-Hardening – die ganze Geschichte
|
||||
|
||||
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
|
||||
Code-Review und öffentlichem Deployment durchlaufen hat.
|
||||
|
||||
Format pro Runde: **Was war kaputt** → **Wie es gefixt wurde** → wo möglich
|
||||
**Live-Test-Resultate**.
|
||||
|
||||
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
|
||||
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Live-verifizierte Tests im Überblick
|
||||
|
||||
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
|
||||
durchgespielt – statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
|
||||
brauchten Live-Tests.
|
||||
|
||||
### Runde 4 – IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
### Runde 5 – DSGVO-GAU + Timing-Side-Channel
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
|
||||
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
|
||||
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
|
||||
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
|
||||
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
|
||||
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
|
||||
|
||||
### Runde 6 – Customer-Liste-Leak + XFF-Bypass
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
|
||||
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
|
||||
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
|
||||
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
|
||||
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
|
||||
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
|
||||
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
|
||||
|
||||
### Runde 7 – SSRF + Logout
|
||||
|
||||
| Test | Vorher | Nachher |
|
||||
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
|
||||
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
|
||||
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
|
||||
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
|
||||
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
|
||||
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
|
||||
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
|
||||
|
||||
### Runde 8 – DNS-Rebinding + Per-File-Ownership
|
||||
|
||||
| Test | Resultat |
|
||||
| ----------------------------------------------------- | --------------------------------------------- |
|
||||
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
|
||||
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
|
||||
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
|
||||
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
|
||||
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
|
||||
| Ohne Token | ✅ HTTP 401 |
|
||||
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
|
||||
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
|
||||
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
|
||||
|
||||
### Runde 9 – Vorher überprüft, Dependency-Audit, Audit-Chain
|
||||
|
||||
| Test | Resultat |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `From`-Address-Header-Injection (CRLF in fromAddress) | ✅ bereits in Stage 3 abgefangen (`containsCRLF`) |
|
||||
| `npm audit` (initial) | 9 Vulns (4× high) |
|
||||
| `npm audit fix` | ✅ 8 transitive Vulns gefixt |
|
||||
| nodemailer breaking-update auf 8.x | 📋 als v1.1-Item dokumentiert |
|
||||
| Audit-Log Hash-Chain vor `rehashAll` | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) |
|
||||
| Audit-Log Hash-Chain nach `rehashAll` | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) |
|
||||
| Authenticated Rate-Limit (50 parallele Requests) | 🟡 keiner – DoS-Schutz vom Reverse-Proxy übernehmen |
|
||||
| Frontend `localStorage` Token-Stealing-Vektor | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau |
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Runde-für-Runde
|
||||
|
||||
### Runde 1 – Erste kritische Findings (statisches Review)
|
||||
|
||||
- CORS komplett offen → `CORS_ORIGINS` explizit
|
||||
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
|
||||
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
|
||||
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
|
||||
- XSS via Email-Body → DOMPurify mit strikter Config
|
||||
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
|
||||
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
|
||||
- Body-Size-Limit 5 MB
|
||||
|
||||
### Runde 2 – Deep-Dive (parallele Audit-Agents)
|
||||
|
||||
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
|
||||
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
|
||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
|
||||
|
||||
### Runde 3 – Tiefer Dive (8 weitere Hardenings)
|
||||
|
||||
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
|
||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
|
||||
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
|
||||
- IDOR PDF-Template-Generator → `canAccessContract`
|
||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
|
||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||
|
||||
### Runde 4 – Live-Tests gegen Dev-Server (Tabelle oben)
|
||||
|
||||
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
|
||||
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
|
||||
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
|
||||
|
||||
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
|
||||
|
||||
### Runde 5 – Hack-Das-Ding-Audit
|
||||
|
||||
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
|
||||
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
|
||||
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
|
||||
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
|
||||
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
|
||||
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
|
||||
(`canAccessContract`).
|
||||
|
||||
### Runde 6 – Tiefer Live-Pentest (Tabelle oben)
|
||||
|
||||
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
|
||||
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For** → `trust proxy = 'loopback'`
|
||||
+ `LISTEN_ADDR=127.0.0.1` in Production
|
||||
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
|
||||
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
|
||||
beide Fehlfälle uniform 403.
|
||||
- Prisma-Error-Leaks generisch ersetzt.
|
||||
|
||||
### Runde 7 – Letzter Schliff
|
||||
|
||||
- **SSRF-Schutz** in `test-connection` und `test-mail-access` →
|
||||
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
|
||||
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
|
||||
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
|
||||
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
|
||||
/ `portalTokenInvalidatedAt` auf jetzt.
|
||||
|
||||
### Runde 8 – Loose Ends
|
||||
|
||||
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
|
||||
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
|
||||
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI – ein
|
||||
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
|
||||
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
|
||||
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
|
||||
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
|
||||
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
|
||||
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
|
||||
|
||||
### Runde 10 – Security-Monitoring + Alerting
|
||||
|
||||
Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
|
||||
zumindest **gesehen** werden. Ergänzt:
|
||||
|
||||
- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
|
||||
+ Indexen für schnelles Filter+Threshold-Detection.
|
||||
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
|
||||
Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
|
||||
(`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
|
||||
(Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
|
||||
- **Service `securityAlert.service.ts`** mit:
|
||||
- **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
|
||||
gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
|
||||
HIGH/5min → erzeugt synthetische CRITICAL-Events.
|
||||
- **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
|
||||
(debounced, max. 1× pro Stunde + IP).
|
||||
- **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
|
||||
in einer Mail (wenn `monitoringDigestEnabled = true`).
|
||||
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
|
||||
Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
|
||||
Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
|
||||
Auto-Refresh alle 30s.
|
||||
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
|
||||
– alle hinter `settings:read` / `settings:update`.
|
||||
|
||||
Live-verifiziert (1. Mai 2026):
|
||||
|
||||
| Test | Resultat |
|
||||
| --------------------------------------------------- | --------------------------------------------------- |
|
||||
| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt |
|
||||
| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event |
|
||||
| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events |
|
||||
| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event |
|
||||
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
|
||||
| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt |
|
||||
| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt |
|
||||
| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt |
|
||||
|
||||
### Runde 9 – Diminishing-Returns-Runde
|
||||
|
||||
Nichts Kritisches mehr gefunden. Liefert noch:
|
||||
|
||||
- **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability
|
||||
(lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene
|
||||
nodemailer-Vuln braucht Major-Update (v6 → v8) – v1.1-Item.
|
||||
- **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch
|
||||
frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll`
|
||||
repariert; integrity-check verifiziert die Chain wieder. Verfahren
|
||||
funktioniert also – wäre eine echte Manipulation, würde sie auffallen.
|
||||
- **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die
|
||||
zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅
|
||||
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
|
||||
atomar gelöscht – zweiter Versuch findet keinen Token. ✅
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
|
||||
|
||||
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
|
||||
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
|
||||
- Path-Traversal in Backup-Name (Regex blockiert)
|
||||
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
|
||||
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
||||
- Self-grant Vollmacht via `customers/X/representatives` → 403
|
||||
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
|
||||
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
|
||||
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
|
||||
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
|
||||
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
|
||||
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
|
||||
|
||||
---
|
||||
|
||||
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
|
||||
|
||||
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
|
||||
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
|
||||
`<a href>`-Downloads ohne JS – v1.2-Item.
|
||||
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
|
||||
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
|
||||
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
|
||||
niedrig-Prio.
|
||||
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
|
||||
bei Validation-Fehler) – Defense-in-Depth-Kandidat, aber nur Admin-
|
||||
erreichbar.
|
||||
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
|
||||
erreichbar, niedrig-Prio).
|
||||
- **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur
|
||||
Login + Password-Reset rate-limited. Eingeloggte User können theoretisch
|
||||
hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy
|
||||
(Nginx/Plesk haben eigene Limits) – nicht im App-Layer. Wenn nötig,
|
||||
später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP).
|
||||
- **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern,
|
||||
XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via
|
||||
Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System.
|
||||
- **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen
|
||||
(SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese
|
||||
Felder nicht aus User-Input – Risiko gering, Update breaking.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Production-Deployment-Checkliste
|
||||
|
||||
Vor dem öffentlichen Schalten muss in der Production-`.env`:
|
||||
|
||||
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
|
||||
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
|
||||
- `NODE_ENV=production`
|
||||
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
|
||||
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
|
||||
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
|
||||
hart auf die echte Client-IP gesetzt wird (nicht angefügt) – sonst
|
||||
Rate-Limit-Bypass möglich.
|
||||
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
|
||||
durchklicken.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Lazy Password-Hash-Upgrade
|
||||
|
||||
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
|
||||
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
|
||||
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an –
|
||||
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
|
||||
Wochen nach Deployment.
|
||||
|
||||
---
|
||||
|
||||
## 🗨️ Lehre aus der Session
|
||||
|
||||
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
|
||||
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
|
||||
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
|
||||
User-zugänglich sei – war's nicht), übersehen aber, dass ein
|
||||
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
|
||||
|
||||
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
|
||||
Vor jedem Launch mit echten Tokens probieren.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Commit-Historie
|
||||
|
||||
| Commit | Runde | Hauptthema |
|
||||
| --------- | ------- | -------------------------------------------------------------- |
|
||||
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
|
||||
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
|
||||
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
|
||||
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
|
||||
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
|
||||
| `12b9abe` | 7 | SSRF-Schutz + Logout |
|
||||
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
|
||||
| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
|
||||
| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) |
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Wann ist „dicht" dicht?
|
||||
|
||||
100 % gibt es nicht. Erreicht ist:
|
||||
|
||||
1. **Mehrere Audit-Methoden durch** – statisches Code-Review, parallele
|
||||
Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓
|
||||
2. **OWASP-Top-10 explizit getestet** – Auth, Access-Control, Injection,
|
||||
Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓
|
||||
3. **Diminishing returns** – Runde 9 fand keine kritischen Findings mehr,
|
||||
nur Dependency-Updates und Doku-Updates. ✓
|
||||
4. **Production-Deployment-Checkliste klar.** ✓
|
||||
5. **Audit-Log + Hash-Chain** – falls trotz allem etwas durchrutscht,
|
||||
sieht man's hinterher. ✓
|
||||
|
||||
Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`),
|
||||
neue Angriffsklassen, Server-Misconfig in Production, Social Engineering.
|
||||
Dafür gibt's keine Code-Lösung – nur Monitoring und Rotation der Secrets.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Security-Review vor 1.0.0
|
||||
|
||||
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
|
||||
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
|
||||
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
|
||||
|
||||
> **Version 2** – dieser Review wurde in 2 Runden durchgeführt.
|
||||
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
|
||||
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents – fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
|
||||
|
||||
+22
-121
@@ -5,7 +5,7 @@
|
||||
## 🔜 Offen
|
||||
|
||||
### Manuelle Tests (vor Release durchklicken)
|
||||
Checklisten für Security + Email-Log-System stehen in **[docs/TESTING.md](../docs/TESTING.md)**.
|
||||
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
|
||||
Einmal komplett durchlaufen vor v1.0.0-Release.
|
||||
|
||||
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
||||
@@ -116,126 +116,27 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
||||
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
||||
|
||||
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)**
|
||||
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
||||
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
||||
- CORS offen → `CORS_ORIGINS` explizit
|
||||
- Helmet + Security-Headers
|
||||
- JWT-Fallback-Secret entfernt (Fail-Fast beim Start)
|
||||
- IDOR bei 7 Contract-Endpoints
|
||||
- XSS via Email-Body (DOMPurify)
|
||||
- Customer-API Data Exposure (Passwort-Hashes)
|
||||
- Portal-JWT-Invalidation nach Passwort-Reset
|
||||
- Body-Size-Limit 5 MB
|
||||
- **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:**
|
||||
- Zip-Slip im Backup-Upload (Arbitrary File Write!)
|
||||
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
|
||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
|
||||
- **Runde 3 – Tiefer Dive (8 weitere Hardenings):**
|
||||
- JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt
|
||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam)
|
||||
- IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId
|
||||
- IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract`
|
||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
|
||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||
- **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
|
||||
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
|
||||
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
|
||||
Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List-
|
||||
Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene
|
||||
Kunden (Filter im Controller).
|
||||
- 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche
|
||||
mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1`
|
||||
hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` –
|
||||
XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt
|
||||
(= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production-
|
||||
Default, damit das Backend nicht direkt von außen ansprechbar ist.
|
||||
- **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**:
|
||||
- Portal-User konnte sich selbst Vollmacht erteilen (1→1) und
|
||||
Datensätze für beliebige `representativeId`s anlegen (auch nicht-
|
||||
existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak).
|
||||
- 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range.
|
||||
- Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`-
|
||||
Verhältnis in einem Query, beide Fehlfälle identisch 403.
|
||||
- **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine
|
||||
Prisma-Stacks mehr im Response.
|
||||
- Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte),
|
||||
Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth
|
||||
auf `/api/customers/:id` 200/403 (kein 404-Leak).
|
||||
|
||||
**Geprüft + sauber (Runde 6):**
|
||||
- Prototype Pollution beim Login → kein Effekt
|
||||
- HTTP-Method-Override via Header → ignoriert
|
||||
- Path-Traversal in Backup-Name → durch Regex blockiert
|
||||
- Developer-Routes existieren nicht (404)
|
||||
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
||||
- Self-grant Vollmacht via `customers/X/representatives` → 403 (perm)
|
||||
- `/api/customers/:id` GET: 200 für eigene, 403 sonst (kein 404-Leak)
|
||||
|
||||
**Offen für v1.1:**
|
||||
- `/api/contracts/:id` GET liefert 404 für nicht-existente IDs (Existence-
|
||||
Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten
|
||||
korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer.
|
||||
- Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei
|
||||
Validation-Fehler) – Defense-in-Depth-Kandidat.
|
||||
|
||||
- **Runde 5 – Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):**
|
||||
- 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) – jetzt hinter
|
||||
`authenticate`. Direkte <a href>-Links nutzen `?token=...` Query-Parameter,
|
||||
unterstützt von auth-Middleware. Frontend-Helper `fileUrl(path)` hängt
|
||||
Token automatisch an, 24 URLs migriert (CustomerDetail, ContractDetail,
|
||||
InvoicesSection, PdfTemplates, GDPRDashboard).
|
||||
- **Login-Timing-Side-Channel**: Bei ungültigem User fehlte `bcrypt.compare`
|
||||
→ 110ms vs 10ms, User-Enumeration trivial. Jetzt Dummy-bcrypt-compare
|
||||
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
|
||||
Live-verifiziert: 422ms vs 425ms – Timing-Angriff dicht.
|
||||
- **XSS via Privacy Policy / Imprint**: 4 Frontend-Seiten renderten
|
||||
Backend-HTML ohne DOMPurify (`PortalPrivacy`, `ConsentPage`,
|
||||
`PortalWebsitePrivacy`, `PortalImprint`). Admin-eingegebene
|
||||
`<script>`-Tags wären bei jedem Portal-Kunden-Besuch ausgeführt worden.
|
||||
Jetzt mit strikter Sanitize-Config (FORBID_TAGS/ATTR).
|
||||
- **IDOR-Härtung Upload/Delete/SaveAttachment**: `canAccessContract` jetzt
|
||||
in `uploadContractDocument`, `deleteContractDocument`, im generischen
|
||||
`handleContractDocumentUpload` (Kündigungsschreiben + -bestätigungen)
|
||||
und in `saveAttachmentAsContractDocument`. Defense-in-Depth, blockt
|
||||
auch bei künftigen Staff-Scoping-Rollen.
|
||||
- Global Error-Handler: `err.status` wird respektiert (413/400 statt 500).
|
||||
|
||||
**Offen für v1.1**:
|
||||
- Per-File-Ownership-Check bei `/api/uploads/*` (aktuell reicht
|
||||
Authentifizierung, kein Datei-spezifischer Owner-Check). Implementierung
|
||||
bräuchte dedizierten `GET /api/files/download?path=...`-Endpoint mit
|
||||
DB-Lookup, welche Ressource zur Datei gehört.
|
||||
- TipTap-Link-Tool: `javascript:`-Protokoll blockieren (Admin-only erreichbar,
|
||||
niedrig-Prio).
|
||||
|
||||
- **Runde 4 – Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:**
|
||||
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
|
||||
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt
|
||||
- Portal-Kunde konnte live per `GET /api/customers/<fremde-id>` kompletten Fremdkunden-Datensatz auslesen → jetzt 403
|
||||
- Error-Handler: `err.status` wird jetzt respektiert (413/400 statt pauschalem 500)
|
||||
|
||||
**Live-verifiziert als Portal-Kunde gegen fremden Test-Kunden #4:**
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
- Deployment-Checkliste komplett
|
||||
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
|
||||
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
||||
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
|
||||
- Erste 2 Runden zusätzlich ausführlich in
|
||||
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
|
||||
- Highlights:
|
||||
- Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
|
||||
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
|
||||
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
|
||||
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
|
||||
Privacy-Policy-XSS
|
||||
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
|
||||
Self-Grant + Existence-Disclosure
|
||||
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
|
||||
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
||||
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
|
||||
neuen Critical-Findings → diminishing returns erreicht
|
||||
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
|
||||
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
|
||||
Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
|
||||
- Deployment-Checkliste komplett (in HARDENING.md)
|
||||
|
||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencrm-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -30,6 +30,7 @@ import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||
import AuditLogs from './pages/settings/AuditLogs';
|
||||
import EmailLogPage from './pages/settings/EmailLogs';
|
||||
import Monitoring from './pages/settings/Monitoring';
|
||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||
import UserList from './pages/users/UserList';
|
||||
import Settings from './pages/Settings';
|
||||
@@ -202,6 +203,7 @@ function App() {
|
||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||
<Route path="settings/monitoring" element={<Monitoring />} />
|
||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||
@@ -238,6 +238,27 @@ export default function Settings() {
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('settings:read') && (
|
||||
<Link
|
||||
to="/settings/monitoring"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
|
||||
<ShieldAlert className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
|
||||
Sicherheits-Monitoring
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('gdpr:admin') && (
|
||||
<Link
|
||||
to="/settings/gdpr"
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Liefert die anzuzeigenden Seitenzahlen für die Pagination.
|
||||
* Bis zu 10 Seitenzahlen, current möglichst mittig.
|
||||
*/
|
||||
function paginationWindow(current: number, total: number, size = 10): number[] {
|
||||
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
let start = Math.max(1, current - Math.floor(size / 2));
|
||||
let end = start + size - 1;
|
||||
if (end > total) {
|
||||
end = total;
|
||||
start = end - size + 1;
|
||||
}
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||||
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
|
||||
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
|
||||
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
|
||||
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
|
||||
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
|
||||
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
|
||||
{ value: 'LOGOUT', label: 'Logout' },
|
||||
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||||
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||||
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
|
||||
{ value: '', label: 'Alle Stufen' },
|
||||
{ value: 'INFO', label: 'Info' },
|
||||
{ value: 'LOW', label: 'Niedrig' },
|
||||
{ value: 'MEDIUM', label: 'Mittel' },
|
||||
{ value: 'HIGH', label: 'Hoch' },
|
||||
{ value: 'CRITICAL', label: 'Kritisch' },
|
||||
];
|
||||
|
||||
function severityClass(s: SecuritySeverity): string {
|
||||
switch (s) {
|
||||
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
|
||||
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
|
||||
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
|
||||
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
|
||||
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function severityIcon(s: SecuritySeverity): string {
|
||||
switch (s) {
|
||||
case 'CRITICAL': return '🚨';
|
||||
case 'HIGH': return '⚠️';
|
||||
case 'MEDIUM': return '🟡';
|
||||
case 'LOW': return '🟢';
|
||||
default: return 'ℹ️';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Monitoring() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [filters, setFilters] = useState({
|
||||
type: '' as SecurityEventType | '',
|
||||
severity: '' as SecuritySeverity | '',
|
||||
search: '',
|
||||
ip: '',
|
||||
});
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
|
||||
|
||||
const [alertEmail, setAlertEmail] = useState('');
|
||||
const [digestEnabled, setDigestEnabled] = useState(false);
|
||||
|
||||
// Settings laden
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['monitoring-settings'],
|
||||
queryFn: monitoringApi.getSettings,
|
||||
});
|
||||
|
||||
// States nach Laden synchronisieren (nur initial)
|
||||
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
|
||||
setAlertEmail(settingsData.data.alertEmail);
|
||||
setDigestEnabled(settingsData.data.digestEnabled);
|
||||
}
|
||||
|
||||
// Events laden
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['monitoring-events', page, pageSize, filters],
|
||||
queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
|
||||
refetchInterval: 30_000, // alle 30s neu laden
|
||||
});
|
||||
|
||||
const clearEvents = useMutation({
|
||||
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message || 'Events gelöscht');
|
||||
setShowClearConfirm(false);
|
||||
setClearOlderThanDays('');
|
||||
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
|
||||
onSuccess: () => {
|
||||
toast.success('Einstellungen gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const testAlert = useMutation({
|
||||
mutationFn: () => monitoringApi.testAlert(),
|
||||
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
|
||||
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const runDigest = useMutation({
|
||||
mutationFn: () => monitoringApi.runDigest(),
|
||||
onSuccess: (res) => {
|
||||
const r = res.data;
|
||||
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
|
||||
else toast(r?.reason || 'Kein Digest versendet', { icon: 'ℹ️' });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const events = eventsData?.data || [];
|
||||
const stats = eventsData?.stats;
|
||||
const pagination = eventsData?.pagination;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" /> Alert-Empfänger
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<Input
|
||||
label="E-Mail-Adresse für Alerts"
|
||||
type="email"
|
||||
value={alertEmail}
|
||||
onChange={(e) => setAlertEmail(e.target.value)}
|
||||
placeholder="security@deine-firma.de"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
<label className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={digestEnabled}
|
||||
onChange={(e) => setDigestEnabled(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
|
||||
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
|
||||
</Button>
|
||||
{settingsData?.data?.lastDigestAt && (
|
||||
<span className="text-xs text-gray-500 self-center">
|
||||
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
|
||||
E-Mail versendet.<br />
|
||||
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats-Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
|
||||
<Card key={sev}>
|
||||
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
|
||||
{severityIcon(sev)} {sev}
|
||||
</div>
|
||||
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="mb-4">
|
||||
<div className="grid sm:grid-cols-4 gap-3">
|
||||
<Select
|
||||
label="Typ"
|
||||
value={filters.type}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Severity"
|
||||
value={filters.severity}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
|
||||
options={SEVERITY_OPTIONS}
|
||||
/>
|
||||
<Input
|
||||
label="Suche (Nachricht/User/Endpoint)"
|
||||
value={filters.search}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
|
||||
placeholder="z.B. admin@admin.com"
|
||||
/>
|
||||
<Input
|
||||
label="IP-Adresse"
|
||||
value={filters.ip}
|
||||
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
|
||||
placeholder="z.B. 1.2.3.4"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabelle */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Events</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Pro Seite:</label>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{eventsLoading ? (
|
||||
<div className="text-gray-500 py-4">Lade…</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
|
||||
<th className="px-3 py-2">Severity</th>
|
||||
<th className="px-3 py-2">Typ</th>
|
||||
<th className="px-3 py-2">Nachricht</th>
|
||||
<th className="px-3 py-2">Wer</th>
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Endpoint</th>
|
||||
<th className="px-3 py-2">Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
|
||||
{severityIcon(e.severity)} {e.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
|
||||
<td className="px-3 py-2">{e.message}</td>
|
||||
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–')}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || '–'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || '–'}</td>
|
||||
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mt-4 text-sm">
|
||||
<span className="text-gray-600">
|
||||
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page <= 1}
|
||||
title="Erste Seite"
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
title="Vorherige Seite"
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{paginationWindow(page, pagination.totalPages, 10).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`min-w-[32px] px-2 py-1 rounded border text-sm ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
disabled={page >= pagination.totalPages}
|
||||
title="Nächste Seite"
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(pagination.totalPages)}
|
||||
disabled={page >= pagination.totalPages}
|
||||
title="Letzte Seite"
|
||||
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Clear-Confirm-Modal */}
|
||||
<Modal
|
||||
isOpen={showClearConfirm}
|
||||
onClose={() => setShowClearConfirm(false)}
|
||||
title="Security-Log leeren"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
|
||||
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nur Events älter als (Tage)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={clearOlderThanDays}
|
||||
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||
placeholder="leer = alle löschen"
|
||||
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Beispiel: 30 = nur Events älter als 30 Tage löschen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
|
||||
disabled={clearEvents.isPending}
|
||||
className="!bg-red-600 hover:!bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1426,6 +1426,76 @@ export interface EmailLog {
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
// ==================== MONITORING ====================
|
||||
|
||||
export type SecurityEventType =
|
||||
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
||||
|
||||
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
|
||||
export interface SecurityEvent {
|
||||
id: number;
|
||||
type: SecurityEventType;
|
||||
severity: SecuritySeverity;
|
||||
message: string;
|
||||
ipAddress: string | null;
|
||||
userId: number | null;
|
||||
customerId: number | null;
|
||||
userEmail: string | null;
|
||||
endpoint: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
alerted: boolean;
|
||||
alertedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MonitoringSettings {
|
||||
alertEmail: string;
|
||||
digestEnabled: boolean;
|
||||
lastDigestAt: string | null;
|
||||
}
|
||||
|
||||
export const monitoringApi = {
|
||||
getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
if (params?.type) q.set('type', params.type);
|
||||
if (params?.severity) q.set('severity', params.severity);
|
||||
if (params?.search) q.set('search', params.search);
|
||||
if (params?.ip) q.set('ip', params.ip);
|
||||
if (params?.since) q.set('since', params.since);
|
||||
const res = await api.get<ApiResponse<SecurityEvent[]> & {
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||
stats: { byType: Record<string, number>; bySeverity: Record<string, number> };
|
||||
}>(`/monitoring/events?${q}`);
|
||||
return res.data;
|
||||
},
|
||||
getSettings: async () => {
|
||||
const res = await api.get<ApiResponse<MonitoringSettings>>('/monitoring/settings');
|
||||
return res.data;
|
||||
},
|
||||
updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => {
|
||||
const res = await api.put<ApiResponse<void>>('/monitoring/settings', data);
|
||||
return res.data;
|
||||
},
|
||||
testAlert: async () => {
|
||||
const res = await api.post<ApiResponse<void>>('/monitoring/test-alert');
|
||||
return res.data;
|
||||
},
|
||||
runDigest: async () => {
|
||||
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
|
||||
return res.data;
|
||||
},
|
||||
clearEvents: async (olderThanDays?: number) => {
|
||||
const q = olderThanDays ? `?olderThanDays=${olderThanDays}` : '';
|
||||
const res = await api.delete<ApiResponse<{ deletedCount: number }>>(`/monitoring/events${q}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const emailLogApi = {
|
||||
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
/**
|
||||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||||
*
|
||||
* `/api/uploads/*` läuft hinter authenticate-Middleware, aber <a href> und
|
||||
* window.open senden keinen Authorization-Header. Darum hängen wir das JWT
|
||||
* als Query-Parameter an. Die authenticate-Middleware akzeptiert
|
||||
* `?token=<jwt>` neben dem Header.
|
||||
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||||
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||||
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||||
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||||
*
|
||||
* Trade-off: Tokens in URLs landen potenziell in Logs/Referrer. Für eine
|
||||
* saubere Lösung (kurzlebige Download-Tokens) wäre ein separater Endpoint
|
||||
* nötig – TODO für v1.1.
|
||||
* <a href> und window.open senden keinen Authorization-Header, daher
|
||||
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||||
*
|
||||
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
|
||||
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
|
||||
* wäre v1.1-Item.
|
||||
*/
|
||||
export function fileUrl(path: string | null | undefined): string {
|
||||
if (!path) return '';
|
||||
const token = localStorage.getItem('token');
|
||||
const base = `/api${path.startsWith('/') ? path : '/' + path}`;
|
||||
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||||
if (!token) return base;
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}${separator}token=${encodeURIComponent(token)}`;
|
||||
return `${base}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user