diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-SECURITY.md b/.planning/phases/19-local-auth-no-oidc-mode/19-SECURITY.md new file mode 100644 index 0000000..883267b --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-SECURITY.md @@ -0,0 +1,113 @@ +--- +phase: 19-local-auth-no-oidc-mode +audited: 2026-06-17 +status: secured +asvs_level: 1 +block_on: high +register_authored_at_plan_time: true +threats_total: 28 +threats_closed: 28 +threats_open: 0 +threats_accepted: 2 +supply_chain_checks: 2 +--- + +# Phase 19 — Local Auth (No-OIDC Mode): Security Audit + +**Audited:** 2026-06-17 +**ASVS Level:** 1 +**block_on:** high +**Compared against:** main..HEAD +**Audit type:** Retroactive threat-mitigation verification (declared register, no net-new scan) +**Branch:** `gsd/phase-19-local-auth-no-oidc-mode` +**Verdict:** SECURED — 28/28 threats closed (26 mitigate + 2 accept), 0 open, 0 unregistered flags + +Implementation files were treated as READ-ONLY. No implementation file was modified by this audit. + +--- + +## Threat Verification + +| Threat ID | Category | Disposition | Status | Evidence (file:line) | +|-----------|----------|-------------|--------|----------------------| +| T-19-01 | Information Disclosure | mitigate | CLOSED | `apps/api/src/auth/localCredentials.ts:66` (16-byte randomBytes salt), `:114` timingSafeEqual, `:115-118` verify never throws; no password logged | +| T-19-02 | Spoofing | mitigate | CLOSED | `apps/api/src/auth/localSession.ts:58` Jwt.sign HS256 w/ LOCAL_SESSION_SECRET; `:90-94` verify returns null on tamper/expiry | +| T-19-03 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/lib/bootGuards.ts:53-66` assertLocalSessionSecretSet (exit 1 when unset/<32, exempt in bypass); wired `apps/api/src/index.ts:265` | +| T-19-04 | Tampering | mitigate | CLOSED | `.dockerignore:7` `apps/api/scripts/`, `:21` `apps/api/tests/`, `:23` `apps/pwa/e2e/` | +| T-19-SC(01) | Tampering | mitigate | CLOSED | `git diff main...HEAD` shows zero dependency-line changes in any package.json | +| T-19-05 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/admin.ts:47` `adminRouter.use('*', requireAdmin)` is first statement; test asserts 403 | +| T-19-06 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/admin.ts:75-79` noEchoHook on create/reset; `:148,:239` no-log comments honored | +| T-19-07 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/me.ts:240` resolveUserId from session, `:260-268` verifyPassword(current) before update | +| T-19-08 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/linkOidc.ts:65-74` preflight conflict before any write; backstop `uniq_oidc_identity` in `migrations/0000_baseline.sql` | +| T-19-09 | Tampering | mitigate | CLOSED | `apps/api/src/routes/me.ts:321-333` per-request nonce in signed HS256 state; `linkNonceStore.ts:41-48` single-use consume | +| T-19-10 | Tampering | mitigate | CLOSED | `apps/api/src/routes/admin.ts:165-185` db.transaction wraps users + local_credentials; `:202` 409 rolls back | +| T-19-11 | Elevation of Privilege | mitigate | CLOSED (deviation noted) | `apps/api/src/routes/localAuth.ts:162-176` 5→429 / 10→423; `:96-98`/admin.ts:262 admin reset clears. Keyed on **username** not IP (CR-04, documented) | +| T-19-12 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:128` dummyHashPromise, `:197-199` verifyPassword always run, `:210` identical 401 body | +| T-19-13 | Spoofing | mitigate | CLOSED | `apps/api/src/index.ts:191-197` OIDC guard wrapped to skip when `c.get('user')` set; `localAuthMiddleware.ts:45-101` populates it | +| T-19-14 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:47-51` noEchoHook on login route | +| T-19-15 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/index.ts:84-133` callback: nonce consume + BL-03 session-match + empty-iss/sub guard + linkOidcToUser conflict (409) | +| T-19-16 | Information Disclosure | accept→mitigate | CLOSED | D-06 applied; Phase-19-touched PWA files render no "Authelia" (see T-19-21) | +| T-19-17 | Spoofing | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:216` fresh issueLocalSessionCookie every success; `localSession.ts:50-55` exp claim bounds lifetime | +| T-19-18 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx` + `SettingsSheet.tsx` password in useState only; no localStorage/sessionStorage write for password fields | +| T-19-19 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx:291` single "Incorrect username or password." — no field-level blame | +| T-19-20 | Tampering | mitigate | CLOSED | No `dangerouslySetInnerHTML` in any PWA src (grep across `apps/pwa/src/` = 0 usages; only prohibition comments) | +| T-19-21 | Information Disclosure | mitigate | CLOSED | `grep -ci authelia` == 0 in LoginPage/AdminPage/BrandSlot; SettingsSheet's 1 hit is a copywriting-rule comment (line 828), not rendered | +| T-19-22 | Elevation of Privilege | accept | CLOSED | Documented accepted risk (below); server boundary verified at `admin.ts:47` requireAdmin | +| T-19-23 | Elevation of Privilege | mitigate | CLOSED | Seed only in `apps/pwa/e2e/global-setup.ts` (.dockerignore'd); zero seed in `migrations/` or `index.ts` | +| T-19-24 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/devBypass.ts:87,:122` NODE_ENV==='production' is FIRST check; `bootGuards.ts:26-34` assertNotDevBypassInProduction | +| T-19-25 | Tampering | mitigate | CLOSED | `.dockerignore:7` excludes `apps/api/scripts/`; `reset-admin.ts:26-32` NODE_ENV=production throw is first executable statement | +| T-19-26 | Information Disclosure | mitigate | CLOSED | `reset-admin.ts` logs only user id / status; no console statement emits the password value; `--dry-run` validates without writing (`:129-133`) | +| T-19-SC(05) | Tampering | mitigate | CLOSED | Zero new packages (same as T-19-SC(01)) | + +--- + +## Deviation Note — T-19-11 (rate-limit key) + +The register declares "per-IP rate-limit". The implementation (`localAuth.ts`, CR-04) keys the +limiter on the **submitted username**, not the client IP. This is a deliberate, documented +deviation: in this Pangolin-tunnel deployment all household traffic shares one X-Forwarded-For +first hop (so IP-keying let one actor lock out every member) and X-Forwarded-For is spoofable. +The declared security property — brute-force resistance via 5→429 and 10→423 with admin-reset +recovery and a self-healing TTL — is fully present. Treated as CLOSED. The register wording is +stale relative to the shipped (stronger-for-this-topology) mechanism. + +--- + +## Accepted Risks Log + +| Threat ID | Risk | Rationale | +|-----------|------|-----------| +| T-19-22 | Client `isAdmin` / `hasLocalCredential` flags are UX-only and trivially editable in the browser. | Accepted: these flags only gate PWA nav/affordances. The real authorization boundary is server-side `requireAdmin` on every `/api/admin/*` request (`admin.ts:47`) and session-derived `resolveUserId` on `/api/me/*`. Client gating is never the security boundary. Documented prior decision. | +| T-19-16 | "Authelia" provider name could leak infrastructure detail in UI/comments. | Low-severity hygiene (accept→mitigate). D-06 applied across Phase-19-touched surfaces; remaining occurrences are in the out-of-scope Phase 12 `SetupPage.tsx` wizard and in source comments, not on the local-auth surfaces this phase introduced. | + +--- + +## Unregistered Flags + +The two `## Threat Flags` entries in `19-04-SUMMARY.md` +(`threat_flag: credential-in-controlled-state` for `LoginPage.tsx` and `SettingsSheet.tsx`) +both map to existing register threats **T-19-18** (password in client storage). Informational +only — no unregistered attack surface. No WARNING raised. + +--- + +## Out-of-Scope Observation (non-blocking, not a Phase 19 gap) + +`apps/pwa/src/routes/SetupPage.tsx` (lines 379, 498, 627, 645, 695) renders the literal string +"Authelia" in the first-run setup wizard. This file was **not** modified in Phase 19 +(`git diff main...HEAD` = no changes) — it is the pre-existing Phase 12 wizard, outside the +T-19-21 mitigation scope ("any PWA source touched by this plan"). It does not affect the local-auth +login/admin/settings surfaces. Flagged here for a future D-06 sweep of the setup wizard; it is +**not** an open Phase 19 threat. + +--- + +## Security Audit 2026-06-17 + +| Metric | Count | +|--------|-------| +| Threats found | 28 | +| Closed | 28 | +| Open | 0 | +| Accepted | 2 | +| Supply-chain checks | 2 |