16 KiB
phase, slug, status, threats_open, asvs_level, created, verified
| phase | slug | status | threats_open | asvs_level | created | verified |
|---|---|---|---|---|---|---|
| 06 | http-routing-auth-groups-and-rate-limiting | verified | 0 | 1 | 2026-09-19 | 2026-09-20 |
Phase 6 — Security Review
Guard registry, dual-group auth, rate limiting, raw-group house-middleware refusal, CORS/body-limit scoping, and the SSRF fetch helper. Every
T-06-01throughT-06-18,T-06-21,T-06-22, andT-06-SCfrom Plans 06-01 through 06-06 is mapped below to a named passing test or a restated accept rationale. Unmapped IDs are a review gap, not an accepted risk.
Date: 2026-09-20
Scope: Plans 06-01 through 06-06 (implementation, coverage, gap closure, and this review).
Repos grepped: summercms.go and fonoteka.go (excluding .planning/ and vendor/).
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> |
| 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 |
| 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 |
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-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: closed. Disposition copied verbatim from the originating plan. Accept rationales copied 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
GuardnorCredentialGuard, a duplicate name, and an unknownMiddlewarelookup all return abouncer: ...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.goRoutes(). - Test evidence:
TestFullRouteTableAuthGroupMutualExclusivitywalks the realBuildRoutertable forgolem15.user+golem15.fonoteka. Zero/api/v1/fonoteka*entries carryjwt.auth; zero/_fonoteka/api/v1*entries carryinv_tokenorinv.scope:*.TestGenresSharedHandlerproves 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_hashis 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(UpdateColumnsoflast_used_at/last_used_iponly). - Test evidence:
TestTokenGuard/valid-stamps-onceasserts one stamp perAuthenticateCredentialcall. - Grep:
rg -n 'fmt\.(Print\|Printf\|Println)\|log\.(Print\|Printf\|Println\|Fatal)\|slog\.'overfonoteka.go/plugins/golem15/fonoteka/classes/authandsummercms.go/bouncerreturns no matches.LastUsedIPappears only as the DB column write and test assertions.bearerTokenis local; the raw bearer is hashed then discarded.bouncer.Credentialcall sites are InvScope (type-assert + HasScope) and thefonoteka-api-tokenbucket 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— untrustedRemoteAddrignores XFF;TestClientIPRightmostUntrustedHophonors XFF only when RemoteAddr is insidehttp.trusted_proxies. - Disposition: closed / mitigate.
T-06-07 — public-share 429 body
- Source:
plugins/golem15/fonoteka/middleware/public_share_headers.go. - Test evidence:
TestPublicShareHeadersRewrites429rewrites{"message":"Too Many Attempts."}to{"error":"Too many requests"}while preserving limiter headers and settingX-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:TestMemoryStoreSweepRemovesExpiredEntryproves the sweep actually deletes expired entries (not only the lazyTooManyAttemptspath). - Disposition: closed / accept.
T-06-09 — APP_DEBUG on recorded fixtures
- Source:
parity/php_parity.shexport 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.gowrap();pact.HasHouseMiddleware;Plugin.HouseMiddlewares(). - Test evidence:
TestRawGroupHouseMiddlewareRefusedAtBuild,TestRawGroupRefusesHouseMiddlewareOnRealPlugins,TestHouseMiddlewareCapabilityOnRealPlugins. - Grep:
inv.must-change-passwordappears inplugin.goonly insideHouseMiddlewares()(line 75), never insideMiddlewares(). Plugins do not callRegisterHouseMiddleware/RegisterMiddleware. - Disposition: closed / mitigate.
T-06-12 / T-06-13 — body limits
- 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 nginxclient_max_body_size=128Mand php.inipost_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 isinv_token->throttle:fonoteka-api-token->inv.scope:read;surf/router.goapplies that declaration last-to-first so the same sequence is the runtime onion. - Test evidence:
TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimitedcreates one fresh handler throughsurf.Assemblefor the realgolem15.user+golem15.fonotekaplugin set and drives 61 same-IP requests throughGET /api/v1/fonoteka/genres. - Finding: Requests 1-60 retain the PHP-compatible 401
{"error":"Invalid token"}response, provingInvScopestill owns denial before exhaustion. Request 61 receives exactly{"message":"Too Many Attempts."}with exhaustedX-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.gofonoteka-api-tokenkey closure. - Test and invariant evidence: The executed route keeps
inv_tokenbeforethrottle:fonoteka-api-token, whileTestPersonalTokenGenresUnauthenticatedRequestsAreRateLimitedexercises the same assembled production middleware chain. The exact invariant isinv_token->throttle:fonoteka-api-token->inv.scope:read. - Finding: A valid personal token populates
bouncer.Credentialbefore the limiter resolves its key, preservingtok:<id>keying. Only missing or invalid credentials fall back tosurf.ClientIP; valid credentials do not collapse onto a shared IP budget. - 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.
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). Plan 06-06 adds two mitigated threats and no new accepts. Rationales are copied verbatim in the Threat Register Proof column for each accept row.
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) |