diff --git a/.planning/STATE.md b/.planning/STATE.md index b3ae1ce..2745910 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,14 +4,14 @@ milestone: v1.0 milestone_name: milestone status: executing stopped_at: Completed 06-11-PLAN.md -last_updated: "2026-09-21T11:04:40.366Z" -last_activity: 2026-09-21 +last_updated: "2026-09-21T17:30:49.586Z" +last_activity: 2026-09-21 -- Phase 06 planning complete progress: total_phases: 15 - completed_phases: 6 - total_plans: 34 + completed_phases: 5 + total_plans: 37 completed_plans: 34 - percent: 40 + percent: 33 --- # Project State @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16) Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING Plan: 11 of 11 -Status: Execution complete — ready to verify -Last activity: 2026-09-21 +Status: Ready to execute +Last activity: 2026-09-21 -- Phase 06 planning complete Progress: [██████████] 100% diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-PLAN.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-PLAN.md new file mode 100644 index 0000000..21d0f00 --- /dev/null +++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-PLAN.md @@ -0,0 +1,174 @@ +--- +phase: 06-http-routing-auth-groups-and-rate-limiting +plan: 12 +type: execute +wave: 1 +depends_on: [] +files_modified: + - surf/router.go + - surf/bodylimit.go + - surf/limiter.go + - bouncer/registry.go + - bouncer/jwt.go +autonomous: true +gap_closure: true +requirements: [HTTP-04, HTTP-09, HTTP-05, HTTP-06] + +must_haves: + truths: + - "A named middleware that reads r.Body on a non-raw route cannot read more than the selected body limit (default or body.limit:N override); raw routes remain unlimited" + - "The body limit sits inside recoverJSON/recoverBare and outside every named/factory middleware" + - "RegisterBucket fails boot on nil Key, Max < 1, Decay <= 0, or a limiter with a nil store" + - "Inline throttle minutes that overflow time.Duration fail boot; Middleware no longer passes traffic through when Key/store/resolution is missing" + - "Missing or non-positive http.body_limits.default_bytes / upload_bytes config fails BuildRouter instead of becoming 0" + - "A ServeMux semantic route conflict is returned as an error from compile/Assemble, never a panic" + - "Middleware factories are constructed once per distinct name:param during BuildRouter+compile" + - "Typed-nil guards are rejected by bouncer Registry.Register; a fractional or non-finite JWT numeric subject is rejected" + artifacts: + - path: surf/router.go + provides: "Corrected wrap ordering, factory cache, body-config validation, panic-to-error mux registration" + - path: surf/limiter.go + provides: "Fail-closed limiter definition validation" + - path: bouncer/registry.go + provides: "Typed-nil guard rejection" + - path: bouncer/jwt.go + provides: "Integer-only numeric subject" + key_links: + - from: surf/router.go + to: surf/bodylimit.go + via: "bodyLimit applied after the named-middleware loop, before locale/recover" + pattern: "bodyLimit\\(" + - from: surf/router.go + to: surf/limiter.go + via: "RegisterBucket errors propagate from BuildRouter" + pattern: "RegisterBucket" +--- + + +Close the surf/bouncer verification gaps: body cap must bound body-consuming middleware (HTTP-09), invalid limiter definitions must fail boot rather than fail open (HTTP-04), plus the five verification warnings. + +Purpose: shared infrastructure must fail closed. Output: edits to surf/router.go, surf/limiter.go, bouncer/registry.go, bouncer/jwt.go. Comprehensive tests are Plan 06-14; this plan only fixes existing tests that the stricter validation legitimately breaks and may add one smoke test per fix. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@.planning/STATE.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md +@surf/router.go +@surf/bodylimit.go +@surf/limiter.go +@bouncer/registry.go +@bouncer/jwt.go + + + + + + Task 1: Router ordering, factory cache, body-config validation, mux conflict error (surf/router.go) + surf/router.go, surf/bodylimit.go + surf/router.go (wrap lines 353-401, compile 338-351, BuildRouter 414-495), surf/bodylimit.go, compass/config.go (Lookup, Int at ~113-140), 06-VERIFICATION.md gaps 1 and warnings + +In Router.wrap, move the bodyLimit application: remove it from directly around the terminal handler and apply it after the named-middleware loop finishes (so it is the outermost wrapper of all named/factory middleware) but before locale(h) and the recoverJSON/recoverBare wrapping (per D-verification gap 1, so panics inside body-consuming middleware are still recovered and raw routes still get limit 0 from routeBodyLimit). Keep orgSlot and constrain innermost. Update the comment in the body.limit factory registration in BuildRouter to say the limit is applied outermost-inside-recovery in wrap(). + +Build middleware factories once: add a per-Router cache map keyed by the full "base:param" name holding the constructed pact.Middleware (initialize in New). In wrap, look up the cache before calling factory.fn(param); store after first construction. BuildRouter's validation pass and compile's second wrap then reuse the same instances. Validation (ValidateThrottle, parseBodyLimit) still runs for every route. + +Body config fail-boot: in BuildRouter when app != nil and app.Config != nil, read http.body_limits.default_bytes and http.body_limits.upload_bytes with Config.Lookup; if either is absent, not numeric, or converts to a value < 1, return an error naming the key (do not fall through to zero). Convert via a small helper that accepts int, int64, uint64 and float64 whole values. When app or app.Config is nil keep the existing zero (no config source at all) behaviour used by unit tests. If existing tests build a Config without these keys, add the two keys to those test configs rather than weakening the check. + +Route conflict: in compile, register each route through a helper that defers recover() around mux.Handle and returns fmt.Errorf("surf: route conflict for %s (plugin %q): %v", ...). compile then returns that error instead of panicking. + + + go vet ./surf/... && go test ./surf/... -count=1 -short + + + - grep of surf/router.go shows `bodyLimit(` is called after the `for i := len(rt.middleware) - 1` loop and before `h = locale(h)` + - A quick smoke test (added to surf/bodylimit_test.go) with limit 4, named middleware doing io.ReadAll(r.Body) and a 10-byte body observes a read error (http.MaxBytesError) inside the middleware + - compile of two semantically conflicting patterns returns a non-nil error and does not panic + - BuildRouter with a Config lacking http.body_limits.default_bytes returns an error containing "default_bytes" + - `go vet ./surf/...` and `go test ./surf/... -short` exit 0 + + Cap bounds all named middleware; factories built once; missing body config and mux conflicts are boot errors. + + + + Task 2: Fail-closed limiter definitions (surf/limiter.go) + surf/limiter.go + surf/limiter.go, surf/limiter_store.go, surf/limiter_test.go, surf/limiter_coverage_test.go (find tests that register nil-Key or zero-decay buckets or pass a nil store) + +RegisterBucket: after existing name check, return errors naming plugin and bucket when l.store == nil, b.Key == nil, b.Max < 1, or b.Decay <= 0. ValidateThrottle: also error when l.store == nil. In resolve, before multiplying the inline minutes, reject m greater than math.MaxInt64 / int64(time.Minute) (import math) with a "malformed throttle" style error, so the Duration conversion cannot overflow; also keep n<1 / m<1 rejection. + +Middleware: remove every pass-through. When l is nil, resolve returns an error, or Key/store is nil at construction, return a middleware whose handler writes a 500 (http.StatusInternalServerError, empty body via WriteHeader only) and never calls next. This is unreachable at boot because ValidateThrottle/RegisterBucket reject the same states, but must be fail-closed if reached. Do not change the 429 wire format or headers. + +Fix existing tests only where they registered now-invalid buckets or relied on pass-through, by giving them a real Key/Max/Decay and a real store (do not weaken validation). Add one smoke test asserting RegisterBucket rejects a zero-decay bucket. + + + go vet ./surf/... && go test ./surf/... -count=1 -race -short + + + - `RegisterBucket("p","n",Bucket{Max:1,Decay:0,Key:k})` returns non-nil error (smoke test) + - `grep -n "next.ServeHTTP" surf/limiter.go` shows only the post-Attempt admitted call + - Inline throttle "1,9223372036854775807" fails ValidateThrottle + - The 429 exact body/header tests already in the suite still pass + + No limiter state can silently disable enforcement. + + + + Task 3: Typed-nil guard rejection and integer-only JWT subject (bouncer) + bouncer/registry.go, bouncer/jwt.go + bouncer/registry.go, bouncer/jwt.go lines 120-186, bouncer/registry_test.go, bouncer/jwt_test.go + +registry.go Register: after the g == nil check, use reflect (stdlib) to reject any g whose reflect.ValueOf kind is Pointer, Map, Slice, Func, Chan or Interface and IsNil() is true, returning the existing "registered empty guard" style error naming plugin and guard. + +jwt.go subject: for float64 reject NaN, Inf, v <= 0, v != math.Trunc(v), or v > 9007199254740992 (2^53) by returning "" ; otherwise format as int64. For json.Number reject values that do not parse with strconv.ParseInt as a positive integer (return ""); string branch unchanged. Existing legacy-equivalence tests for whole-number float subjects must keep passing. + + + go vet ./bouncer/... && go test ./bouncer/... -count=1 -race -short + + + - Registering a typed-nil pointer implementing Guard returns an error (smoke test) + - A token with sub 12.5 fails authentication; sub 12 (float64) still resolves user 12 + - `go test ./bouncer/... -short` exits 0 + + Typed-nil guards and fractional subjects are rejected. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client body -> named middleware | untrusted body stream read by auth/plugin middleware before any handler | +| plugin bucket definitions -> limiter | plugin-declared Max/Decay/Key define whether a security control enforces | +| signed JWT claim -> user id | numeric subject converted to a lookup key | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-28 | Denial of Service | surf/router.go wrap | mitigate | bodyLimit outermost of named middleware, inside recovery; regression in 06-14 | +| T-06-29 | Denial of Service / Elevation | surf/limiter.go | mitigate | RegisterBucket/ValidateThrottle reject nil store/Key, Max<1, Decay<=0, overflow; Middleware fails closed | +| T-06-32 | Denial of Service | bouncer/registry.go | mitigate | reflect-based typed-nil rejection at Register | +| T-06-33 | Spoofing | bouncer/jwt.go subject | mitigate | reject fractional/non-finite/oversized numeric sub | +| T-06-34 | Denial of Service | surf/router.go compile | mitigate | recover ServeMux conflict panic into returned error | +| T-06-35 | Denial of Service | surf/router.go BuildRouter | mitigate | missing/malformed body_limits config fails boot | + + + +`go vet ./... && go test ./... -count=1 -race -short` green in summercms.go; the sibling ../fonoteka.go `go test ./... -short` still green (its buckets in plugin.go must satisfy the new validation). + + + +All must_haves truths hold; both repositories build and test green; commit is code only (no planning docs), no co-author tags. + + + +Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-SUMMARY.md` when done + diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-PLAN.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-PLAN.md new file mode 100644 index 0000000..55e0b50 --- /dev/null +++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-PLAN.md @@ -0,0 +1,118 @@ +--- +phase: 06-http-routing-auth-groups-and-rate-limiting +plan: 13 +type: execute +wave: 1 +depends_on: [] +files_modified: + - fetchguard/ip.go + - fetchguard/fetch.go +autonomous: true +gap_closure: true +requirements: [HTTP-07] + +must_haves: + truths: + - "Every IANA special-use IPv4 and IPv6 non-public range in the plan table is classified non-public, including 198.18.0.0/15, 192.0.0.0/24 and 240.0.0.0/4" + - "Zoned IPv6 addresses (for example fe80::1%eth0) are rejected with private_ip at dial time and classify identically to their unzoned form" + - "Public controls (8.8.8.8, 1.1.1.1, 2606:4700:4700::1111) remain allowed" + - "Existing NAT64/6to4 embedded-IPv4 recursion still works" + artifacts: + - path: fetchguard/ip.go + provides: "Complete special-use prefix tables and zone-stripping classifier" + - path: fetchguard/fetch.go + provides: "Dial-time rejection of zoned addresses" + key_links: + - from: fetchguard/fetch.go + to: fetchguard/ip.go + via: "dialControl -> isReservedOrPrivate" + pattern: "isReservedOrPrivate" +--- + + +Close the HTTP-07 SSRF gap: replace the partial PHP-literal table with the full IANA special-use non-public set and stop zoned IPv6 from evading Prefix.Contains. + +Purpose: the outbound fetch helper must reject every non-public destination at the actual dial boundary. Output: edits to fetchguard/ip.go and fetchguard/fetch.go. Full boundary tests are Plan 06-14; add only a small smoke test here. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md +@fetchguard/ip.go +@fetchguard/fetch.go +@fetchguard/ip_test.go + + + + + + Task 1: Complete non-public prefix tables and zone-stripping classifier (fetchguard/ip.go) + fetchguard/ip.go + fetchguard/ip.go, fetchguard/ip_test.go (existing table, must stay green), 06-VERIFICATION.md truth 4 + +Keep the existing variable names privateV4 / privateV6 and the transition handling. Extend privateV4 to the full IANA IPv4 special-purpose set: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 (240/4 also covers 255.255.255.255). Extend privateV6 to: ::/96 (unspecified plus deprecated IPv4-compatible, includes ::1), 100::/64, 2001::/23 (IETF protocol assignments incl. Teredo and ORCHID), 2001:db8::/32, 3fff::/20, 5f00::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8. Update the doc comment: it is no longer a literal PHP port but a strict superset of ManualCoverUrlFetcher.php's lists, chosen per 06-VERIFICATION gap 3. + +In isReservedOrPrivate, immediately after the IsValid check, strip any IPv6 zone with addr.WithZone("") so zone text never changes Prefix.Contains results, then Unmap as before. Keep the recursive embedded-IPv4 path (it receives the unzoned address). Keep the IsMulticast/IsUnspecified shortcuts. 2002::/16 and 64:ff9b::/96 stay handled by embeddedTransitionIPv4 (public embedded IPv4 remains allowed), so do not add them to the tables. + + + go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -short + + + - isReservedOrPrivate(198.18.0.1), (192.0.0.1), (240.0.0.1), (255.255.255.255), (2001:db8::1), (fec0::1), (fe80::1%eth0) all true (smoke test) + - isReservedOrPrivate(8.8.8.8), (1.1.1.1), (2606:4700:4700::1111) false + - Existing TestIsReservedOrPrivate and TestIsReservedOrPrivateIPv6Transitions still pass + + Classifier covers the full non-public set and is zone-insensitive. + + + + Task 2: Reject zoned addresses at the dial boundary (fetchguard/fetch.go) + fetchguard/fetch.go + fetchguard/fetch.go lines 145-175, fetchguard/fetch_test.go TestDialControlRejectsUnsafeIPv6Transitions + +In dialControl, after netip.ParseAddr succeeds and before Unmap/classification, if addr.Zone() != "" return errPrivateIP (a scoped literal is never a routable public destination, so it is rejected outright rather than normalized; mapTransportError then yields ReasonPrivateIP). Leave policy.skipReservedCheck handling untouched. Add a smoke test invoking dialControl with address "[fe80::1%eth0]:443" expecting errPrivateIP, and "198.18.0.1:443" expecting errPrivateIP. + + + go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -race -short + + + - dialControl on "[fe80::1%eth0]:443" returns an error satisfying errors.Is(err, errPrivateIP) + - `grep -n "Zone()" fetchguard/fetch.go` shows the check precedes isReservedOrPrivate + - go vet and go test ./fetchguard/... exit 0 + + Zoned scoped addresses fail with private_ip at dial time. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| caller URL / DNS answer -> dial target | untrusted destination reaches net.Dialer.Control | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-30 | Elevation of Privilege (SSRF) | fetchguard/ip.go | mitigate | full IANA special-use IPv4/IPv6 prefix tables; public controls stay allowed | +| T-06-31 | Elevation of Privilege (SSRF) | fetchguard/fetch.go dialControl | mitigate | zone stripped for classification, zoned dial targets rejected outright | + + + +`go vet ./... && go test ./... -count=1 -race -short` green in summercms.go. + + + +Both truths hold, suite green, code-only commit, no co-author tags. + + + +Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-SUMMARY.md` when done + diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-14-PLAN.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-14-PLAN.md new file mode 100644 index 0000000..2869c58 --- /dev/null +++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-14-PLAN.md @@ -0,0 +1,161 @@ +--- +phase: 06-http-routing-auth-groups-and-rate-limiting +plan: 14 +type: execute +wave: 2 +depends_on: ["06-12", "06-13"] +files_modified: + - surf/bodylimit_test.go + - surf/limiter_test.go + - surf/router_test.go + - bouncer/registry_test.go + - bouncer/jwt_test.go + - fetchguard/ip_test.go + - fetchguard/fetch_test.go + - .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md +autonomous: true +gap_closure: true +requirements: [HTTP-03, HTTP-04, HTTP-05, HTTP-06, HTTP-07, HTTP-08, HTTP-09] + +must_haves: + truths: + - "A body-consuming named middleware cannot read past the limit on default and body.limit:N routes, and raw routes are unaffected" + - "Every invalid limiter definition (nil store, nil Key, Max<1, Decay<=0, inline overflow) is a boot-time error covered by a test" + - "An IANA boundary table and zoned fe80 dial-time tests prove the fetchguard classifier" + - "Each Plan 06-12 warning fix (typed-nil guard, ServeMux conflict error, factories built once, missing body config, fractional JWT sub) has a regression test" + - "06-SECURITY-REVIEW.md lists T-06-28 through T-06-35, cites the named passing tests, and its totals/verdict match the post-fix evidence" + artifacts: + - path: surf/bodylimit_test.go + provides: "Body-consuming middleware regression" + - path: fetchguard/ip_test.go + provides: "IANA boundary table" + - path: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md + provides: "Reopened and re-closed threat register" + key_links: + - from: 06-SECURITY-REVIEW.md + to: surf/bodylimit_test.go + via: "T-06-28 proof cites the named regression" + pattern: "T-06-28" + - from: 06-SECURITY-REVIEW.md + to: fetchguard/ip_test.go + via: "T-06-30/T-06-31 proofs cite table and zoned tests" + pattern: "T-06-31" +--- + + +Bring full unit and regression coverage to the gaps fixed in 06-12 and 06-13, then rewrite the security review truthfully (lean-mode rule 3: tests are the last plan). + +Purpose: close verification truths 4, 5, 8, 11. Output: test files and a rewritten 06-SECURITY-REVIEW.md. Code commit (tests) and docs commit (review) are separate. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@CLAUDE.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-SUMMARY.md +@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-SUMMARY.md + + + + + + Task 1: surf and bouncer regression tests + surf/bodylimit_test.go, surf/limiter_test.go, surf/router_test.go, bouncer/registry_test.go, bouncer/jwt_test.go + existing tests in each file (helper names, how routers are built), surf/router.go wrap/compile, surf/limiter.go, bouncer/jwt.go subject + + - TestBodyLimitBoundsBodyConsumingMiddleware: limit 4, named middleware io.ReadAll(r.Body) on 10-byte body gets *http.MaxBytesError (record it), for default limit and for a body.limit:N override; a raw route with the same middleware reads all bytes; a panic in that middleware still yields the clean 500 + - TestRegisterBucketRejectsInvalid: table for nil Key, Max 0, Max -1, Decay 0, Decay negative, nil store, each error non-nil and names plugin and bucket; TestValidateThrottleRejectsOverflowAndNilStore; TestMiddlewareFailsClosed proves misconfigured limiter yields 500 and next is never called (no 204 pass-through) + - TestBuildRouterFailsOnMissingBodyConfig: missing key, zero, negative, non-numeric each error; valid config passes + - TestCompileRouteConflictReturnsError: two overlapping semantic patterns give error, no panic + - TestFactoriesBuiltOncePerName: counting factory invoked exactly once across BuildRouter+compile for a repeated name:param + - registry: typed-nil pointer/func/map guard rejected, valid guard accepted; jwt: sub 12.5, NaN-equivalent, 2^60 float, -1, json.Number "1.5" rejected, sub 12 and "12" accepted + + +Write the tests above using the existing test helpers in each file; plain func TestX(t *testing.T), testify only if already imported there. Table-driven with subtests. Do not modify production code; if a test exposes a defect, stop and report it instead of loosening the test. Run with -race. + + + go vet ./surf/... ./bouncer/... && go test ./surf/... ./bouncer/... -count=1 -race -short + + + - `go test ./surf/... ./bouncer/... -run 'TestBodyLimitBoundsBodyConsumingMiddleware|TestRegisterBucketRejectsInvalid|TestMiddlewareFailsClosed|TestFactoriesBuiltOncePerName|TestCompileRouteConflictReturnsError|TestBuildRouterFailsOnMissingBodyConfig' -v` lists each PASS + - `go test ./surf/... -cover` reports statement coverage of surf/limiter.go and surf/bodylimit.go functions at 100% for the changed branches (check with -coverprofile / go tool cover -func) + - bouncer tests for typed-nil and fractional subject pass + + Every surf/bouncer gap and warning has a named regression. + + + + Task 2: fetchguard IANA boundary and zoned dial-time tests + fetchguard/ip_test.go, fetchguard/fetch_test.go + fetchguard/ip.go, fetchguard/ip_test.go, fetchguard/fetch_test.go (TestDialControlRejectsUnsafeIPv6Transitions pattern) + + - TestIsReservedOrPrivateIANABoundaries: for every prefix in the 06-13 table assert first address, last address and one interior address are non-public, plus the address immediately before and after each range is public when it is not itself in another listed range (for example 198.17.255.255 and 198.20.0.0 public; 239.255.255.255 multicast blocked) + - Public controls 8.8.8.8, 1.1.1.1, 2606:4700:4700::1111 allowed; IPv4-mapped forms of blocked addresses blocked + - TestIsReservedOrPrivateIgnoresZone: fe80::1%eth0 and %1, and a zoned public address classifies as its unzoned form + - TestDialControlRejectsZonedAndSpecialUse: dialControl returns errPrivateIP for [fe80::1%eth0]:443, 198.18.0.1:443, 192.0.0.1:443, 240.0.0.1:443, and passes 8.8.8.8:443; one PublicOnlyMode Fetch case maps to ReasonPrivateIP (not network_error) for those four probe inputs + + +Write the table-driven tests. Do not modify production code. Fetch-level cases must not perform real network I/O (the dial control rejects before connect; use literal IP URLs with the mode/allow-list used by existing tests). + + + go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -race -short + + + - Named tests TestIsReservedOrPrivateIANABoundaries, TestIsReservedOrPrivateIgnoresZone, TestDialControlRejectsZonedAndSpecialUse pass + - `go tool cover -func` shows isReservedOrPrivate and dialControl at 100% + + Classifier and dial boundary fully tested against the previously bypassing inputs. + + + + Task 3: Reopen and rewrite 06-SECURITY-REVIEW.md (docs commit, separate from code) + .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md + 06-SECURITY-REVIEW.md, 06-VERIFICATION.md, 06-12-SUMMARY.md, 06-13-SUMMARY.md, and the actual test names created in Tasks 1 and 2 + +Rewrite the review in the existing structure. First state honestly in a "Reopened" note that the 2026-09-21 zero-open verdict was contradicted by verification and record that audit-trail row as superseded (append, do not delete history). Add threat rows and Findings sections for T-06-28 (body-consuming middleware bypassing the cap), T-06-29 (invalid or overflowing limiter definitions failing open), T-06-30 (remaining non-public IPv4/IPv6 special-use ranges), T-06-31 (zoned IPv6 evading prefix checks), and warning-class threats T-06-32 (typed-nil guard), T-06-33 (fractional JWT subject), T-06-34 (ServeMux conflict panic), T-06-35 (missing body config becomes zero). Each row: category, plan of origin (06-12 or 06-13), disposition mitigate, and Proof citing the exact named passing tests from Tasks 1-2 plus source location. Add trust-boundary rows for body -> named middleware, plugin bucket definition -> limiter, and non-public IP representations -> dial. Update T-06-12 finding to note the earlier proof only covered the terminal handler. Recompute totals (34 threats: verify count from the register), frontmatter status, verdict, scope, accepted risks (unchanged 4), and append an audit-trail row with the date and results of the final gates run in this task: `go test ./... -count=1 -race -short` and `go vet ./...` in both summercms.go and ../fonoteka.go. If any gate fails, leave the affected threats open and status blocked rather than verified. + + + test $(grep -c '^| T-06-' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md) -ge 34 && grep -v '^#' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md | grep -c 'T-06-35' + + + - Register contains rows T-06-28 through T-06-35, each with a named test in Proof + - Frontmatter threats_total equals the actual row count and threats_open reflects gate results + - Audit trail has a new row and retains the superseded 2026-09-21 row + - Every test name cited exists (grep each name in the repo returns a match) + + Review is truthful, reopened, and re-closed only on passing evidence. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| test evidence -> security sign-off | review verdict must follow executed proof | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-36 | Repudiation | 06-SECURITY-REVIEW.md | mitigate | verdict derived from gate output; cited tests verified by grep; superseded history retained | +| T-06-37 | Tampering | test suite | mitigate | tests never loosen production checks; defects found are reported, not masked | + + + +`go vet ./... && go test ./... -count=1 -race -short` in summercms.go and fonoteka.go. Tests commit and review commit are separate; no co-author tags. + + + +Verification truths 4, 5, 8, 11 are supportable by named tests and an honest review. + + + +Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-14-SUMMARY.md` when done +