Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-07-PLAN.md
2026-09-20 16:14:21 +02:00

152 lines
13 KiB
Markdown

---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 07
type: execute
wave: 6
depends_on: ["06-06"]
files_modified:
- summercms.go/surf/limiter.go
- summercms.go/surf/limiter_store.go
- summercms.go/surf/limiter_test.go
- summercms.go/surf/limiter_coverage_test.go
autonomous: true
gap_closure: true
requirements: [HTTP-04]
must_haves:
truths:
- "A fixed-window Max=1 bucket admits exactly one request when many requests contend concurrently; no check-then-increment window remains"
- "Anonymous inline throttles use one server-controlled domainless-route namespace plus trusted-proxy ClientIP; neither the throttle parameter nor any request Host input selects the bucket"
- "Different inline throttle parameters applied to the same anonymous client IP share the same Laravel-compatible domainless/IP signature and budget"
- "Rotating Host while keeping the same anonymous client IP cannot obtain a fresh inline-throttle budget"
- "Authenticated inline throttles retain independent u:<principal id> buckets, and named bucket behavior remains unchanged"
artifacts:
- path: summercms.go/surf/limiter_store.go
provides: "Atomic Store.Attempt admission contract and mutex-guarded MemoryStore implementation"
- path: summercms.go/surf/limiter.go
provides: "FixedWindowLimiter middleware consuming one atomic attempt result and stable inline keys"
- path: summercms.go/surf/limiter_test.go
provides: "Coordinated concurrent Max=1, Host-rotation, and cross-inline-parameter key regressions"
key_links:
- from: summercms.go/surf/limiter.go
to: summercms.go/surf/limiter_store.go
via: "Middleware calls Store.Attempt exactly once per request to decide admission and derive headers"
pattern: "\.Attempt\(key, b\.Max, b\.Decay\)"
- from: summercms.go/surf/limiter.go
to: summercms.go/surf/clientip.go
via: "anonymous inline key uses one constant domainless-route namespace plus ClientIP with the constructor-supplied trusted prefixes"
pattern: "inline:domainless\\|.*ClientIP"
---
<objective>
Close the two rate-limit bypasses left after Plan 06-06: make fixed-window admission atomic under concurrent HTTP traffic and replace attacker-controlled `r.Host` with Laravel-compatible domainless-route/IP identity for anonymous inline-throttle keys.
Purpose: HTTP-04 is a security control, so sequential correctness is insufficient; one atomic store operation must own threshold check plus increment, and request-controlled headers must not select a bucket.
Output: an atomic Store API, FixedWindowLimiter wired to it, one server-controlled domainless inline namespace, and deterministic concurrency/Host-rotation/cross-policy regressions.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.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-REVIEW.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-06-SUMMARY.md
</context>
<interfaces>
Replace the split Store admission protocol with one operation:
`Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)`
The operation owns lazy expiry, first-hit window creation, threshold comparison, increment of an admitted request, and remaining-window calculation under one lock. `attempts` is the post-increment count when allowed and the unchanged exhausted count when denied.
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Make fixed-window admission atomic and inline keys server-controlled</name>
<files>summercms.go/surf/limiter.go, summercms.go/surf/limiter_store.go, summercms.go/surf/limiter_test.go, summercms.go/surf/limiter_coverage_test.go</files>
<behavior>
- A fresh key with Max=1 returns allowed=true and attempts=1; every further attempt before expiry returns allowed=false without incrementing past 1.
- After expiry, the next attempt starts a fresh first-hit-wins window and is allowed with attempts=1.
- Thirty-two goroutines released by one start barrier against the same Max=1 key produce exactly one allowed result.
- Thirty-two concurrent requests released by one start barrier through `FixedWindowLimiter.Middleware("1,1")` invoke the protected handler exactly once and return one success plus thirty-one 429 responses.
- Two anonymous requests from the same explicit RemoteAddr but different Host values share one `throttle:1,1` bucket: first succeeds, second returns 429.
- Anonymous requests from the same explicit RemoteAddr routed through different inline parameters share one key: after two successes through `throttle:2,1`, a request through `throttle:1,1` returns 429 rather than receiving a fresh parameter-specific budget.
- Two authenticated principals from the same IP retain distinct `u:<id>` inline buckets.
</behavior>
<read_first>
summercms.go/surf/limiter_store.go (current Store interface and MemoryStore locking/expiry behavior)
summercms.go/surf/limiter.go (Middleware's separate TooManyAttempts/Hit calls and inline key closure)
summercms.go/surf/limiter_test.go (existing window, headers, stacking, user-key, and anonymous-key tests)
summercms.go/surf/limiter_coverage_test.go (sweep and expiry coverage that must be adapted without weakening)
summercms.go/surf/clientip.go (the sole trusted-proxy-aware client IP source required by D-04)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md (D-01 through D-05)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (first authoritative gap, including concurrent Max=1 and Host rotation)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (CR-01 and CR-02)
</read_first>
<action>
In `limiter_store.go`, replace the public `Hit`/`TooManyAttempts`/`AvailableIn` Store protocol with the single `Attempt(key, max, decay)` signature from the interfaces block. Implement `MemoryStore.Attempt` under one `s.mu.Lock`: read `time.Now()` once; delete an expired entry; create a new entry with count zero and `resetAt=now.Add(decay)` when absent; if the live count is already `>= max`, return denied with the unchanged count and a non-negative `resetAt.Sub(now)`; otherwise increment once and return allowed with the post-increment count and the same remaining duration. Keep first-hit-wins (later attempts never extend resetAt), lazy expiry, and the sweep goroutine. Do not retain any middleware path that can check and increment under separate locks.
In `limiter.go`, call `l.store.Attempt(key, b.Max, b.Decay)` exactly once. On denial, derive `Retry-After`, `X-RateLimit-Reset`, limit/remaining headers, status 429, and the existing exact body from that returned retry duration. On admission, derive remaining as `b.Max-attempts`, set the existing success headers, and invoke `next`. Preserve the exact 60th-allowed/61st-denied semantics, named-bucket stacking, fixed-window duration, and PHP body/headers from D-02.
Change the anonymous inline key closure to `"inline:domainless|" + ClientIP(r, trusted)`. The constant prefix represents the router's server-controlled domainless route identity required by D-02/Laravel parity. Every anonymous inline policy for the same client IP must therefore share this signature: do not include `param`, `r.Host`, `Host`, `X-Forwarded-Host`, URL host, or any other policy-specific or caller-controlled value. Preserve authenticated keys as `u:<principal id>`.
Rewrite existing Store tests around Attempt without deleting expiry, first-hit-wins, sweep, header, stacking, or remaining-count assertions. Add two coordinated concurrency regressions: all goroutines must signal ready and block on a shared start channel before attempting the same key/request; assert exact counts after all complete. The test must use the real MemoryStore and real FixedWindowLimiter, not a serial fake. Extend the anonymous inline-key tests in two independent regressions: (1) request 1 and request 2 use the same RemoteAddr but intentionally different Host values and the second is 429; (2) two requests through `Middleware("2,1")` for one RemoteAddr succeed, then a request from that same RemoteAddr through `Middleware("1,1")` returns 429, proving the inline parameter does not create a new key. Retain the authenticated same-IP/different-principal test proving distinct `u:<id>` buckets. Run the race detector on the package.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go &amp;&amp; go test ./surf -run 'Test.*(Atomic|Concurrent|InlineThrottleKeys|MemoryStore|TooManyAttempts|StackedBuckets)' -count=1 -race -short &amp;&amp; go vet ./surf &amp;&amp; go test ./surf -count=1 -race -short</automated>
</verify>
<acceptance_criteria>
- `Store` exposes one atomic Attempt operation; production middleware contains no `TooManyAttempts` followed by `Hit` sequence.
- MemoryStore performs expiry check, threshold check, and admitted increment within one mutex critical section and never increments a denied attempt.
- The concurrent Max=1 Store and middleware tests each coordinate all workers with ready/start barriers; the middleware test proves exactly one protected-handler invocation, one success, and N-1 exact 429 responses.
- An anonymous Host-rotation test uses one IP and at least two distinct Host strings, then proves the second request shares the exhausted bucket.
- A cross-inline-policy regression exhausts the shared same-IP counter through `throttle:2,1`, then proves `throttle:1,1` returns 429 instead of receiving a fresh parameter-specific budget.
- Production anonymous inline key construction contains the constant `inline:domainless|` namespace and `ClientIP`, but contains neither `param`, `r.Host`, nor forwarded-host input; authenticated inline keys remain `u:<principal id>`.
- Existing sequential window, success/429 header, stacked-bucket, authenticated-user-key, sweep, and expiry tests remain present and pass under `-race`.
</acceptance_criteria>
<done>Concurrent callers cannot over-admit a fixed window, anonymous callers cannot rotate Host or inline policy parameters to rotate buckets, authenticated users retain isolated keys, and the existing PHP-compatible sequential/header semantics remain green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| concurrent clients -> MemoryStore | Many untrusted requests can reach the same key simultaneously and must receive one serialized admission decision |
| request metadata -> inline bucket key | Host and forwarding headers are attacker-controlled; the router contributes one constant domainless-route namespace and only trusted-proxy-aware ClientIP varies the anonymous signature |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-23 | Denial of Service | `Store` / `FixedWindowLimiter.Middleware` | mitigate | Replace split check/increment with atomic Attempt and prove coordinated Max=1 contention admits exactly one request under `-race` |
| T-06-24 | Denial of Service | inline anonymous key resolver | mitigate | Key only by `inline:domainless|<trusted ClientIP>`; exclude Host and throttle parameter, prove Host rotation returns 429, and prove different inline policies for one IP share the exhausted counter |
| T-06-SC | Tampering | package supply chain | accept | No dependency or package-manager change; this plan uses existing stdlib and Phase 6 packages only |
</threat_model>
<verification>
Run the targeted coordinated, Host-rotation, cross-inline-parameter, and authenticated-isolation regressions under `-race`, then the complete `surf` package under `go vet` and `go test -race -short`. Inspect the anonymous key closure to confirm it is exactly `"inline:domainless|" + ClientIP(r, trusted)` and therefore captures neither `param` nor Host input; grep production limiter code to confirm `r.Host` and the split `TooManyAttempts`/`Hit` admission path are absent.
</verification>
<success_criteria>
- Exactly one concurrent request reaches a Max=1 protected handler.
- Threshold comparison and increment are atomic in the Store contract and implementation.
- Different Host values from one anonymous IP share the same inline bucket.
- Different inline throttle parameters from one anonymous IP share the same domainless/IP key and budget.
- Authenticated principals from one IP retain independent `u:<id>` buckets.
- Named buckets, authenticated per-user keys, stacking, expiry, headers, and exact 429 body retain their established behavior.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-07-SUMMARY.md` when done.
</output>