diff --git a/AGENTS.md b/AGENTS.md index f19b5e0..8719167 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -797,15 +797,22 @@ Rules for agents working on this repo: Open security points (state at adoption, 2026-10-15; updated 2026-10-17): -- **No `docs/THREATMODEL.md` yet.** B1/B2 (object access, list filtering) are K items in - the ASVS-light checklist, so `pre-release-gate` reports No-Go until the shared-projects - decision behind `idorGap` is written down there (who decided, why, which instance/team), - e.g. with the `threat-model-light` skill. `idorGap` alone is not evidence. - +- **Threat model: `docs/THREATMODEL.md`** (German, since 2026-10-17). Keep it current when + auth, roles, routes, imports/exports or deployment change. Its "Design-Entscheidungen" + section is still empty: only Julian or Christian record decisions there, agents never do. +- **Shared-projects decision pending (THREATMODEL R3 / E1, blocks release).** B1/B2 (object + access, list filtering) are K items in the ASVS-light checklist. `pre-release-gate` reports + No-Go until E1 is answered and recorded as a design decision (who decided, when, why, which + instance/team, when to re-evaluate). `idorGap` alone is not evidence. - **Projects have no owner.** Every signed-in user can read and change every project. The IDOR tests in `baseline.test.ts` run but are reported as TODO via `idorGap` in - `security.config.ts`. Introduce per-project ownership/sharing, then remove `idorGap`; - the tests must turn green. + `security.config.ts`. If E1 is answered "no": introduce per-project ownership/sharing, + then remove `idorGap`; the tests must turn green. +- Further open items from the threat model (no decision needed, see its "Nächste Schritte"): + R6 live port 3090 reachable over plain HTTP in the LAN, R7 global 25 MB JSON limit before + the auth guard, R8 no XLSX zip-bomb guard before `workbook.xlsx.load`, R11 no route test + for the logo upload, R12 `actorId` never set in project history. Open decisions E2 (public + without Authelia) and E3 (who may change office profile/logo). - `npm audit`: 6 moderate findings without a non-breaking fix (`drizzle-kit` dev tooling via `esbuild`, `exceljs` via `uuid`). High/critical: none. - ESLint warnings (not blocking): `security/detect-unsafe-regex` in diff --git a/docs/README.md b/docs/README.md index be55461..8aaf82b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Deployment und Betrieb](deployment.md) +- [Threat-Model](THREATMODEL.md): Assets, Angreifer, Risiken mit Beleg, offene + Entscheidungen für das `pre-release-gate` ## Revit / Linear XLSX diff --git a/docs/THREATMODEL.md b/docs/THREATMODEL.md new file mode 100644 index 0000000..5dc7ea2 --- /dev/null +++ b/docs/THREATMODEL.md @@ -0,0 +1,123 @@ +# 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. 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 ` 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, `idorGap` in +> `tests/security/security.config.ts` bleibt 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=true` in der Live-`.env` prü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: `actorId` aus der Session in den Projektverlauf schreiben, mit Test diff --git a/tests/security/security.config.ts b/tests/security/security.config.ts index c707911..3f0b217 100644 --- a/tests/security/security.config.ts +++ b/tests/security/security.config.ts @@ -94,10 +94,13 @@ const config = { return (await res.json()) as Record; }, - // OFFENE LÜCKE (bewusst, Entscheidung Julian): Projekte haben keinen Besitzer, jeder - // angemeldete Nutzer sieht und bearbeitet alle Projekte. Die IDOR-Tests laufen trotzdem - // und zeigen den Ist-Zustand, zählen aber als TODO statt als Fehler. Wird Besitz bzw. - // Freigabe pro Projekt eingeführt, diese Zeile entfernen – dann müssen sie grün werden. + // OFFENE LÜCKE: Projekte haben keinen Besitzer, jeder angemeldete Nutzer sieht und + // bearbeitet alle Projekte. Gedacht als teamweit geteilt, die Entscheidung (wer, warum, + // welche Instanz/welches Team) ist aber noch nicht festgehalten: docs/THREATMODEL.md R3 + // und offene Entscheidung E1. Bis dahin gilt B1/B2 im pre-release-gate als offen. + // Die IDOR-Tests laufen trotzdem und zeigen den Ist-Zustand, zählen aber als TODO statt + // als Fehler. Wird Besitz bzw. Freigabe pro Projekt eingeführt, diese Zeile entfernen, + // dann müssen sie grün werden. idorGap: "Projekte sind für alle angemeldeten Nutzer freigegeben (kein Besitz pro Projekt)", idorCases: [ { method: "GET", path: "/api/projects/{id}" },