152 lines
13 KiB
Markdown
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 && go test ./surf -run 'Test.*(Atomic|Concurrent|InlineThrottleKeys|MemoryStore|TooManyAttempts|StackedBuckets)' -count=1 -race -short && go vet ./surf && 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>
|