Deploy auf cj-ki: Web-Container nur noch 127.0.0.1:3090, Caddy über das Docker-Netz, COOKIE_SECURE=true geprüft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
13 KiB
Markdown
115 lines
13 KiB
Markdown
# Threat-Model (light): leistungsbilanz-ts
|
|
|
|
Stand: 17.10.2026 (Branch `security-baseline`). Risiken mit Maßnahme und prüfbarem Beleg.
|
|
Prioritäten wie ASVS-light: **K** blockiert den Release, **H**/**M** werden Auflage oder Ausnahme
|
|
(`docs/RELEASE-AUSNAHMEN.md`). Ein Risiko ohne Beleg ist offen.
|
|
|
|
## System
|
|
|
|
- Browser → Caddy (TLS, `leistungsbilanz.jansseneconsult.de`, `leistungsbilanz.electric-consult.de`,
|
|
ohne Authelia) → Web (Next.js, Container-Port 3001, Host `0.0.0.0:3090`) → Next-Rewrite → API
|
|
(Express 5, Port 3000, nur im Compose-Netz) → SQLite `data/leistungsbilanz.db`
|
|
- Browser im LAN → `http://192.168.0.133:3090` direkt, ohne TLS und am Caddy vorbei
|
|
- Anmeldung nur in der App: `POST /api/auth/login` → Session-Cookie `lb_session` (httpOnly,
|
|
SameSite=Lax, `Secure` per `COOKIE_SECURE`), Sessions im Speicher der API
|
|
- Schreibzugriffe: zusätzlich Header `X-Leistungsbilanz-Request: 1`, `Sec-Fetch-Site` wird geprüft
|
|
- Rollen: `admin` (zusätzlich `/api/users/*`) und `user` (alles andere), kein Self-Signup
|
|
- Fremdinhalt: Revit/Linear-XLSX (Base64 im JSON, `/api/projects/:id/...` Erst- und Folgeimport),
|
|
Projektdatei `*.leistungsbilanz.json` (`POST /api/projects/import`, `/:projectId/import`),
|
|
Dokument-Logo (`PUT /api/document-branding/logo`)
|
|
- Ausgaben: Projekt-Export als JSON (`GET /api/projects/:id/export`), Rückgabe-XLSX für Linear,
|
|
Druckansicht/PDF über den Browser
|
|
|
|
Live-Instanz auf cj-ki: `/home/cj/leistungsbilanz` (Branch `deploy/cj-ki`, Stand 28.09.2026, ohne
|
|
diese Baseline). Der Caddy-Kommentar dort hält fest: bewusst ohne Authelia seit 28.09.2026, weil
|
|
die App eine eigene Anmeldung hat.
|
|
|
|
## Assets
|
|
|
|
| Asset | Schutzbedarf | Bemerkung |
|
|
|---|---|---|
|
|
| Projektdaten (Verteiler, Stromkreise, Lasten, Räume, Grobbilanz) | hoch | Planungsdaten von Kundenprojekten (Daten Dritter). Fehler wirken auf die Auslegung der Anlage. |
|
|
| Importierte Revit/Linear-XLSX inkl. Originalbytes des letzten Imports | hoch | Kundendaten, liegen in der DB und im Projekt-Export |
|
|
| Projektverlauf, Snapshots, Undo/Redo | mittel | Wiederherstellbarkeit nach Fehlbedienung |
|
|
| Benutzerkonten (Anmeldename, Name, Rolle, scrypt-Hash) | hoch | personenbezogen, Zugang zu allen Projekten (siehe R3) |
|
|
| Session-Cookie `lb_session` | hoch | wer es hat, ist der Nutzer |
|
|
| Büroprofil und Logo für Druckköpfe | mittel | erscheint auf allen Ausdrucken |
|
|
| Bootstrap-Admin-Passwort in `.env` | hoch | nur bis zum ersten Start relevant |
|
|
| Echte Beispieldatei `docs/spec/kbp-Stromkreis.xlsx` | hoch | gitignored, nie in Tests oder Commits |
|
|
|
|
Gesundheitsdaten: keine. Personenbezug: nur Benutzerkonten.
|
|
|
|
## Angreifer
|
|
|
|
- **A1** anonym aus dem Internet (Instanz ist über Caddy öffentlich erreichbar)
|
|
- **A2** jemand im LAN, direkt an Port 3090 (HTTP, am Caddy vorbei)
|
|
- **A3** angemeldeter Nutzer mit Rolle `user`, der Admin-Rechte will
|
|
- **A4** Kollege mit gleicher Rolle, der fremde Projekte liest oder ändert (IDOR)
|
|
- **A5** präparierte Importdatei (XLSX, Projektdatei, Logo), auch ungewollt von Kunden weitergereicht
|
|
- **A6** kompromittiertes npm-Paket, KI-Agent mit Schreibrechten im Repo
|
|
|
|
## Vertrauensgrenzen
|
|
|
|
1. Internet/LAN → Web: Identität wird **nur** in der API über `lb_session` festgestellt. Kein
|
|
Proxy-Header wird geglaubt (gefälschtes `X-Remote-User` wird im Baseline-Test abgelehnt).
|
|
2. Web → API: die API ist nicht auf dem Host veröffentlicht (`expose`), der Next-Rewrite reicht
|
|
Cookie und Header durch.
|
|
3. API → Projektdaten: nach dem Login gibt es **keine** Prüfung pro Projekt (R3).
|
|
4. Importdateien sind Fremdinhalt und werden erst nach dem Auth-Guard verarbeitet.
|
|
|
|
## Risiken
|
|
|
|
| # | Risiko | Angreifer | Wirkung | Prio | Maßnahme | Beleg | Status |
|
|
|---|---|---|---|---|---|---|---|
|
|
| R1 | Zugriff ohne Anmeldung | A1, A2 | alle Projekte lesen/ändern | K | Auth-Guard vor allen `/api`-Routen, offen nur `login`/`status`/`logout` und `/health`; Passwortwechsel-Pflicht vor allem anderen | `tests/security/baseline.test.ts` „jede Route verlangt Login“, „gefälschte Identität wird abgelehnt“; `roles.test.ts` „ohne Passwortwechsel …“; Mutationen „Login-Pflicht entfernt“, „ungeschützte Route vor dem Auth-Guard“, „Passwortwechsel-Pflicht entfernt“ | belegt |
|
|
| R2 | Rechteausweitung `user` → `admin`, Benutzerverwaltung | A3 | Konten anlegen, Passwörter zurücksetzen, Admin werden | K | `/api/users/*` nur `admin`; Rolle kommt aus der DB, nie aus der Anfrage; zod `.strict()`; letzter aktiver Admin nicht entfernbar (atomar); Reset/Deaktivieren beendet alle Sessions | `roles.test.ts` „normaler Nutzer bekommt 403 …“ + Gegenprobe; `authentication.test.ts` „never removes the last active admin“, „… concurrent updates“; Baseline Mass Assignment (`role: "admin"`); Mutation „Admin-Rolle für /api/users entfernt“ | belegt |
|
|
| R3 | Fremde Projekte lesen, ändern, exportieren, löschen (ASVS-light **B1/B2**: Objektzugriff, Listen) | A4 | jeder angemeldete Nutzer hat Vollzugriff auf alle Projekte | K | keine Prüfung pro Projekt, Projekte haben keinen Besitzer: **Design-Entscheidung D1** (teamweit geteilt, Konten nur für das eigene Team) | D1 unten; IDOR-/Listen-Tests in `baseline.test.ts` laufen und zählen per `idorGap` (`tests/security/security.config.ts`) als TODO | **nicht anwendbar (D1)**, neu bewerten bei externen Konten |
|
|
| R4 | CSRF von fremden Seiten oder anderen Diensten | A1, A2 | Änderungen im Namen eines angemeldeten Nutzers | H | SameSite=Lax, Pflichtheader `X-Leistungsbilanz-Request: 1` für POST/PUT/PATCH/DELETE (erzwingt CORS-Preflight), `Sec-Fetch-Site` wenn vorhanden, keine CORS-Freigabe | `authentication.test.ts` „rejects writes without the application header …“; Baseline „CORS lässt fremde Origins nicht zu“; Mutationen „CSRF-Header-Prüfung entfernt“, „CORS für alle Origins“ | belegt |
|
|
| R5 | Passwort-Raten und Session-Diebstahl über das Internet | A1 | Konto übernommen, damit alle Projekte (R3) | H | scrypt mit Salt, 5 Fehlversuche je Name in 15 min → 429, gleiche Laufzeit für unbekannte Namen, Längengrenzen vor scrypt, neue Session-ID bei Login und Passwortwechsel, gleitende 12 h + harte 7 Tage, max. 20 Sessions je Nutzer. Bewusst nicht: dauerhafte Sperre, IP-basierte Drosselung (alle Anfragen kommen über den Rewrite mit derselben IP), 2. Faktor | `authentication.test.ts` (Hash, Throttle, Session-Laufzeit, Look-alike-Namen, Längengrenzen) | belegt; Restrisiko öffentliche Login-Seite siehe E2 |
|
|
| R6 | Klartext im LAN: Port 3090 auf `0.0.0.0`, HTTP am Caddy vorbei; Passwort und `lb_session` mitlesbar; über `http://192.168.0.133:3090` gesetzte Cookies gehen an alle HTTP-Dienste dieses Hosts | A2 | Session-Übernahme | H | Port nur für Caddy erreichbar machen (Bind/Firewall), Zugriff nur über die TLS-Hostnamen, `COOKIE_SECURE=true` | Seit 2026-10-17: `compose.local.yaml` bindet `127.0.0.1:3090:3001`, Caddy erreicht `leistungsbilanz-web-1:3001` über das Docker-Netz `nginx-proxy-manager_default`; `192.168.0.133:3090` aus dem LAN geprüft zu, beide HTTPS-Hosts 200; `COOKIE_SECURE=true` in der Live-`.env` geprüft. Kein automatischer Test (Deploy-Konfiguration liegt außerhalb des Repos) | **behoben** (Deploy cj-ki) |
|
|
| R7 | Großer JSON-Body vor der Anmeldung: `express.json({ limit: "25mb" })` gilt global vor dem Auth-Guard, also auch für `POST /api/auth/login` | A1 | Speicher-DoS (API-Container 512 MB, Neustart beendet alle Sessions) | M | kleines globales Limit, 25 MB nur auf den Import-Routen nach dem Auth-Guard (wie netzberechnung R5) | `src/server/app.ts:36`; kein Test | **offen** |
|
|
| R8 | Präparierte XLSX: übergroß, Zip-Bombe, Formeln, Datei zwischen Vorschau und Übernahme getauscht | A5 | Speicher-DoS, falsche Daten im Projekt | M | Endung `.xlsx`, max. 10 MB, kanonisches Base64, nach dem Lesen max. 50.000 Zeilen / 256 Spalten / 2 Mio. Zellen; Formeln werden nie ausgewertet (nur zwischengespeicherter Wert, sonst Warnung); Übernahme nur bei gleicher SHA-256 wie die Vorschau und passender Projektrevision. **Offen:** ExcelJS entpackt die ganze Datei, bevor die Zeilen-/Zellgrenzen greifen (Zip-Bombe innerhalb 10 MB) | `tests/external-xlsx-transport.test.ts` „enforces extension, byte size and post-read sheet limits“; `revit-xlsx-scale.test.ts`; `external-xlsx-initial-import.test.ts`; kein Zip-Bomben-Test | teilweise, Zip-Bombe offen |
|
|
| R9 | Präparierte Projektdatei (`*.leistungsbilanz.json`, Snapshot-Restore) | A5 | kaputter oder untergeschobener Projektstand | M | zod-Schemas (`project-transfer.schemas.ts`), Schemaversion, Prüfsumme und Zustands-Hash, Import als neues Projekt oder als Revision mit Undo. Prüfsumme sichert Integrität, nicht Herkunft: wer die Datei baut, kann sie korrekt berechnen | `tests/project-state-snapshot.test.ts`; Baseline „ungültige Eingaben führen nie zu 5xx“ | belegt (Integrität), Herkunft bewusst nicht |
|
|
| R10 | Export: Projekt-JSON enthält alle Daten inkl. XLSX-Originalbytes; Header-Injection über den Projektnamen im Dateinamen | A4 | Abfluss von Kundendaten | M | `safeFilename` (nur `a-zA-Z0-9_-`, max. 80 Zeichen); Zugriff wie R3 | `src/server/controllers/project-transfer.controller.ts:120`; Export ist in `idorCases` (R3) | Dateiname belegt durch Code, kein Test; Zugriff siehe R3 |
|
|
| R11 | Logo-Upload: Skript im Bild (SVG), falscher Typ, jeder `user` darf Logo und Büroprofil für alle Ausdrucke ändern | A4, A5 | XSS beim direkten Öffnen, verfälschte Druckköpfe | M | PNG/JPEG per Signatur, SVG nur per `<svg` in den ersten 2 kB (schwach); max. 2 MB; Abruf mit `nosniff` und `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:` | `src/server/controllers/document-branding.controller.ts`; `tests/document-branding.repository.test.ts` (nur Repository, kein Routentest) | teilweise, Routentest fehlt; Rechte siehe E3 |
|
|
| R12 | Keine Nachvollziehbarkeit bei geteilten Projekten: der Projektverlauf hat `actorId`, die API setzt ihn aber nie | A4 | Änderungen keinem Nutzer zuordenbar | M | angemeldeten Nutzer (`req.authUser`) bis in `projectCommandService` durchreichen | `grep actorId src/server` leer; Spalte `project_revisions.actor_id` | **offen** |
|
|
| R13 | Secrets oder echte Kundendaten im Repo | A6 | Zugangsdaten bzw. Kundendaten öffentlich | H | `.env`/`.env.*` und `docs/spec/kbp-Stromkreis.xlsx` gitignored, Testdaten synthetisch, gitleaks per pre-commit | `.gitignore`; `make secrets` | belegt |
|
|
| R14 | Abhängigkeiten (u. a. `exceljs` parst Fremdinhalt) | A6 | Codeausführung, DoS | M | `npm audit` in `make check`, Lockfile, neue Pakete per `dependency-vetting` | `make audit`: 6 moderate ohne nicht-brechenden Fix (`drizzle-kit`/`esbuild`, `exceljs`/`uuid`), kein High/Critical | offen bis Eintrag in `RELEASE-AUSNAHMEN.md` oder Update |
|
|
|
|
## Design-Entscheidungen
|
|
|
|
Hier steht nur, was Julian bzw. Christian ausdrücklich entschieden haben. Ein Agent trägt hier
|
|
nichts als entschieden ein.
|
|
|
|
**D1: Projekte sind teamweit geteilt (kein Besitz pro Projekt).**
|
|
- Entschieden von: Julian, am 17.10.2026 gegenüber Christian bestätigt (beantwortet E1).
|
|
Eingetragen von einem Agenten auf Anweisung von Christian.
|
|
- Begründung: Die Software läuft nur im eigenen Team. Alle angemeldeten Nutzer sollen alle
|
|
Projekte sehen und bearbeiten.
|
|
- Gilt für: jede Instanz, auf der nur Mitglieder des eigenen Teams Konten haben; heute die
|
|
Instanz auf cj-ki (`leistungsbilanz.jansseneconsult.de`, `leistungsbilanz.electric-consult.de`).
|
|
Admins legen Konten nur für Teammitglieder an, keine Externen.
|
|
- Neu bewerten: sobald Externe (z. B. Kunden, Subplaner) ein Konto bekommen sollen. Dann wird
|
|
das System überarbeitet (Besitz bzw. Freigabe pro Projekt), **bevor** das erste externe Konto
|
|
angelegt wird; `idorGap` entfernen, die IDOR-Tests müssen grün werden (`authz-idor-audit`).
|
|
- Folge: ASVS-light B1/B2 sind für diese Instanz nicht anwendbar (R3). `idorGap` in
|
|
`tests/security/security.config.ts` bleibt gesetzt und macht den Ist-Zustand sichtbar.
|
|
|
|
## Offene Entscheidungen
|
|
|
|
E1 (geteilte Projekte) ist mit D1 beantwortet.
|
|
|
|
**E2 (H): Bleibt die Instanz ohne Authelia öffentlich erreichbar?** Laut Caddy-Kommentar seit
|
|
28.09.2026 bewusst so, wer das entschieden hat, steht dort nicht. Wegen D1 heißt ein erratenes
|
|
Passwort Zugriff auf alle Kundenprojekte. Alternativen: Authelia bzw. VPN vorschalten oder
|
|
2. Faktor in der App.
|
|
|
|
**E3 (M): Dürfen alle `user` Büroprofil und Logo ändern**, oder nur `admin`?
|
|
|
|
## Nächste Schritte (technisch, ohne Entscheidung machbar)
|
|
|
|
- ~~R6~~ erledigt 2026-10-17 (Port nur noch 127.0.0.1, Caddy über Docker-Netz, `COOKIE_SECURE=true`)
|
|
- R7: globales JSON-Limit klein, 25 MB nur auf Import-Routen nach dem Auth-Guard, mit Test
|
|
- R8: Zip-Bomben-Test und Grenze für die entpackte Größe vor `workbook.xlsx.load`
|
|
- R11: Routentest für den Logo-Upload (SVG mit Skript, falscher Typ, Größe, ohne Login)
|
|
- R12: `actorId` aus der Session in den Projektverlauf schreiben, mit Test
|