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

31 KiB

phase, slug, status, threats_total, threats_closed, threats_open, accepted_risks, asvs_level, created, verified, reopened, reverified
phase slug status threats_total threats_closed threats_open accepted_risks asvs_level created verified reopened reverified
06 http-routing-auth-groups-and-rate-limiting verified 34 34 0 4 1 2026-09-19 2026-09-21 2026-09-21 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
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)