docs(phase-19): add security threat verification (28/28 closed, threats_open: 0)
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
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
This commit is contained in:
@@ -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 |
|
||||
Reference in New Issue
Block a user