docs(phase-19.3): add security threat verification

This commit is contained in:
Jakub Zych
2026-11-05 09:33:44 +01:00
parent 7490a310ce
commit 8c6b3171a8

View File

@@ -0,0 +1,142 @@
---
phase: "19.3"
slug: "framework-transport-and-infrastructure-for-wavepath"
status: verified
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
threats_open: 0
asvs_level: 1
created: "2026-11-05"
---
# Phase 19.3 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Framework paths are relative to the repo root (HEAD `7b8d205`); `seo/` = `../vue-seo-layer`. The shared Nuxt purge route moved after the phase from the faq, menu and journal layers into vue-seo-layer, which all three now extend; T-23, T-29, T-37 and T-43 were verified there. T-19.3-SC is declared once per plan (6 rows, one ID).
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| application to Redis | network hop carrying credentials in `redis.url` and shared counters other instances trust | Redis URL, cache entries, rate-limit counters (high) |
| request data to cache and limiter keys | keys can derive from client IPs, user ids or request values | key material (medium) |
| config and environment to boot | CORS, cookie, Turnstile, api-docs, `redis.url`, driver keys, `public_url` validated or reported before serving | config values, secrets (high) |
| browser on a sibling subdomain / SPA origin to the API | credentialed cross-origin requests and the token cookie | session cookie, API answers (high) |
| request to Turnstile middleware | untrusted token and body; siteverify crosses to Cloudflare | Turnstile token, secret (high) |
| jsDelivr CDN to the api-docs page | third-party scripts executed in the app's origin | JS (medium) |
| client upload to attach.Store | untrusted bytes and file name | file content, name (high) |
| bucket or CDN to the browser | stored files served from the app's or a CDN's origin | files (medium) |
| AWS environment to s3blob | credentials outside summer config | AWS credentials (medium) |
| summer.yaml to go build | manifest values become command arguments | build tags (medium) |
| Go app to the site's Nitro purge route | bearer-token POST over loopback or https after admin saves | purge token (high) |
| network to the Nitro purge route | anyone who can reach the site can call the route | purge requests (medium) |
| plugin hooks to the save transaction | hook errors roll back admin writes | hook results (low) |
| Go module proxy to the build | a new test-only module enters go.sum | modules (medium) |
| test harnesses to the network | httptest, miniredis and testcontainers only | test traffic (low) |
| local checkouts to origin | tags and pushes every consumer reads | tags (medium) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.3-01 | Information disclosure | cooler.OpenRedis, Install errors/logs | high | mitigate | cooler/redis.go:15,30-46 fixed error naming `redis.url`, parse error dropped, Addr-only endpoint log; TestOpenRedisRedactsSmoke | closed |
| T-19.3-02 | Denial of service | RateLimitStore during outage | medium | mitigate | ratelimit.go:63-73 attempt timeout + fallback; 500 ms (cooler.go:56); once/min warner (:410-446); memory fallback (surf/serve.go:237) | closed |
| T-19.3-03 | Tampering | Redis key space | medium | mitigate | `<prefix>cache:` / `<prefix>rl:` namespaces; SCAN with escaped glob + UNLINK, no FLUSHDB (rediscache.go:121-146). Residual IN-01 | closed |
| T-19.3-04 | Denial of service | memory cache growth | medium | mitigate | memory.go:40-45 `MaximumSize`, <1 refused; cooler.go:51,304-312 | closed |
| T-19.3-05 | Information disclosure | route:list/tests dialing Redis | low | mitigate | serve.go:83 only `installCooler` caller; router.go:556-566 lookup only; store_lookup_test.go:60 | closed |
| T-19.3-06 | Information disclosure | CORS credentials + `*` | high | mitigate | surf/cors.go:50-51 enforced via LoadCORSConfig and LoadCORSProfiles; TestCORSCredentialsWildcardRefusedSmoke | closed |
| T-19.3-07 | Tampering | shared-cache CORS answers | medium | mitigate | cors.go:240-281 Vary added before the allow check, preflight; :488-503 exact-origin echo | closed |
| T-19.3-08 | Spoofing | token cookie attributes | high | mitigate | bouncer/cookie.go:85-116 Domain validity, production Secure, None requires Secure; HttpOnly hard-coded (:154,:172) | closed |
| T-19.3-09 | Information disclosure | cookie Domain scope | medium | mitigate | cookie.go:82-90 host-only default, Domain opt-in; docs/services/authentication.md:95 | closed |
| T-19.3-10 | Tampering | CSRF on refresh/logout | low | accept | AR-19.3-01. Residual IN-08 | closed |
| T-19.3-11 | Spoofing | Turnstile with no secret | high | mitigate | bouncer/turnstile.go:111-119 production + no secret → 503, success required; production flag from config (router.go:732-733). Residual IN-11 | closed |
| T-19.3-12 | Denial of service | slow siteverify | medium | mitigate | turnstile.go 5 s client + ctx, 4 KiB LimitReader | closed |
| T-19.3-13 | Tampering | body consumed by Turnstile | medium | mitigate | turnstile.go:264-266 read once, restored; body limit wraps route middleware (router.go:513-515); TestTurnstileMiddlewareSmoke. Residual IN-05 | closed |
| T-19.3-14 | Information disclosure | Turnstile secret/token in logs | high | mitigate | secret only in form body; url.Error unwrapped; log carries error text only (turnstile.go:120-241); TestTurnstileNeverLogsSecret | closed |
| T-19.3-15 | Tampering | swagger-ui on jsDelivr | medium | mitigate | surf/apidocs.go:32-66 pinned 5.33.1 with sha384 SRI + crossorigin; nonce-only CSP (:208) | closed |
| T-19.3-16 | Information disclosure | /api-docs surface | low | accept | AR-19.3-02 | closed |
| T-19.3-17 | Tampering (XSS) | api_docs title/spec URL | medium | mitigate | apidocs.go `html/template` contextual escaping (:10,:55-81) | closed |
| T-19.3-18 | Tampering / EoP | stored XSS via disk name | high | mitigate | lagoon/attach/diskext.go:39-77, store.go:170 `DiskExtension(sniffed, ext)`; inline.go:38-44 attachment disposition; storage.md:110-135; TestDiskExtensionTable. Residual IN-07 | closed |
| T-19.3-19 | Information disclosure | public_url exposing files | medium | mitigate | 22-hex random names (store.go:294-300); boot Warn (bucket.go:157-161); storage.md:94 | closed |
| T-19.3-20 | Tampering | build.tags reaching go build | medium | mitigate | internal/build/manifest.go:121-172 validation; build.go:58-78 `exec.CommandContext`, single `-tags=` arg, no shell | closed |
| T-19.3-21 | Repudiation / misconfig | s3:// in an untagged binary | medium | mitigate | attach/bucket.go:106-137 refusal; s3_on.go/s3_off.go; build prints `build tags:` | closed |
| T-19.3-22 | Information disclosure | AWS credentials | medium | mitigate | storage.md:78 no keys in bucket_url/config; no credential keys in attach | closed |
| T-19.3-23 | DoS / Spoofing | shared Nitro purge route | medium | mitigate | seo/server/lib/siteCachePurge.ts:13,54-60 ≥32-char token else 404, proxied → 404, sha256 + timingSafeEqual; purge.post.ts:32-48; route `cache: false` | closed |
| T-19.3-24 | Information disclosure | purge token in Go logs/errors | high | mitigate | cooler/sitecache.go errors/logs name keys, kind, slug, status, host+path only; assertNoToken in sitecache_test.go | closed |
| T-19.3-25 | Information disclosure | token in clear on the network | high | mitigate | sitecache.go:97-132 user info refused, http only to loopback | closed |
| T-19.3-26 | Denial of service | purge breaking saves | medium | mitigate | `WithoutCancel` + 2 s, `lagoon.AfterCommit`, errors logged only (sitecache.go:159,302-309); faq/menu 500-receiver tests | closed |
| T-19.3-27 | Tampering | purge before commit | low | mitigate | AfterCommit (sitecache.go:302-309); `cabana.TxFromContext` in faq, menu, journal site_cache.go | closed |
| T-19.3-28 | Tampering | ReorderAfterSave failing | low | accept | AR-19.3-03 | closed |
| T-19.3-29 | Tampering | three route copies drifting | low | mitigate | superseded: single copy in vue-seo-layer, none left in faq/menu/journal; sha256 pins seo/tests/sitePurge.test.ts:26-39 | closed |
| T-19.3-30 | Repudiation | gate passing on skipped tests | medium | mitigate | scripts/check-phase19.3.sh:195-285 fails on skip/zero/missing; self-test (:357+). See "Gate out of date" | closed |
| T-19.3-31 | Tampering | unverified release | medium | mitigate | 19.3-05 checkpoint; v0.1.27 unmoved on origin, ancestor of v0.2.0 and origin/master | closed |
| T-19.3-32 | Information disclosure | secret-in-logs tests | low | mitigate | TestTurnstileNeverLogsSecret, TestOpenRedisRedactsSmoke, assertNoToken | closed |
| T-19.3-33 | Tampering | profiled-route refusals missing CORS/Vary | medium | mitigate | surf/router.go:462-530 profile wraps recovery/body limit/locale, :905-918 Vary merge; TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary | closed |
| T-19.3-34 | Information disclosure | credentialed wildcard/unanchored origins | high | mitigate | cors.go:61-160 anchoring and wildcard host-label rules with probes, per profile; TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch. Residual: multi-label public suffix (below) | closed |
| T-19.3-35 | Information disclosure | token forwarded on redirect | high | mitigate | sitecache.go:109-115 `CheckRedirect` → `ErrUseLastResponse`; TestSiteCachePurgeNoRedirect | closed |
| T-19.3-36 | Denial of service | synchronous per-row purges | medium | mitigate | single coalescing worker, Wait, Close drains (sitecache.go:212-277; cooler.go:123-131); async tests | closed |
| T-19.3-37 | Spoofing | allowProxied reachable from the network | medium | mitigate | siteCachePurge.ts:55-59 token check first, `allowProxied !== true` → 404; default false (seo/nuxt.config.ts:58-71); docs/services/caching.md:215 | closed |
| T-19.3-38 | Information disclosure | inferred insecure cookie in production | high | mitigate | cookie.go:107-112 error names `auth.cookie.secure` and `app.url`; TestLoadCookieConfigProductionInferredSecure | closed |
| T-19.3-39 | Information disclosure | zero CookieConfig | medium | mitigate | cookie.go:273-287 zero → Secure, SameSite Lax, used by both handlers; TestZeroCookieConfigDefaults | closed |
| T-19.3-40 | Elevation of privilege | api-docs CSP with host source | medium | mitigate | apidocs.go:65-67,208 nonce-only `script-src`; TestAPIDocsCSPNoHostSource | closed |
| T-19.3-41 | Information disclosure | protected files in a public_url bucket | high | mitigate | attach/store.go:38-42,139-140 refused before body read; boot warning; every bucket via attach.OpenBucket; TestStoreProtectedNeedsPrivateBucket | closed |
| T-19.3-42 | Spoofing | turnstile routes without a secret, unnoticed | medium | mitigate | router.go:710,739-758 one Warn with route count and production flag, never the secret; TestTurnstileEmptySecretWarnsAtBoot. Residual IN-11 | closed |
| T-19.3-43 | Tampering | layer copies drifting | low | mitigate | superseded, as T-19.3-29 | closed |
| T-19.3-44 | Tampering | unverified release / moved tag | medium | mitigate | 19.3-06 checkpoint; v0.2.0 contains 0c8109c and v0.1.27, on origin/master; v0.1.27 unmoved | closed |
| T-19.3-SC (01) | Tampering | go-redis, otter installs | high | mitigate | decision note 2026-10-15-cooler-go-redis-otter.md precedes the go.mod commit; pinned v9.23.0 / v2.3.0 | closed |
| T-19.3-SC (02) | Tampering | installs | high | mitigate | go.mod/go.sum unchanged across the plan; swagger-ui via SRI-pinned CDN | closed |
| T-19.3-SC (03) | Tampering | s3blob / AWS SDK | high | mitigate | gocloud.dev v0.46.0; s3blob only behind `//go:build summercms_s3`; note precedes the go.mod commit | closed |
| T-19.3-SC (04) | Tampering | installs | high | mitigate | framework go.mod unchanged; layer package.json version bumps only; plugin go.mod bumps add only modules the framework already pins | closed |
| T-19.3-SC (05) | Tampering | miniredis install | high | mitigate | human approval recorded (19.3-05-SUMMARY:121-129); pinned v2.40.0; test-only imports | closed |
| T-19.3-SC (06) | Tampering | installs | high | mitigate | go.mod/go.sum unchanged across the plan; layer commits bump versions only | closed |
*Status: open · closed · open — below high threshold (non-blocking)*
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-19.3-01 | T-19.3-10 | Refresh only rotates a cookie (the answer is unreadable cross-origin unless CORS allows that origin); logout CSRF is a forced logout; routes are POST-only and the docs advise JSON or a custom header (authentication.md:97). | 19.3-02 PLAN | 2026-11-05 |
| AR-19.3-02 | T-19.3-16 | /api-docs is off by default; the spec is the app's hand-written public surface (D-16); both routes are public by design (D-17). | 19.3-02 PLAN | 2026-11-05 |
| AR-19.3-03 | T-19.3-28 | The hook runs inside the reorder transaction by design so its failure rolls the reorder back, as FormAfter hooks do; purge hooks never return errors. | 19.3-04 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Unregistered Flags and Residual Risk (non-blocking)
1. **Purge token was published in page payloads (fixed after the phase).** The phase's layer versions read the token from `NUXT_SITE_CACHE_PURGE_TOKEN`; nuxt-site-config copies every `NUXT_SITE_*` variable into the public site config. Fixed: the token is now `cachePurge.token` / `NUXT_CACHE_PURGE_TOKEN`, seo/server/plugins/site-cache-purge.ts strips the old keys, and docs/services/caching.md:207 says to rotate. **Action:** confirm every deployment that set the old variable has rotated its token.
2. **Credentialed wildcard on a multi-label public suffix passes boot** (VERIFICATION advisory 1, next to T-19.3-34). `https://*.co.uk` is accepted: `wildcardHostLabel` accepts any non-empty remainder and the probes only cover `.invalid` and `.com`.
3. **Second purge route.** The deprecated `vue-journal-layer/server/api/__journal__/cache/purge.post.ts` predates this phase, still ships, and is not covered by this register.
4. Open review info items from 19.3-REVIEW-DISPOSITION: IN-01 (T-03), IN-05 (T-13), IN-07 (T-18), IN-08 (T-10), IN-11 (T-11/T-42: no log when a published verifier replaces a configured secret).
### Gate out of date
The layers stage of `scripts/check-phase19.3.sh` (lines 175-177, 524) still `cmp`s the purge files inside the faq, menu and journal layers, which moved to vue-seo-layer; that stage would now fail. No threat reopens; T-29/T-43 evidence now comes from vue-seo-layer.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 50 rows (45 IDs) | 50 | 0 | msd-security-auditor (ASVS L1, block_on high; `go test -short` on cooler, bouncer, surf, lagoon/attach, internal/build: 28 named tests pass, 4 Docker-backed tests not run) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
- [ ] Old `NUXT_SITE_CACHE_PURGE_TOKEN` values rotated on every deployment that set them
**Approval:** verified 2026-11-05 (ASVS L1)