Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-PLAN.md
2026-09-21 19:30:50 +02:00

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, gap_closure, requirements, must_haves
phase plan type wave depends_on files_modified autonomous gap_closure requirements must_haves
06-http-routing-auth-groups-and-rate-limiting 12 execute 1
surf/router.go
surf/bodylimit.go
surf/limiter.go
bouncer/registry.go
bouncer/jwt.go
true true
HTTP-04
HTTP-09
HTTP-05
HTTP-06
truths artifacts key_links
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
path provides
surf/router.go Corrected wrap ordering, factory cache, body-config validation, panic-to-error mux registration
path provides
surf/limiter.go Fail-closed limiter definition validation
path provides
bouncer/registry.go Typed-nil guard rejection
path provides
bouncer/jwt.go Integer-only numeric subject
from to via pattern
surf/router.go surf/bodylimit.go bodyLimit applied after the named-middleware loop, before locale/recover bodyLimit(
from to via pattern
surf/router.go surf/limiter.go RegisterBucket errors propagate from BuildRouter 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@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 <acceptance_criteria> - 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 </acceptance_criteria> 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 <acceptance_criteria> - 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 </acceptance_criteria> 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 <acceptance_criteria> - 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 </acceptance_criteria> Typed-nil guards and fractional subjects are rejected.

<threat_model>

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
</threat_model>
`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).

<success_criteria> All must_haves truths hold; both repositories build and test green; commit is code only (no planning docs), no co-author tags. </success_criteria>

Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-12-SUMMARY.md` when done