Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-SECURITY.md
T
Lucas Berger abf7be782a
CI / changes (pull_request) Successful in 10s
CI / fast-checks (pull_request) Failing after 2m17s
CI / api (pull_request) Successful in 2m51s
CI / security (pull_request) Failing after 12s
CI / harness (pull_request) Successful in 5m9s
CI / gate (pull_request) Failing after 2s
docs(phase-19): add security threat verification (28/28 closed, threats_open: 0)
2026-06-17 22:47:45 -04:00

8.1 KiB

phase, audited, status, asvs_level, block_on, register_authored_at_plan_time, threats_total, threats_closed, threats_open, threats_accepted, supply_chain_checks
phase audited status asvs_level block_on register_authored_at_plan_time threats_total threats_closed threats_open threats_accepted supply_chain_checks
19-local-auth-no-oidc-mode 2026-06-17 secured 1 high true 28 28 0 2 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