From ad726b07fd6fabfcd24d875e50fa97fb5e8f2f7b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 12 Oct 2026 16:39:03 +0200 Subject: [PATCH] docs(phase-06): add security threat verification --- .../06-SECURITY.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY.md diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY.md new file mode 100644 index 0000000..f1978cd --- /dev/null +++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY.md @@ -0,0 +1,139 @@ +--- +phase: "06" +slug: "http-routing-auth-groups-and-rate-limiting" +status: verified +# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate) +threats_open: 0 +threats_total: 38 +threats_closed: 38 +accepted_risks: 4 +asvs_level: 1 +block_on: high +register_authored_at_plan_time: true +created: "2026-10-12" +--- + +# Phase 06 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. + +Detailed per-threat findings, grep evidence and the reopen history (Plan 06-11's verdict superseded by 06-12 to 06-14) live in `06-SECURITY-REVIEW.md`. This file is the gate contract derived from all fourteen `` blocks. Framework paths are relative to `summercms.go/modules/`; app paths are relative to `fonoteka.go/`. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| client → Authorization header | untrusted JWT or `inv_` bearer parsed on every request | raw token, token hash, user id (high) | +| guard registry → plugin Boot | plugin-declared guard names become live auth middleware | guard names, guard values | +| inv_token guard → api_tokens table | hash-indexed lookup of an untrusted bearer | token_hash, scopes, expiry, revocation (high) | +| client → X-Forwarded-For / limiter keys | untrusted IP, token id, route param feed the limiter store | RemoteAddr, XFF, `tok:` | +| request metadata → anonymous inline key | throttle text and Host inputs must not select a fresh budget | `inline:domainless`, trusted ClientIP | +| public-share group → anonymous caller | zero-credential surface; errors must not leak internals | 429 body, cache/robots headers | +| raw group → house middleware | RFC/OAuth surface must never inherit the house envelope | middleware names | +| request body → middleware and handler | unbounded body is a resource-exhaustion vector | request body bytes | +| handler/middleware → client response | partial output must not escape on panic | status, headers, body | +| caller URL / DNS answer → outbound dial | SSRF to loopback, RFC1918, CGNAT, metadata, special-use, transition and zoned addresses | dial-time IP, host allow-list (high) | +| personal-token context → denial serializer | 401/403 bytes are part of the PHP compatibility contract | exact JSON bytes | +| test evidence → security sign-off | verdict must follow executed proof | review document, gate output | + +--- + +## Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status | +|-----------|----------|-----------|----------|-------------|------------|--------| +| T-06-01 | Spoofing | bouncer.Registry.Register | high | mitigate | duplicate/unknown/neither-interface guards fail boot; `bouncer/registry_test.go:TestDuplicateGuardNameFailsWithPluginAndName`, `TestUnknownGuardNameFails`, `TestRegisterNeitherInterfaceNamesPluginAndName` | closed | +| T-06-02 | Elevation of Privilege | jwt vs inv_token groups | high | mitigate | `plugins/golem15/fonoteka/routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity`; `routes_group_test.go:TestGenresSharedHandler` | closed | +| T-06-03 | Information Disclosure | ApiToken.TokenHash | medium | accept | hidden via `json:"-"`/Hidden(); `classes/hidden_marshal_test.go:TestHiddenNeverMarshals` | closed | +| T-06-04 | Repudiation | last_used stamping | low | mitigate | single UpdateColumns, no bearer logging; `classes/auth/token_guard_test.go:TestTokenGuard` | closed | +| T-06-05 | Tampering | SHA-256 hash lookup timing | low | accept | indexed equality lookup, not a secret byte compare | closed | +| T-06-06 | Denial of Service | surf.ClientIP | medium | mitigate | XFF honored only from `http.trusted_proxies`; `surf/clientip_test.go:TestClientIPRejectsSpoofedXFF` | closed | +| T-06-07 | Information Disclosure | public-share 429 | medium | mitigate | fixed JSON body + no-index/no-store; `middleware/public_share_headers_test.go:TestPublicShareHeadersRewrites429` | closed | +| T-06-08 | Denial of Service | MemoryStore cardinality | medium | accept | sweep bounds growth (D-03); `surf/limiter_coverage_test.go:TestMemoryStoreSweepRemovesExpiredEntry` | closed | +| T-06-09 | Repudiation | parity fixtures under APP_DEBUG | low | mitigate | `parity/php_debug_test.go:TestPHPParityPinsAppDebugFalse` | closed | +| T-06-10 | Elevation of Privilege | full route-table exclusivity | high | mitigate | `routes_isolation_test.go:TestFullRouteTableAuthGroupMutualExclusivity` over real `Router.Routes()` | closed | +| T-06-11 | Tampering | raw-group house-middleware refusal | medium | mitigate | `surf/routetable_test.go:TestRawGroupHouseMiddlewareRefusedAtBuild`; `routes_cors_test.go:TestRawGroupRefusesHouseMiddlewareOnRealPlugins`, `TestHouseMiddlewareCapabilityOnRealPlugins` | closed | +| T-06-12 | Denial of Service | unbounded request body | high | mitigate | MaxBytesReader on non-raw routes; `surf/bodylimit_test.go:TestBodyLimitDefaultRejectsOversizedBody`, `TestBodyLimitRawExempt` (middleware gap closed by T-06-28) | closed | +| T-06-13 | Information Disclosure | body limits shipped as a guess | low | mitigate | operator-confirmed 128M; `http_config_test.go:TestProductionBodyLimitsOperatorConfirmed` | closed | +| T-06-14 | Elevation of Privilege | SSRF to internal/metadata | critical | mitigate | dial-time Control in both modes; `fetchguard/fetch_test.go:TestFetchPrivateIPBlockedInBothModes`; `fetchguard/ip_test.go:TestIsReservedOrPrivate` | closed | +| T-06-15 | Tampering | DNS rebinding | high | mitigate | check runs on the connected address; `TestFetchPrivateIPBlockedInBothModes` | closed | +| T-06-16 | Denial of Service | unbounded response body | medium | mitigate | streaming LimitReader + timeout; `fetchguard/fetch_test.go:TestFetchTooLargeIsStreaming` | closed | +| T-06-17 | Elevation of Privilege | redirect to private address | high | mitigate | no automatic redirects; `fetchguard/fetch_test.go:TestFetchDoesNotFollowRedirect` | closed | +| T-06-18 | Spoofing | dotted-suffix allow-list bypass | high | mitigate | `TestFetchAllowHostsRejectsDottedSuffixBypass`; `fetchguard/fetch_coverage_test.go:TestHostAllowedExactAndDottedSuffix` | closed | +| T-06-19 | Repudiation | security review completeness | low | mitigate | `06-SECURITY-REVIEW.md` maps every implementation ID; this file maps all 38 | closed | +| T-06-20 | Tampering | coverage gaps hiding defects | low | mitigate | 06-05 coverage tests (`*_coverage_test.go`); 06-14 regression tests | closed | +| T-06-21 | Denial of Service | personal-token genres chain | high | mitigate | `inv_token` → `throttle` → `inv.scope`; `routes_isolation_test.go:TestPersonalTokenGenresUnauthenticatedRequestsAreRateLimited` | closed | +| T-06-22 | Denial of Service | fonoteka-api-token key selection | medium | mitigate | credential resolved before key closure (`tok:`); same test + route-order invariant | closed | +| T-06-23 | Denial of Service | Store / FixedWindowLimiter | high | mitigate | atomic Attempt; `surf/limiter_test.go:TestMemoryStoreConcurrentAttempt`, `TestFixedWindowLimiterConcurrentMaxOne` | closed | +| T-06-24 | Denial of Service | inline anonymous key | high | mitigate | `inline:domainless|`; `surf/limiter_test.go:TestFixedWindowLimiterInlineThrottleKeys` | closed | +| T-06-25 | Elevation of Privilege | IPv6 transition SSRF | critical | mitigate | NAT64/6to4 decode; `fetchguard/ip_test.go:TestIsReservedOrPrivateIPv6Transitions`; `fetch_test.go:TestDialControlRejectsUnsafeIPv6Transitions` | closed | +| T-06-26 | Information Disclosure | panic recovery | medium | mitigate | buffered commit; `surf/router_test.go:TestRecoverDiscardsPartialResponse`, `TestBufferedResponseCommitsSuccessfulOutput` | closed | +| T-06-27 | Tampering | InvScope 401/403 bytes | medium | mitigate | `wire.WriteJSON`; `middleware/token_scope_test.go:TestInvScope` | closed | +| T-06-28 | Denial of Service | body limit vs named middleware | high | mitigate | bodyLimit outermost; `surf/bodylimit_test.go:TestBodyLimitBoundsBodyConsumingMiddleware`, `TestBodyLimitBoundsNamedMiddleware`, `TestBodyLimitInvalidParamFailsBoot` | closed | +| T-06-29 | Denial of Service | limiter definitions | high | mitigate | fail closed; `surf/limiter_test.go:TestRegisterBucketRejectsInvalid`, `TestValidateThrottleRejectsOverflowAndNilStore`, `TestMiddlewareFailsClosed` | closed | +| T-06-30 | Elevation of Privilege | IANA special-use ranges | critical | mitigate | `fetchguard/ip_test.go:TestIsReservedOrPrivateIANABoundaries`, `TestIsReservedOrPrivateSpecialUseSmoke` | closed | +| T-06-31 | Elevation of Privilege | zoned IPv6 dial | high | mitigate | `ip_test.go:TestIsReservedOrPrivateIgnoresZone`; `fetch_test.go:TestDialControlRejectsZonedAndSpecialUse`, `TestFetchPublicOnlyMapsSpecialUseToPrivateIP` | closed | +| T-06-32 | Denial of Service | typed-nil guard | medium | mitigate | `bouncer/registry_test.go:TestRegisterRejectsTypedNilGuard`, `TestRegisterRejectsTypedNilPointerFuncMapGuards`, `TestRegisterAcceptsValidGuards` | closed | +| T-06-33 | Spoofing | JWT numeric subject | high | mitigate | `bouncer/jwt_test.go:TestVerifyRejectsFractionalSubject`, `TestVerifySubjectMatrix`, `TestSubjectJSONNumber` | closed | +| T-06-34 | Denial of Service | ServeMux conflict panic | medium | mitigate | `surf/bodylimit_test.go:TestCompileRouteConflictReturnsError`; `surf/router_test.go:TestFactoriesBuiltOncePerName` | closed | +| T-06-35 | Denial of Service | missing body_limits config | medium | mitigate | `surf/router_test.go:TestBuildRouterFailsOnMissingBodyConfig`; `surf/bodylimit_test.go:TestBodyLimitMissingConfigFailsBoot` | closed | +| T-06-36 | Repudiation | 06-SECURITY-REVIEW.md verdict | low | mitigate | verdict follows 06-14 gate output; superseded 06-11 row retained; re-checked by this audit | closed | +| T-06-37 | Tampering | test suite loosening checks | low | mitigate | 06-14 reported the factory-cache defect and fixed it (1d2e00c) instead of masking it | closed | +| T-06-SC | Tampering | package supply chain | low | accept | swag/openapi-typescript passed 06-RESEARCH package audit; 06-06 to 06-14 add no dependencies | 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)* + +Severities were not recorded in the plans; they are assigned in this audit. No threat is open, so the assignment does not affect the gate. + +--- + +## Accepted Risks Log + +| Risk ID | Threat Ref | Rationale | Accepted By | Date | +|---------|------------|-----------|-------------|------| +| AR-06-01 | T-06-03 | 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 | Plan 06-01 | 2026-09-19 | +| AR-06-02 | T-06-05 | 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 | Plan 06-01 | 2026-09-19 | +| AR-06-03 | T-06-08 | 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 | Plan 06-02 | 2026-09-19 | +| AR-06-04 | T-06-SC | Both packages are STACK.md-named and pass 06-RESEARCH.md's Package Legitimacy Audit (Approved disposition, no [ASSUMED]/[SUS] verdicts); later gap plans add no dependencies | Plan 06-03 | 2026-09-19 | + +*Accepted risks do not resurface in future audit runs.* + +--- + +## 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), superseded | +| 2026-09-21 | 34 | 34 | 0 | gsd-executor (06-14) | +| 2026-10-12 | 38 | 38 | 0 | msd-secure-phase (L1, auditor skipped by short-circuit) | + +### Security Audit 2026-10-12 + +| Metric | Count | +|--------|-------| +| Threats found | 38 | +| Closed | 38 | +| Open | 0 | + +- Register built from all 14 PLAN `` blocks. It adds T-06-19, T-06-20, T-06-36 and T-06-37 (process threats from 06-05 and 06-14), which `06-SECURITY-REVIEW.md` did not list. +- SUMMARY threat flags: only 06-10 has one ("None"). +- All 53 cited test functions were found by grep at their current paths (framework packages now live under `modules/`). +- `go test -short -count=1` passed for `modules/bouncer`, `modules/surf` and `modules/fetchguard` in summercms.go, and for `./plugins/golem15/fonoteka/...`, `./parity` and the root package in fonoteka.go. + +--- + +## 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 + +**Approval:** verified 2026-10-12