docs/THREATMODEL.md (threat-model-light): System, Assets, Angreifer, Vertrauensgrenzen, Risiken R1-R14 mit Beleg. R3 (ASVS-light B1/B2) bleibt offen, bis Julian E1 (Projekte teamweit geteilt) bestaetigt. idorGap- Kommentar und AGENTS.md verweisen darauf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 KiB
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, Host0.0.0.0:3090) → Next-Rewrite → API (Express 5, Port 3000, nur im Compose-Netz) → SQLitedata/leistungsbilanz.db - Browser im LAN →
http://192.168.0.133:3090direkt, ohne TLS und am Caddy vorbei - Anmeldung nur in der App:
POST /api/auth/login→ Session-Cookielb_session(httpOnly, SameSite=Lax,SecureperCOOKIE_SECURE), Sessions im Speicher der API - Schreibzugriffe: zusätzlich Header
X-Leistungsbilanz-Request: 1,Sec-Fetch-Sitewird geprüft - Rollen:
admin(zusätzlich/api/users/*) unduser(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
- Internet/LAN → Web: Identität wird nur in der API über
lb_sessionfestgestellt. Kein Proxy-Header wird geglaubt (gefälschtesX-Remote-Userwird im Baseline-Test abgelehnt). - Web → API: die API ist nicht auf dem Host veröffentlicht (
expose), der Next-Rewrite reicht Cookie und Header durch. - API → Projektdaten: nach dem Login gibt es keine Prüfung pro Projekt (R3).
- 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. Gedacht als teamweit geteilte Projekte, aber noch nicht als Entscheidung festgehalten (E1) | IDOR-/Listen-Tests in baseline.test.ts laufen, zählen per idorGap (tests/security/security.config.ts) als TODO. idorGap allein ist kein Beleg |
offen, Entscheidung E1 ausstehend |
| 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 |
/home/cj/leistungsbilanz/compose.local.yaml (${LB_WEB_PORT:-3090}:3001); Wert von COOKIE_SECURE in der Live-.env nicht geprüft |
offen |
| 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.
(noch keine)
Offene Entscheidungen
E1 (K, blockiert den Release): Sind Projekte bewusst für alle angemeldeten Nutzer geteilt?
Heute sieht und ändert jeder angemeldete Nutzer jedes Projekt (R3). Damit B1/B2 im
pre-release-gate als „nicht anwendbar“ gelten, braucht es unter „Design-Entscheidungen“ einen
Eintrag mit:
- Wer hat entschieden (Julian? Christian? beide?) und wann
- Warum (z. B. alle Nutzer arbeiten im selben Planungsteam an denselben Projekten, Vertretung ohne Freigabeschritt)
- Für welche Instanz und welches Team: gilt das für
leistungsbilanz.jansseneconsult.deundleistungsbilanz.electric-consult.de(dieselbe Instanz auf cj-ki)? Wer bekommt dort Konten, nur das eigene Team oder auch Externe (Kunden, Subplaner)? - Bis wann / unter welcher Bedingung das neu bewertet wird (z. B. sobald externe Konten dazukommen oder eine zweite Firma die Instanz nutzt)
Vorschlag für den Eintrag, erst nach Bestätigung durch Julian übernehmen:
D1: Projekte sind teamweit geteilt (kein Besitz pro Projekt). Entschieden von am . Begründung: <…>. Gilt für die Instanz <…> mit Konten nur für . Neu bewerten, sobald . Folge: ASVS-light B1/B2 nicht anwendbar,
idorGapintests/security/security.config.tsbleibt bis dahin gesetzt.
Lautet die Antwort „nein“: Besitz bzw. Freigabe pro Projekt bauen, idorGap entfernen, die
IDOR-Tests müssen grün werden (authz-idor-audit).
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. Mit R3 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: Port 3090 nur für Caddy,
COOKIE_SECURE=truein der Live-.envprüfen - 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:
actorIdaus der Session in den Projektverlauf schreiben, mit Test