Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md

327 lines
31 KiB
Markdown

---
phase: 06
slug: http-routing-auth-groups-and-rate-limiting
status: verified
threats_total: 34
threats_closed: 34
threats_open: 0
accepted_risks: 4
asvs_level: 1
created: 2026-09-19
verified: 2026-09-21
reopened: 2026-09-21
reverified: 2026-09-21
---
# Phase 6 — Security Review
> Guard registry, dual-group auth, atomic rate limiting, raw-group house-middleware refusal and transactional panic recovery, CORS/body-limit scoping, transition-aware SSRF protection, and exact personal-token denial serialization. Every reviewed ID from Plans 06-01 through 06-10 is mapped below to a named passing test or a restated accept rationale; Plan 06-11 refreshes the review only after both repositories pass their complete race and vet gates. Unmapped IDs are a review gap, not an accepted risk.
**Date:** 2026-09-21
**Scope:** Plans 06-01 through 06-10 (implementation, coverage, and corrective gap closure), the Plan 06-11 review refresh (superseded, see Reopened), and gap-closure Plans 06-12 through 06-14 (T-06-28 through T-06-35).
**Repos grepped:** `summercms.go` and `fonoteka.go` (excluding `.planning/` and `vendor/`).
---
## Reopened
The 2026-09-21 verdict of 26 closed / 0 open (Plan 06-11) was contradicted by phase verification: named middleware could read past the body cap, invalid limiter definitions failed open, the SSRF classifier missed IANA special-use ranges and zoned IPv6, and several warning-class defects existed. That verdict is **superseded**; its audit-trail row is retained below. Plans 06-12 and 06-13 fixed the code and Plan 06-14 added the regression proof, so T-06-28 through T-06-35 were added, and T-06-12 is annotated below.
## Verdict Summary
The register contains **34 total threats: 34 closed, 0 open, with 4 unchanged accepted risks and no new accepted risk**. This verdict follows the 2026-09-21 Plan 06-14 gate run: `go vet ./...` and `go test ./... -count=1 -race -short` passed in both `summercms.go` and `fonoteka.go`, and every test cited for T-06-28 through T-06-35 was confirmed to exist and pass.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| client → Authorization header | untrusted JWT or `inv_` bearer parsed on every request | raw token, token hash, `users.id` |
| guard registry → plugin Boot | plugin-declared guard names become live auth middleware | `jwt`, `inv_token` |
| inv_token guard → `golem15_fonoteka_api_tokens` | hash-indexed lookup of an untrusted bearer | `token_hash`, scopes, expiry, revocation |
| client → X-Forwarded-For / limiter keys | untrusted IP / token id / route param feeds the Store | `RemoteAddr`, XFF, `tok:<id>` |
| unauthenticated client → personal-token route | missing or invalid bearer traffic must consume a bounded per-IP budget before scope denial returns | bearer status, client IP, limiter counter |
| inv_token context → limiter key resolver | valid credentials must be resolved before rate limiting to retain independent token budgets | `bouncer.Credential`, `tok:<id>` |
| concurrent requests → limiter admission | threshold comparison and admitted increment must be one atomic decision | fixed-window count, maximum, retry duration |
| request metadata → anonymous inline key | caller-controlled throttle text and Host inputs must not select a fresh budget | constant `inline:domainless`, trusted-proxy `ClientIP` |
| public-share group → anonymous caller | zero-credential surface; 429 bodies must not leak internals | Retry-After, JSON error body |
| raw group → house middleware | RFC/OAuth surface must never inherit the house envelope | `inv.must-change-password` |
| handler/middleware → client response | status, headers, and body remain private until successful handler completion | buffered response, success commit, panic discard |
| request body → handler | unbounded POST is a resource-exhaustion vector | `http.MaxBytesReader` |
| caller-supplied URL → outbound fetch | user/third-party URL must never reach loopback, RFC1918, CGNAT, or metadata | dial-time IP, host allow-list |
| IPv6 transition syntax → IPv4 SSRF policy | embedded IPv4 in NAT64 and 6to4 must receive the ordinary reserved/private classification | RFC 6052 `/96` and `/48`, RFC 3056 `2002::/16` |
| request body → named middleware | a body-consuming named middleware must be bounded by the same cap as the terminal handler | `http.MaxBytesReader`, `io.ReadAll` in middleware |
| plugin bucket definition → limiter | a plugin-supplied bucket or inline throttle must not fail open at runtime | `Bucket{Key, Max, Decay}`, `N,M` param |
| non-public IP representations → dial | zoned, special-use and mapped forms must classify as their non-public form at connect time | `netip.Addr` incl. zone, IANA special-use prefixes |
| personal-token context → denial serializer | status and exact JSON bytes cross the public compatibility boundary together | `wire.WriteJSON`, raw 401/403 bytes |
---
## Threat Register
| Threat ID | Category | Plan of origin | Disposition | Proof |
|-----------|----------|----------------|-------------|-------|
| T-06-01 | Spoofing | 06-01 | mitigate | `bouncer/registry_test.go:TestDuplicateGuardNameFailsWithPluginAndName`; `bouncer/registry_test.go:TestUnknownGuardNameFails`; `bouncer/registry_test.go:TestRegisterNeitherInterfaceNamesPluginAndName` |
| T-06-02 | Elevation of Privilege | 06-01 | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`; `plugins/golem15/fonoteka/routes_group_test.go:TestGenresSharedHandler` |
| T-06-03 | Information Disclosure | 06-01 | accept | Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash |
| T-06-04 | Repudiation | 06-01 | mitigate | `plugins/golem15/fonoteka/classes/auth/token_guard_test.go:TestTokenGuard` (`valid-stamps-once`); grep of the auth package finds no fmt/log of the raw bearer |
| T-06-05 | Tampering | 06-01 | accept | Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here |
| T-06-06 | Denial of Service | 06-02 | mitigate | `surf/clientip_test.go:TestClientIPRejectsSpoofedXFF` |
| T-06-07 | Information Disclosure | 06-02 | mitigate | `plugins/golem15/fonoteka/middleware/public_share_headers_test.go:TestPublicShareHeadersRewrites429` |
| T-06-08 | Denial of Service | 06-02 | accept | v1 ships an unbounded-until-swept map per CONTEXT D-03's explicit "no otter/cooler this phase" decision; the sweep goroutine bounds long-term growth to roughly one decay window's worth of distinct keys, acceptable for a single-instance v1 deployment |
| T-06-09 | Repudiation | 06-02 | mitigate | `parity/php_debug_test.go:TestPHPParityPinsAppDebugFalse` |
| T-06-10 | Elevation of Privilege | 06-03 | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity` (full assembled `Router.Routes()`, not a hand-built fixture) |
| T-06-11 | Tampering | 06-03 | mitigate | `surf/routetable_test.go:TestRawGroupHouseMiddlewareRefusedAtBuild`; `plugins/golem15/fonoteka/routes_cors_test.go:TestRawGroupRefusesHouseMiddlewareOnRealPlugins`; `plugins/golem15/fonoteka/routes_cors_test.go:TestHouseMiddlewareCapabilityOnRealPlugins` |
| T-06-12 | Denial of Service | 06-03 | mitigate | `surf/bodylimit_test.go:TestBodyLimitDefaultRejectsOversizedBody`; `surf/bodylimit_test.go:TestBodyLimitRawExempt` |
| T-06-13 | Information Disclosure | 06-03 | mitigate | `http_config_test.go:TestProductionBodyLimitsOperatorConfirmed` (134217728 / 134217728; no INTERIM) |
| T-06-14 | Elevation of Privilege | 06-04 | mitigate | `fetchguard/fetch_test.go:TestFetchPrivateIPBlockedInBothModes`; `fetchguard/ip_test.go:TestIsReservedOrPrivate` |
| T-06-15 | Tampering | 06-04 | mitigate | `fetchguard/fetch_test.go:TestFetchPrivateIPBlockedInBothModes` (dial-time `net.Dialer.Control` on the address being connected, not a pre-resolved hostname) |
| T-06-16 | Denial of Service | 06-04 | mitigate | `fetchguard/fetch_test.go:TestFetchTooLargeIsStreaming` |
| T-06-17 | Elevation of Privilege | 06-04 | mitigate | `fetchguard/fetch_test.go:TestFetchDoesNotFollowRedirect` |
| T-06-18 | Spoofing | 06-04 | mitigate | `fetchguard/fetch_test.go:TestFetchAllowHostsRejectsDottedSuffixBypass`; `fetchguard/fetch_coverage_test.go:TestHostAllowedExactAndDottedSuffix` |
| T-06-21 | Denial of Service | 06-06 | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` drives 61 same-IP requests through the real handler returned by `surf.Assemble`: requests 1-60 retain 401 `Invalid token`, while request 61 receives the exact 429 response. The source declaration and runtime-order invariant is `inv_token` -> `throttle:fonoteka-api-token` -> `inv.scope:read`. |
| T-06-22 | Denial of Service | 06-06 | mitigate | The live route keeps `inv_token` before `throttle:fonoteka-api-token`, so `bouncer.Credential` is populated before the bucket key closure and valid credentials retain `tok:<id>` keying instead of collapsing onto the IP fallback. The exact ordering is covered by `TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` plus the route-source invariant. |
| T-06-23 | Denial of Service | 06-07 | mitigate | `surf/limiter_test.go:TestMemoryStoreConcurrentAttempt`; `surf/limiter_test.go:TestFixedWindowLimiterConcurrentMaxOne`; `surf.MemoryStore.Attempt` owns expiry, threshold comparison, admitted increment, and retry duration under one mutex. |
| T-06-24 | Denial of Service | 06-07 | mitigate | `surf/limiter_test.go:TestFixedWindowLimiterInlineThrottleKeys/anonymous_same_IP_different_Host`; `TestFixedWindowLimiterInlineThrottleKeys/anonymous_inline_policies_share_a_domainless_key`; `TestFixedWindowLimiterInlineThrottleKeys/principals_differ`; production anonymous key is exactly `inline:domainless|<ClientIP>`. |
| T-06-25 | Elevation of Privilege / Information Disclosure | 06-08 | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIPv6Transitions`; `fetchguard/fetch_test.go:TestDialControlRejectsUnsafeIPv6Transitions`; `fetchguard.embeddedTransitionIPv4` decodes both NAT64 forms and 6to4 before the dial decision. |
| T-06-26 | Information Disclosure | 06-09 | mitigate | `surf/router_test.go:TestRecoverDiscardsPartialResponse/house`; `TestRecoverDiscardsPartialResponse/raw`; `TestBufferedResponseCommitsSuccessfulOutput`; shared `bufferedResponse` commits only after a normal return. |
| T-06-27 | Tampering | 06-10 | mitigate | `plugins/golem15/fonoteka/middleware/token_scope_test.go:TestInvScope/no-user-401`; `TestInvScope/missing-scope-403`; both denial branches call `wire.WriteJSON` and compare untrimmed bytes. |
| T-06-28 | Denial of Service | 06-12 | mitigate | `surf/bodylimit_test.go:TestBodyLimitBoundsBodyConsumingMiddleware`; `surf/bodylimit_test.go:TestBodyLimitBoundsNamedMiddleware`; `surf/bodylimit_test.go:TestBodyLimitInvalidParamFailsBoot`; source `surf/router.go` `wrap` |
| T-06-29 | Denial of Service | 06-12 | mitigate | `surf/limiter_test.go:TestRegisterBucketRejectsInvalid`; `surf/limiter_test.go:TestValidateThrottleRejectsOverflowAndNilStore`; `surf/limiter_test.go:TestMiddlewareFailsClosed`; source `surf/limiter.go` `RegisterBucket`/`Middleware`/`ValidateThrottle`/`resolve` |
| T-06-30 | Elevation of Privilege | 06-13 | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIANABoundaries`; `fetchguard/ip_test.go:TestIsReservedOrPrivateSpecialUseSmoke`; source `fetchguard/ip.go` `privateV4`/`privateV6` |
| T-06-31 | Elevation of Privilege | 06-13 | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIgnoresZone`; `fetchguard/fetch_test.go:TestDialControlRejectsZonedAndSpecialUse`; `fetchguard/fetch_test.go:TestFetchPublicOnlyMapsSpecialUseToPrivateIP`; source `fetchguard/fetch.go` `dialControl` |
| T-06-32 | Spoofing | 06-12 | mitigate | `bouncer/registry_test.go:TestRegisterRejectsTypedNilGuard`; `bouncer/registry_test.go:TestRegisterRejectsTypedNilPointerFuncMapGuards`; `bouncer/registry_test.go:TestRegisterAcceptsValidGuards`; source `bouncer/registry.go` `Register` |
| T-06-33 | Spoofing | 06-12 | mitigate | `bouncer/jwt_test.go:TestVerifyRejectsFractionalSubject`; `bouncer/jwt_test.go:TestVerifySubjectMatrix`; `bouncer/jwt_test.go:TestSubjectJSONNumber`; source `bouncer/jwt.go` `subject` |
| T-06-34 | Denial of Service | 06-12 | mitigate | `surf/bodylimit_test.go:TestCompileRouteConflictReturnsError`; `surf/router_test.go:TestFactoriesBuiltOncePerName`; source `surf/router.go` `handleRoute` |
| T-06-35 | Denial of Service | 06-12 | mitigate | `surf/router_test.go:TestBuildRouterFailsOnMissingBodyConfig`; `surf/bodylimit_test.go:TestBodyLimitMissingConfigFailsBoot`; source `surf/router.go` `requiredBytes` |
| T-06-SC | Tampering | 06-03 | accept | Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts) -- no additional human-verify checkpoint required beyond that prior audit |
*Status: 34 closed / 0 open. Dispositions are copied from the originating plans; all accept rationales remain verbatim.*
---
## Findings by Threat
### T-06-01 — duplicate or unknown guard names fail boot
- **Source:** `bouncer/registry.go` (`Register`, `Middleware`).
- **Test evidence:** `TestDuplicateGuardNameFailsWithPluginAndName`, `TestUnknownGuardNameFails`, `TestRegisterNeitherInterfaceNamesPluginAndName`.
- **Finding:** Empty name, nil guard, a type implementing neither `Guard` nor `CredentialGuard`, a duplicate name, and an unknown `Middleware` lookup all return a `bouncer: ...` error naming plugin and guard. No silent no-op auth.
- **Disposition:** closed / mitigate.
### T-06-02 / T-06-10 — jwt and inv_token groups are mutually exclusive
- **Source:** `plugins/golem15/fonoteka/routes.go`; `surf/routetable.go` `Routes()`.
- **Test evidence:** `TestFullRouteTableAuthGroupMutualExclusivity` walks the real `BuildRouter` table for `golem15.user` + `golem15.fonoteka`. Zero `/api/v1/fonoteka*` entries carry `jwt.auth`; zero `/_fonoteka/api/v1*` entries carry `inv_token` or `inv.scope:*`. `TestGenresSharedHandler` proves both groups reach the same handler through different guards.
- **Finding:** Plan 06-01's partial coverage (two groups never sharing a middleware list literal) is completed over the whole assembled table, not the genres pair alone.
- **Disposition:** closed / mitigate.
### T-06-03 — ApiToken.TokenHash serialization (accept)
- **Rationale (verbatim from 06-01):** Already hidden via json:"-" and Hidden() (Phase 5, verified by 05-06's hidden-marshal test); this plan adds no new serialization path for the hash.
- **Supporting evidence:** `classes/hidden_marshal_test.go:TestHiddenNeverMarshals` / `TestSecretColumnNames` (`token_hash` is a secret column). Phase 6 added no marshal path.
- **Disposition:** closed / accept.
### T-06-04 — last_used stamp without logging the bearer
- **Source:** `plugins/golem15/fonoteka/classes/auth/token_guard.go` (`UpdateColumns` of `last_used_at` / `last_used_ip` only).
- **Test evidence:** `TestTokenGuard` / `valid-stamps-once` asserts one stamp per `AuthenticateCredential` call.
- **Grep:** `rg -n 'fmt\.(Print\|Printf\|Println)\|log\.(Print\|Printf\|Println\|Fatal)\|slog\.'` over `fonoteka.go/plugins/golem15/fonoteka/classes/auth` and `summercms.go/bouncer` returns no matches. `LastUsedIP` appears only as the DB column write and test assertions. `bearerToken` is local; the raw bearer is hashed then discarded. `bouncer.Credential` call sites are InvScope (type-assert + HasScope) and the `fonoteka-api-token` bucket key (`tok:<id>`), never a log line.
- **Disposition:** closed / mitigate.
### T-06-05 — SHA-256 hash lookup timing (accept)
- **Rationale (verbatim from 06-01):** Indexed equality lookup (not a byte-for-byte secret compare) is not a timing side-channel per RESEARCH.md's V6 Cryptography note; crypto/subtle is reserved for a future raw-compare path (e.g. OAuth client secrets, Phase 8), not needed here.
- **Disposition:** closed / accept.
### T-06-06 — X-Forwarded-For spoofing
- **Source:** `surf/clientip.go`.
- **Test evidence:** `TestClientIPRejectsSpoofedXFF` — untrusted `RemoteAddr` ignores XFF; `TestClientIPRightmostUntrustedHop` honors XFF only when RemoteAddr is inside `http.trusted_proxies`.
- **Disposition:** closed / mitigate.
### T-06-07 — public-share 429 body
- **Source:** `plugins/golem15/fonoteka/middleware/public_share_headers.go`.
- **Test evidence:** `TestPublicShareHeadersRewrites429` rewrites `{"message":"Too Many Attempts."}` to `{"error":"Too many requests"}` while preserving limiter headers and setting `X-Robots-Tag` / `Cache-Control`.
- **Disposition:** closed / mitigate.
### T-06-08 — MemoryStore cardinality (accept)
- **Rationale (verbatim from 06-02):** v1 ships an unbounded-until-swept map per CONTEXT D-03's explicit "no otter/cooler this phase" decision; the sweep goroutine bounds long-term growth to roughly one decay window's worth of distinct keys, acceptable for a single-instance v1 deployment.
- **Supporting evidence:** `surf/limiter_coverage_test.go:TestMemoryStoreSweepRemovesExpiredEntry` proves the sweep actually deletes expired entries (not only the lazy `TooManyAttempts` path).
- **Disposition:** closed / accept.
### T-06-09 — APP_DEBUG on recorded fixtures
- **Source:** `parity/php_parity.sh` `export APP_DEBUG=false`.
- **Test evidence:** `TestPHPParityPinsAppDebugFalse`.
- **Finding:** 06-02 audited three existing HTML-exception fixtures recorded under debug; they remain flagged for re-record and are not 429s. Future recordings are production-shaped.
- **Disposition:** closed / mitigate.
### T-06-11 — raw group cannot take house-envelope middleware
- **Source:** `surf/router.go` `wrap()`; `pact.HasHouseMiddleware`; `Plugin.HouseMiddlewares()`.
- **Test evidence:** `TestRawGroupHouseMiddlewareRefusedAtBuild`, `TestRawGroupRefusesHouseMiddlewareOnRealPlugins`, `TestHouseMiddlewareCapabilityOnRealPlugins`.
- **Grep:** `inv.must-change-password` appears in `plugin.go` only inside `HouseMiddlewares()` (line 75), never inside `Middlewares()`. Plugins do not call `RegisterHouseMiddleware` / `RegisterMiddleware`.
- **Disposition:** closed / mitigate.
### T-06-12 / T-06-13 — body limits
- **Correction (06-14):** the original proof only covered the terminal handler, so a named middleware running before the handler could read an unbounded body. See T-06-28 for the corrected proof.
- **Source:** `surf/bodylimit.go`; `fonoteka.go/config/http.yaml`.
- **Test evidence:** `TestBodyLimitDefaultRejectsOversizedBody` (MaxBytesReader 413 on non-raw); `TestBodyLimitRawExempt`; `TestProductionBodyLimitsOperatorConfirmed` (both keys 134217728, no INTERIM). Operator-confirmed 2026-09-19 from nginx `client_max_body_size=128M` and php.ini `post_max_size=128M` / `upload_max_filesize=128M`.
- **Disposition:** closed / mitigate.
### T-06-14 through T-06-18 — SSRF fetch helper
- **Source:** `fetchguard/ip.go`, `fetchguard/fetch.go`.
- **Test evidence:** private/reserved/CGNAT/metadata table (`TestIsReservedOrPrivate`); always-on dial-time block in both modes (`TestFetchPrivateIPBlockedInBothModes`); streaming cap (`TestFetchTooLargeIsStreaming`); no automatic redirects (`TestFetchDoesNotFollowRedirect`); dotted-suffix allow-list (`TestFetchAllowHostsRejectsDottedSuffixBypass`, `TestHostAllowedExactAndDottedSuffix`).
- **Disposition:** closed / mitigate.
### T-06-21 — unauthenticated personal-token traffic cannot bypass the limiter
- **Source:** `plugins/golem15/fonoteka/routes.go`, whose exact declaration is `inv_token` -> `throttle:fonoteka-api-token` -> `inv.scope:read`; `surf/router.go` applies that declaration last-to-first so the same sequence is the runtime onion.
- **Test evidence:** `TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` creates one fresh handler through `surf.Assemble` for the real `golem15.user` + `golem15.fonoteka` plugin set and drives 61 same-IP requests through `GET /api/v1/fonoteka/genres`.
- **Finding:** Requests 1-60 retain the PHP-compatible 401 `{"error":"Invalid token"}` response, proving `InvScope` still owns denial before exhaustion. Request 61 receives exactly `{"message":"Too Many Attempts."}` with exhausted `X-RateLimit-*` headers, proving missing credentials consume the 60/minute IP-fallback budget.
- **Disposition:** closed / mitigate.
### T-06-22 — valid credentials retain isolated token buckets
- **Source:** `plugins/golem15/fonoteka/routes.go`; `plugins/golem15/fonoteka/plugin.go` `fonoteka-api-token` key closure.
- **Test and invariant evidence:** The executed route keeps `inv_token` before `throttle:fonoteka-api-token`, while `TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` exercises the same assembled production middleware chain. The exact invariant is `inv_token` -> `throttle:fonoteka-api-token` -> `inv.scope:read`.
- **Finding:** A valid personal token populates `bouncer.Credential` before the limiter resolves its key, preserving `tok:<id>` keying. Only missing or invalid credentials fall back to `surf.ClientIP`; valid credentials do not collapse onto a shared IP budget.
- **Disposition:** closed / mitigate.
### T-06-23 — fixed-window admission is atomic under contention
- **Source:** `surf/limiter_store.go` (`Store.Attempt`, `MemoryStore.Attempt`); `surf/limiter.go` (`FixedWindowLimiter.Middleware`).
- **Test evidence:** `TestMemoryStoreConcurrentAttempt` and `TestFixedWindowLimiterConcurrentMaxOne` coordinate 32 workers behind ready/start barriers with `Max=1`.
- **Finding:** `Attempt` reads time once and performs lazy expiry, threshold comparison, the admitted increment, and retry-duration calculation while holding one mutex. Exactly one contender is admitted, the protected handler runs once, and the other 31 requests receive the exact 429 body without incrementing the exhausted counter.
- **Disposition:** closed / mitigate.
### T-06-24 — anonymous inline keys are server-controlled and domainless
- **Source:** `surf/limiter.go` (`FixedWindowLimiter.resolve`), whose anonymous signature is exactly `inline:domainless|<ClientIP>` and whose authenticated signature remains `u:<id>`.
- **Test evidence:** `TestFixedWindowLimiterInlineThrottleKeys/anonymous_same_IP_different_Host` proves Host rotation shares the exhausted bucket; `TestFixedWindowLimiterInlineThrottleKeys/anonymous_inline_policies_share_a_domainless_key` proves different inline throttle parameters from the same IP share one budget; `TestFixedWindowLimiterInlineThrottleKeys/principals_differ` proves authenticated principals retain independent `u:<id>` buckets.
- **Finding:** The anonymous key explicitly excludes the throttle `param`, `r.Host`, the `Forwarded` host parameter, and `X-Forwarded-Host`. Only the constant router-owned namespace and trusted-proxy-aware `ClientIP` participate, so neither policy text nor any request/forwarded Host input can rotate anonymous buckets.
- **Disposition:** closed / mitigate.
### T-06-25 — transition-address SSRF representations receive the IPv4 policy
- **Source:** `fetchguard/ip.go` (`isReservedOrPrivate`, `embeddedTransitionIPv4`); `fetchguard/fetch.go` (`dialControl`).
- **Test evidence:** `TestIsReservedOrPrivateIPv6Transitions` covers loopback, RFC1918, metadata, and public controls for RFC 6052 `64:ff9b::/96`, RFC 6052 local-use `64:ff9b:1::/48`, and RFC 3056 6to4 `2002::/16`, including fail-closed non-zero `/48` `u` octet handling. `TestDialControlRejectsUnsafeIPv6Transitions` proves all unsafe forms return `errPrivateIP` / `ReasonPrivateIP` at the production connection boundary before the raw connection is used.
- **Finding:** Supported transition formats extract an IPv4 value and recursively apply the ordinary IPv4 reserved/private table; public `8.8.8.8` controls remain allowed rather than blanket-blocking the prefixes.
- **Disposition:** closed / mitigate.
### T-06-26 — partial route output is discarded on panic
- **Source:** `surf/router.go` (`bufferedResponse`, `recoverJSON`, `recoverBare`).
- **Test evidence:** `TestRecoverDiscardsPartialResponse/house` and `TestRecoverDiscardsPartialResponse/raw` each write status 202, `X-Partial: secret`, and `secret-partial` before panicking. `TestBufferedResponseCommitsSuccessfulOutput` covers explicit status, repeated `WriteHeader`, implicit 200, headers, and body on success.
- **Finding:** Both recovery wrappers pass only the private buffer to the route. A panic discards buffered status, headers, and body: house returns exact `{"error":true,"message":"Internal server error"}` with status 500, while raw returns a header-clean, bodyless 500. A normal return commits once and replaces only route-owned header keys, preserving unrelated outer-wrapper headers.
- **Disposition:** closed / mitigate.
### T-06-27 — InvScope denial bytes match the PHP contract
- **Source:** `plugins/golem15/fonoteka/middleware/token_scope.go`, where both denial branches call `wire.WriteJSON`.
- **Test evidence:** `TestInvScope/no-user-401` compares raw bytes exactly to `{"error":"Invalid token"}`; `TestInvScope/missing-scope-403` compares raw bytes exactly to `{"error":"Missing required scope: write"}`. Both assert final `}` and reject every CR/LF byte before the secondary JSON-shape check.
- **Finding:** The prior `json.Encoder.Encode` newline is gone; status, Content-Type, and untrimmed body bytes are one locked response contract. The valid-scope path still reaches the handler, and the wrong-credential path remains fail-closed.
- **Disposition:** closed / mitigate.
### T-06-SC — OpenAPI toolchain packages (accept)
- **Rationale (verbatim from 06-03):** Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts) -- no additional human-verify checkpoint required beyond that prior audit.
- **Disposition:** closed / accept.
---
### T-06-28 — body cap bounds body-consuming named middleware
- **Source:** `surf/router.go` `wrap`: the `bodyLimit` wrapper is applied after all named/factory middleware, so it is outermost inside recovery.
- **Test evidence:** `TestBodyLimitBoundsBodyConsumingMiddleware` (default limit, `body.limit:N` override both raising and bounding, raw route unaffected, panic after read yields clean 500 without leaking the panic text); `TestBodyLimitBoundsNamedMiddleware`; `TestBodyLimitInvalidParamFailsBoot`.
- **Disposition:** closed / mitigate.
### T-06-29 — invalid limiter definitions fail closed
- **Source:** `surf/limiter.go`.
- **Test evidence:** `TestRegisterBucketRejectsInvalid` (nil Key, Max 0/-1, Decay 0/negative, nil store; errors name plugin and bucket); `TestValidateThrottleRejectsOverflowAndNilStore`; `TestMiddlewareFailsClosed` (misconfigured limiter answers 500 and never calls next).
- **Disposition:** closed / mitigate.
### T-06-30 — IANA special-use ranges classified non-public
- **Source:** `fetchguard/ip.go`.
- **Test evidence:** `TestIsReservedOrPrivateIANABoundaries` asserts first, last and interior address of every listed prefix are non-public, neighbours outside all ranges are public, and IPv4-mapped forms follow the IPv4 table. `isReservedOrPrivate` is at 100% statement coverage.
- **Disposition:** closed / mitigate.
### T-06-31 — zoned IPv6 cannot evade prefix checks
- **Source:** `fetchguard/ip.go` (zone stripped), `fetchguard/fetch.go` `dialControl` (zoned targets rejected).
- **Test evidence:** `TestIsReservedOrPrivateIgnoresZone`, `TestDialControlRejectsZonedAndSpecialUse` (`[fe80::1%eth0]`, 198.18.0.1, 192.0.0.1, 240.0.0.1 all `errPrivateIP`; public passes), `TestFetchPublicOnlyMapsSpecialUseToPrivateIP` (Fetch reason `private_ip`, no network I/O). `dialControl` is at 100% statement coverage.
- **Disposition:** closed / mitigate.
### T-06-32 — typed-nil guards rejected at registration
- **Test evidence:** `TestRegisterRejectsTypedNilGuard`, `TestRegisterRejectsTypedNilPointerFuncMapGuards`, `TestRegisterAcceptsValidGuards`.
- **Disposition:** closed / mitigate.
### T-06-33 — fractional or out-of-range JWT subject rejected
- **Test evidence:** `TestVerifyRejectsFractionalSubject`, `TestVerifySubjectMatrix` (12.5, 1e300, 2^60 float, -1, 0 rejected; 12 and "12" accepted), `TestSubjectJSONNumber`.
- **Disposition:** closed / mitigate.
### T-06-34 — route conflicts return errors, factories built once
- **Test evidence:** `TestCompileRouteConflictReturnsError` (no panic); `TestFactoriesBuiltOncePerName`. The latter initially failed: the 06-12 `built` cache was declared but never consulted, so factories ran once per route per pass. Fixed in Plan 06-14 commit 1d2e00c (cache keyed by `name:param`).
- **Disposition:** closed / mitigate.
### T-06-35 — missing body config no longer becomes zero
- **Test evidence:** `TestBuildRouterFailsOnMissingBodyConfig` (missing, zero, negative, non-numeric error; valid passes), `TestBodyLimitMissingConfigFailsBoot`.
- **Disposition:** closed / mitigate.
---
## Credential / bearer logging grep
`rg -n 'raw|bearer|LastUsedIP' fonoteka.go/plugins/golem15/fonoteka/classes/auth` (excluding tests): `bearerToken` helper, `LastUsedIP` column write in `UpdateColumns`, no adjacent `fmt.Print*` / `log.*` / `slog`. `summercms.go/bouncer` has no Print/log of the token. `bouncer.Credential` is read by InvScope and the named bucket key only.
## House-middleware registration grep
```
grep -n "inv.must-change-password" fonoteka.go/plugins/golem15/fonoteka/plugin.go
```
```
75: "inv.must-change-password": middleware.MustChangePassword,
```
That line is inside `HouseMiddlewares()`. `Middlewares()` registers `public.share-headers` and `inv_token` only. Plan 06-03's move onto `pact.HasHouseMiddleware` is the only registration path.
---
## Accepted Risks Log
Four accepts (06-05's "three" list omitted T-06-05, which 06-01 already accepted). Plans 06-06 through 06-14 add mitigated threats only and no new accepts. Rationales are copied verbatim in the Threat Register `Proof` column for each accept row.
## Post-Gap Verification Gates
Final gates (Plan 06-14, 2026-09-21): both `go vet ./...` and `go test ./... -count=1 -race -short` passed in `summercms.go` and `fonoteka.go`.
- `summercms.go`: `go test ./... -count=1 -race -short` — pass; `go vet ./...` — pass.
- `fonoteka.go`: `go test ./... -count=1 -race -short` — pass; `go vet ./...` — pass.
- Source assertion: anonymous inline keys contain `inline:domainless|<ClientIP>` and no throttle-parameter or request/forwarded-Host contribution — pass.
- Evidence assertion: exactly one Threat Register row and one substantive finding exist for each of T-06-23 through T-06-27 — pass.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-09-19 | 19 | 19 | 0 | gsd-executor (06-05) |
| 2026-09-20 | 21 | 21 | 0 | gsd-executor (06-06) |
| 2026-09-21 | 26 | 26 | 0 | gsd-executor (06-11 post-gap refresh) -- SUPERSEDED, contradicted by verification |
| 2026-09-21 | 34 | 34 | 0 | gsd-executor (06-14 reopen and re-close; vet + race tests green in both repos) |