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 |