docs(06): create gap closure plans

This commit is contained in:
Jakub Zych
2026-09-20 16:14:21 +02:00
parent c187d6f735
commit 47e9c44b2a
7 changed files with 719 additions and 6 deletions

View File

@@ -184,7 +184,7 @@ Plans:
4. The money cast round-trips the PHP ceiling and blank-string cases as a fixed 4-decimal JSON string (never `float64`), and an encrypted-at-rest credential column is AES-GCM encrypted at rest and hidden from serialization. 4. The money cast round-trips the PHP ceiling and blank-string cases as a fixed 4-decimal JSON string (never `float64`), and an encrypted-at-rest credential column is AES-GCM encrypted at rest and hidden from serialization.
5. Paginated responses use the exact `{data, meta{current_page,last_page,per_page,total}}` envelope with no `links` key; another plugin extends a model's lifecycle through the GORM callback registry and a companion migration without editing the owning plugin's file; soft-deletable + uniquely-keyed tables pass a delete-then-recreate test. 5. Paginated responses use the exact `{data, meta{current_page,last_page,per_page,total}}` envelope with no `links` key; another plugin extends a model's lifecycle through the GORM callback registry and a companion migration without editing the owning plugin's file; soft-deletable + uniquely-keyed tables pass a delete-then-recreate test.
**Plans**: 6 plans **Plans**: 11 plans
Plans: Plans:
**Wave 1** **Wave 1**
@@ -247,6 +247,17 @@ Plans:
- [x] 06-06-PLAN.md — Repair personal-token middleware order and prove unauthenticated request 61 is rate-limited - [x] 06-06-PLAN.md — Repair personal-token middleware order and prove unauthenticated request 61 is rate-limited
**Wave 6** *(gap closure; parallel, blocked on 06-06)*
- [ ] 06-07-PLAN.md — Make limiter admission atomic and remove attacker-controlled Host from inline keys
- [ ] 06-08-PLAN.md — Reject private IPv4 embedded in NAT64 and 6to4 dial addresses
- [ ] 06-09-PLAN.md — Buffer route responses so partial-write panics yield clean raw/house 500s
- [ ] 06-10-PLAN.md — Restore exact no-newline InvScope 401/403 wire bodies
**Wave 7** *(gap closure; blocked on 06-07..06-10)*
- [ ] 06-11-PLAN.md — Re-run Phase 6 security gates and refresh the stale threat verdict
### Phase 7: User plugin and authentication ### Phase 7: User plugin and authentication
**Goal**: The user plugin is ported with registration, login, JWT issue/refresh, organizations, personal API tokens and the must-change-password lock. Security-load-bearing — password auth, token scope ceilings and the session lock all live here; apply the security-review agent. **Goal**: The user plugin is ported with registration, login, JWT issue/refresh, organizations, personal API tokens and the must-change-password lock. Security-load-bearing — password auth, token scope ceilings and the session lock all live here; apply the security-review agent.

View File

@@ -4,12 +4,12 @@ milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Completed 06-06-PLAN.md stopped_at: Completed 06-06-PLAN.md
last_updated: "2026-09-20T11:32:44.614Z" last_updated: "2026-09-20T14:14:04.240Z"
last_activity: 2026-09-20 last_activity: 2026-09-20 -- Phase 6 planning complete
progress: progress:
total_phases: 15 total_phases: 15
completed_phases: 5 completed_phases: 5
total_plans: 29 total_plans: 34
completed_plans: 29 completed_plans: 29
percent: 33 percent: 33
--- ---
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING
Plan: 6 of 6 Plan: 6 of 6
Status: Gaps found Status: Ready to execute
Last activity: 2026-09-20 Last activity: 2026-09-20 -- Phase 6 planning complete
Progress: [██████████] 100% Progress: [██████████] 100%

View File

@@ -0,0 +1,151 @@
---
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>

View File

@@ -0,0 +1,131 @@
---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 08
type: execute
wave: 6
depends_on: ["06-06"]
files_modified:
- summercms.go/fetchguard/ip.go
- summercms.go/fetchguard/ip_test.go
- summercms.go/fetchguard/fetch_test.go
autonomous: true
gap_closure: true
requirements: [HTTP-07]
must_haves:
truths:
- "NAT64 64:ff9b::/96, local-use NAT64 64:ff9b:1::/48, and 6to4 2002::/16 addresses embedding loopback, RFC1918, or 169.254.169.254 are rejected as private_ip"
- "The same three transition formats embedding a public IPv4 address remain classifiable as public rather than being blanket-rejected"
- "The dial-time control path, not only a standalone helper test, rejects unsafe transition addresses before connection"
- "Existing IPv4, IPv4-mapped IPv6, native private IPv6, CGNAT, multicast, and unspecified-address behavior remains intact"
artifacts:
- path: summercms.go/fetchguard/ip.go
provides: "IPv4 extraction/classification for the three supported IPv6 transition prefixes"
- path: summercms.go/fetchguard/ip_test.go
provides: "Transition-address tables covering embedded loopback, RFC1918, metadata, and public IPv4"
- path: summercms.go/fetchguard/fetch_test.go
provides: "dialControl regression proving transition rejection occurs at the actual connect boundary"
key_links:
- from: summercms.go/fetchguard/fetch.go
to: summercms.go/fetchguard/ip.go
via: "dialControl parses the actual dial address and invokes isReservedOrPrivate after Unmap"
pattern: "isReservedOrPrivate\(addr\)"
---
<objective>
Close the transition-address SSRF bypass by decoding embedded IPv4 from both NAT64 prefixes and 6to4 before the dial-time allow decision.
Purpose: HTTP-07 treats fetchguard as a security boundary; an environment-dependent path through an IPv6 translator to loopback, RFC1918, or cloud metadata must be rejected exactly like the plain IPv4 target.
Output: transition-aware classification plus table-driven helper and dial-control security 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-RESEARCH.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-04-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Decode and reclassify embedded IPv4 at the dial-time SSRF boundary</name>
<files>summercms.go/fetchguard/ip.go, summercms.go/fetchguard/ip_test.go, summercms.go/fetchguard/fetch_test.go</files>
<behavior>
- `64:ff9b::7f00:1`, `64:ff9b::a00:1`, and `64:ff9b::a9fe:a9fe` classify private; `64:ff9b::808:808` classifies public.
- RFC 6052 /48 encodings under `64:ff9b:1::/48` of 127.0.0.1, 10.0.0.1, and 169.254.169.254 classify private; the encoding of 8.8.8.8 classifies public.
- `2002:7f00:1::`, `2002:a00:1::`, and `2002:a9fe:a9fe::` classify private; `2002:808:808::` classifies public.
- Calling the production dialControl callback for every unsafe transition literal returns an error mapped to ReasonPrivateIP before using the RawConn.
</behavior>
<read_first>
summercms.go/fetchguard/ip.go (existing privateV4/privateV6 tables and classifier)
summercms.go/fetchguard/ip_test.go (existing boundary cases, including IPv4-mapped Unmap behavior)
summercms.go/fetchguard/fetch.go (dialControl and mapTransportError; the production connection-time link)
summercms.go/fetchguard/fetch_test.go (existing real-network private-IP and reason assertions)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md (D-11 through D-14)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (second authoritative gap)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (CR-03 transition examples)
RFC 6052 section 2.2 (for /96 and /48 extraction: /48 uses bits 48-63 plus 72-87 and skips the zero u octet at bits 64-71)
RFC 3056 section 2 (6to4 embeds IPv4 in bits 16-47)
</read_first>
<action>
In `ip.go`, normalize with `addr.Unmap()` inside the classifier so direct and dial-time callers cannot forget mapped-IPv4 normalization. Add package-level prefixes for `64:ff9b::/96`, `64:ff9b:1::/48`, and `2002::/16`, then an unexported extraction helper returning `(netip.Addr, bool)` for only these formats. Use `addr.As16()` and exact byte positions: the /96 NAT64 IPv4 is bytes 12-15; the RFC 6052 /48 local-use NAT64 IPv4 is bytes 6-7 followed by bytes 9-10 (byte 8 is the required zero `u` octet); 6to4 IPv4 is bytes 2-5. If a `64:ff9b:1::/48` address has a non-zero u octet, fail closed as reserved rather than treating it as native public IPv6.
Before returning public for a native IPv6 address, if the helper recognizes one of the three transition prefixes, pass the extracted IPv4 back through the ordinary IPv4 private/reserved/CGNAT/metadata classification. Reject only when the embedded address is unsafe (or the form is malformed); keep a correctly encoded public IPv4 result public. Preserve the existing native IPv6 prefix table and multicast/unspecified handling.
Add table-driven tests for all four categories (loopback, RFC1918, metadata link-local, public) under each of the three formats. Use exact /48 literals following RFC 6052, including `64:ff9b:1:7f00:0:100::` for 127.0.0.1, `64:ff9b:1:a00:0:100::` for 10.0.0.1, `64:ff9b:1:a9fe:a9:fe00::` for 169.254.169.254, and `64:ff9b:1:808:8:800::` for 8.8.8.8. Add a malformed non-zero-u /48 case and assert fail-closed.
In `fetch_test.go`, call the real `dialControl(Policy{Mode: PublicOnlyMode})` callback with bracketed IPv6 host:443 addresses and nil RawConn (the callback classifies before touching RawConn). Cover at least one loopback, one RFC1918, and the metadata address in every transition prefix; assert `errors.Is(err, errPrivateIP)` and `mapTransportError(err).Reason == ReasonPrivateIP`. This test must exercise production dialControl, not only `isReservedOrPrivate`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go &amp;&amp; go test ./fetchguard -run 'Test(IsReservedOrPrivate|.*Transition|.*NAT64|.*6to4)' -count=1 -race -short &amp;&amp; go vet ./fetchguard &amp;&amp; go test ./fetchguard -count=1 -race -short</automated>
</verify>
<acceptance_criteria>
- Tests cover embedded 127.0.0.1, 10.0.0.1, and 169.254.169.254 for `64:ff9b::/96`, `64:ff9b:1::/48`, and `2002::/16`.
- Tests cover an embedded public 8.8.8.8 in all three formats and assert it is not classified private.
- The /48 decoder skips exactly the RFC 6052 u octet and a non-zero u octet fails closed.
- A dialControl-level table proves every unsafe transition address returns the private-IP sentinel and maps to `ReasonPrivateIP` before connection.
- Existing plain IPv4, mapped IPv4, native IPv6, CGNAT, metadata, multicast, and unspecified tests remain present and green under `-race`.
</acceptance_criteria>
<done>All three supported IPv6 transition formats receive the same private/reserved IPv4 policy at dial time, while public embedded IPv4 remains allowed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| DNS result -> TCP dial address | An attacker-controlled hostname can resolve to an IPv6 transition address whose embedded IPv4 targets private infrastructure |
| IPv6 syntax -> IPv4 policy | NAT64/6to4 representation must not bypass the ordinary loopback, RFC1918, link-local metadata, or CGNAT table |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-25 | Elevation of Privilege / Information Disclosure | `fetchguard.isReservedOrPrivate` and `dialControl` | mitigate | Decode RFC 6052 /96 and /48 plus 6to4 embedded IPv4, reapply the private table, fail closed on malformed local-use NAT64, and prove the production dial hook rejects unsafe cases |
| T-06-SC | Tampering | package supply chain | accept | No dependencies or manifests change; implementation uses `net/netip` and existing fetchguard code |
</threat_model>
<verification>
Run transition-specific tables and the full fetchguard package under the race detector. Confirm the tests distinguish unsafe embedded IPv4 from public 8.8.8.8 for each supported transition prefix and include dialControl-level evidence.
</verification>
<success_criteria>
- NAT64 and 6to4 cannot encode loopback, RFC1918, or metadata IPv4 past fetchguard.
- The rejection is enforced at actual dial-address classification.
- Correct public embeddings are not blanket-blocked.
- Existing SSRF, redirect, timeout, and byte-cap behavior remains green.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-08-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,130 @@
---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 09
type: execute
wave: 6
depends_on: ["06-06"]
files_modified:
- summercms.go/surf/router.go
- summercms.go/surf/router_test.go
autonomous: true
gap_closure: true
requirements: [HTTP-06]
must_haves:
truths:
- "A house handler that writes a status, headers, and secret partial body before panicking returns only status 500 and the exact opaque JSON body"
- "A raw handler that writes a status, headers, and partial body before panicking returns only a bare status 500 with zero body and no leaked partial headers"
- "Buffered status, headers, and body are committed unchanged when the wrapped handler completes without panic"
- "Recovery behavior does not narrow the locked D-16 raw/house contract based on whether the handler wrote first"
artifacts:
- path: summercms.go/surf/router.go
provides: "buffer/discard recovery writer used by recoverJSON and recoverBare"
- path: summercms.go/surf/router_test.go
provides: "partial-write-then-panic regressions for house and raw routes plus successful flush coverage"
key_links:
- from: summercms.go/surf/router.go
to: summercms.go/wire/response.go
via: "house panic fallback uses the established exact opaque JSON 500 contract after discarding the buffer"
pattern: "WriteOpaque500|Internal server error"
---
<objective>
Make both recovery wrappers transactional: hold a route response until successful completion, then commit it; on panic discard every partial status/header/body byte and emit only the promised house or raw 500.
Purpose: HTTP-06 and D-16 promise opaque error boundaries. Writing the fallback after the original writer is committed cannot satisfy that promise and may leak sensitive partial output.
Output: shared buffered response machinery in `surf/router.go` and adversarial partial-write regressions for both route kinds.
</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-PATTERNS.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-03-SUMMARY.md
</context>
<interfaces>
The recovery buffer is an unexported `http.ResponseWriter` implementation with a private `http.Header`, first-write status (implicit 200), body buffer, and a success-only commit method. It may implement `http.Flusher` as a no-op so a flush request does not violate the discard-on-panic contract; it must never unwrap or expose the destination writer before successful completion.
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Buffer route responses so panic recovery can discard partial output</name>
<files>summercms.go/surf/router.go, summercms.go/surf/router_test.go</files>
<behavior>
- A non-raw handler sets `X-Partial: secret`, calls `WriteHeader(202)`, writes `secret-partial`, then panics: response is status 500, `Content-Type: application/json`, exact body `{"error":true,"message":"Internal server error"}`, and no `X-Partial` or partial bytes.
- A raw handler performs the same partial write then panic: response is status 500, zero-length body, empty Content-Type, and no `X-Partial` or partial bytes.
- A successful handler's first status, headers, and body are copied to the destination once and exactly once after return.
- Calling Write without WriteHeader records status 200; repeated WriteHeader calls preserve the first status as net/http does.
</behavior>
<read_first>
summercms.go/surf/router.go (wrap order plus current recoverJSON/recoverBare direct writes)
summercms.go/surf/router_test.go (TestRawGroupPanicBare500 and TestRecoverReturnsOpaqueJSON500)
summercms.go/wire/response.go (the exact house `WriteOpaque500` body and Content-Type contract)
fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go (existing buffer-then-flush responseRecorder pattern; borrow the shape, not its 429 rewrite)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md (D-16: raw bare 500 and house opaque recovery are locked)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (third authoritative gap; contract may not be narrowed)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (CR-04 partial-response disclosure)
</read_first>
<action>
In `router.go`, add one unexported buffered response type shared by `recoverJSON` and `recoverBare`. `Header()` returns only the private header map. `WriteHeader` records only the first status. `Write` implies 200 when no status is recorded and appends only to the private byte buffer. A no-op `Flush` may mark the type as `http.Flusher`, but it must not write to the destination; do not provide `Unwrap`, Hijack, or another path that can commit the real writer before the handler returns.
Add a success commit method that copies buffered header values defensively to the destination, replacing values for those same keys, then writes the recorded status (default 200) and buffered body once. Do not clear unrelated headers already placed on the destination by an outer wrapper such as path-scoped CORS. Recovery itself must not copy any buffered route header before commit, so a panic discards the handler/middleware headers as well as its status/body.
Rewrite `recoverJSON` and `recoverBare` to invoke `next` with a fresh buffer. In a deferred function, if recovery observes a panic, do not commit the buffer: `recoverJSON` writes only the established opaque JSON 500 (prefer `wire.WriteOpaque500` to avoid a second literal) and `recoverBare` writes only status 500 with no Content-Type/body. If no panic occurs, commit the buffer. Do not special-case panic-before-write versus panic-after-write; both must yield identical fallbacks. Preserve the placement of recovery in `wrap` and all raw-group registration rules.
Extend `router_test.go` with separate partial-write-then-panic subtests registered as real raw and house routes through `Router.compile`. Each handler must explicitly write a non-500 status, a sensitive header, and body bytes before panic. Compare raw response bytes, not parsed/trimmed output. Assert the partial status, header, and body do not survive. Add a successful buffered-response test that proves status/header/body pass through unchanged and first-WriteHeader semantics are retained. Keep the existing panic-before-write tests.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go &amp;&amp; go test ./surf -run 'Test.*(Recover|Panic|BufferedResponse|RawGroup)' -count=1 -race -short &amp;&amp; go vet ./surf &amp;&amp; go test ./surf -count=1 -race -short</automated>
</verify>
<acceptance_criteria>
- Both adversarial handlers call WriteHeader and Write before panic; a panic-before-write test alone is insufficient.
- The house assertion compares the raw body exactly to `{"error":true,"message":"Internal server error"}` and proves neither the secret header nor `secret-partial` appears.
- The raw assertion requires status 500, `len(body)==0`, empty Content-Type, and absence of the secret header/body.
- Production recovery always passes the buffer to `next`; no branch writes a fallback after the destination may already be committed.
- Successful status/header/body responses and implicit-200 behavior remain covered and pass under `-race`.
</acceptance_criteria>
<done>House and raw panics produce clean, contract-exact 500 responses even after status/body writes, while successful responses commit normally.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| handler/middleware -> client response | Application code may panic after producing sensitive or malformed partial output; recovery must prevent those bytes from crossing the network boundary |
| raw route -> RFC client | Raw routes omit the house envelope but still promise a bare, clean 500 on panic |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-26 | Information Disclosure | `recoverJSON` / `recoverBare` | mitigate | Buffer all covered route output, commit only on successful return, discard on panic, and assert partial status/header/body cannot leak for both raw and house routes |
| T-06-SC | Tampering | package supply chain | accept | No dependency change; uses existing stdlib and `wire` response helpers |
</threat_model>
<verification>
Run panic and successful-buffer regressions under the race detector, then the entire surf suite. Inspect both recovery functions to confirm the original destination writer is never passed to `next` and is written only after success or with the panic fallback.
</verification>
<success_criteria>
- Partial house output is discarded and replaced by the exact opaque JSON 500.
- Partial raw output is discarded and replaced by a bodyless, header-clean 500.
- Success responses preserve first status, headers, and body.
- Existing routing, middleware, raw-group, CORS, body-limit, and limiter tests remain green.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-09-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,121 @@
---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 10
type: execute
wave: 6
depends_on: ["06-06"]
files_modified:
- fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
- fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
autonomous: true
gap_closure: true
requirements: [HTTP-05, HTTP-06]
must_haves:
truths:
- "InvScope without a resolved user returns byte-exact 401 body {\"error\":\"Invalid token\"} with no trailing newline"
- "InvScope with a token missing the requested scope returns byte-exact 403 body {\"error\":\"Missing required scope: <scope>\"} with no trailing newline"
- "Tests compare raw recorder bytes directly and cannot hide whitespace with TrimSpace"
- "A correctly scoped token still reaches the next handler unchanged"
artifacts:
- path: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
provides: "InvScope 401/403 responses delegated to framework wire.WriteJSON"
- path: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
provides: "exact raw-byte assertions for both TokenScope denial branches"
key_links:
- from: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
to: summercms.go/wire/response.go
via: "both denial branches call wire.WriteJSON, the shared PHP-compatible no-newline writer"
pattern: "wire\.WriteJSON"
---
<objective>
Restore PHP byte parity for personal-token scope denial by using the shared no-newline JSON writer and making the 401/403 tests compare exact raw bytes.
Purpose: the guard registry and response-convention contracts are only satisfied when TokenScope responses are byte-identical; `json.Encoder.Encode` adds a byte PHP does not send.
Output: `InvScope` delegated to `wire.WriteJSON` and exact-byte denial tests that cannot mask the regression.
</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-01-SUMMARY.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-03-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Emit and assert exact no-newline InvScope denial bodies</name>
<files>fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go, fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go</files>
<behavior>
- No bouncer.User context -> 401, Content-Type application/json, raw body bytes exactly `{"error":"Invalid token"}`.
- Resolved user with an ApiToken lacking `write` -> 403, Content-Type application/json, raw body bytes exactly `{"error":"Missing required scope: write"}`.
- Both bodies have final byte `}` and contain no `\n` or `\r`; tests perform no trimming or whitespace normalization.
- Resolved user plus a token containing the requested scope -> next handler runs and returns its original 204 response.
</behavior>
<read_first>
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go (current local json.Encoder writer)
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go (assertErrorBody currently hides the newline with strings.TrimSpace)
fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_coverage_test.go (wrong-credential fail-closed path that reuses the assertion helper)
summercms.go/wire/response.go (`wire.WriteJSON` exact behavior: SetEscapeHTML(false), trailing newline removed)
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/TokenScope.php (source-of-truth 401/403 payloads)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md (D-08 exact TokenScope bodies)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (fourth authoritative gap)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (WR-11)
</read_first>
<action>
In `token_scope.go`, remove the local `writeJSON` helper and its `encoding/json` import. Import `git.golem15.com/golem15/summercms/wire` and call `wire.WriteJSON` in both denial branches with the existing statuses and exact `map[string]string` payloads. Do not change user/credential lookup, fail-closed wrong-credential behavior, scope text, status codes, or middleware ordering (D-08).
In `token_scope_test.go`, remove `strings.TrimSpace` and compare `rec.Body.Bytes()` or `rec.Body.String()` directly to the expected literal. Keep JSON decoding only as a secondary shape/type assertion after the exact-byte comparison; it must not replace or normalize the wire assertion. Add explicit checks that neither denial body ends in newline/carriage-return. Ensure the named no-user 401 and missing-scope 403 subtests each make their own exact expected-body assertion. Keep the read-scope success case and the wrong-credential 403 coverage green.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/middleware -run 'TestInvScope' -count=1 -race -short &amp;&amp; go vet ./plugins/golem15/fonoteka/middleware &amp;&amp; go test ./plugins/golem15/fonoteka/... -count=1 -short</automated>
</verify>
<acceptance_criteria>
- `token_scope.go` imports and calls `wire.WriteJSON`; it contains no `json.NewEncoder` or local response writer.
- The 401 raw body equals exactly `{"error":"Invalid token"}` and the 403 raw body equals exactly `{"error":"Missing required scope: write"}`.
- `token_scope_test.go` contains no `TrimSpace`, `Trim`, or normalized-body comparison on either denial path.
- Both denial tests retain exact status and Content-Type assertions, while the valid-scope next-handler and wrong-credential fail-closed tests pass.
</acceptance_criteria>
<done>InvScope's 401 and 403 are byte-identical to PHP TokenScope, with exact tests that fail on any trailing newline.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| personal token context -> HTTP denial | Authentication/scope state crosses into a public wire response whose status and bytes are part of the PHP compatibility contract |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-27 | Tampering | `InvScope` 401/403 serialization | mitigate | Use the established `wire.WriteJSON` implementation and raw-byte assertions for both branches; forbid trimming in the regression tests |
| T-06-SC | Tampering | package supply chain | accept | No install or manifest change; `wire` is an existing framework package already used by the phase |
</threat_model>
<verification>
Run exact InvScope tests under `-race`, then the full fonoteka plugin suite. Grep the production and test files to prove `json.NewEncoder` and TrimSpace are absent from the TokenScope path.
</verification>
<success_criteria>
- Personal-token 401 and 403 bodies contain no trailing newline and match PHP bytes exactly.
- Tests compare raw bytes before optional JSON shape checks.
- Scope authorization and fail-closed credential behavior are unchanged.
- The fonoteka middleware and plugin suites pass.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-10-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,169 @@
---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 11
type: execute
wave: 7
depends_on: ["06-07", "06-08", "06-09", "06-10"]
files_modified:
- .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:
- "The security review no longer claims zero open threats until all four corrective plans and both repositories' complete `go test ./... -count=1 -race -short` gates pass"
- "T-06-24 records the anonymous key as exactly `inline:domainless|<ClientIP>` and explicitly excludes the throttle parameter, `r.Host`, `Forwarded` host, and `X-Forwarded-Host` from bucket selection"
- "T-06-23 through T-06-27 map atomic admission, domainless inline keys, transition-address SSRF rejection, clean partial-write panic recovery, and exact InvScope bytes to named passing tests"
- "T-06-24 cites the named Plan 06-07 Host-rotation, cross-inline-parameter shared-budget, and authenticated `u:<id>` isolation regressions"
- "The review's verdict, scope, totals, accepted-risk count, and audit trail agree with the post-fix code and evidence"
- "Any failed corrective test leaves the review open/blocked rather than documenting a false verified state"
artifacts:
- path: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
provides: "Post-gap Phase 6 ASVS L1 threat map and evidence-backed verdict"
key_links:
- from: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
to: summercms.go/surf/limiter_test.go
via: "T-06-23/T-06-24 cite the coordinated concurrency plus the named `TestFixedWindowLimiterInlineThrottleKeys` Host-rotation, cross-inline-parameter shared-budget, and authenticated-principal isolation cases"
pattern: "TestFixedWindowLimiterInlineThrottleKeys"
- from: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
to: summercms.go/fetchguard/ip_test.go
via: "T-06-25 cites NAT64/6to4 embedded-private and public-control cases"
pattern: "T-06-25"
- from: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
to: summercms.go/surf/router_test.go
via: "T-06-26 cites raw and house partial-write panic regressions"
pattern: "T-06-26"
- from: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
to: fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
via: "T-06-27 cites exact no-newline 401/403 byte assertions"
pattern: "T-06-27"
---
<objective>
Refresh the Phase 6 ASVS L1 security review only after all corrective code and tests are green, replacing the stale zero-open conclusion with a complete post-gap threat map and auditable evidence.
Purpose: the review is itself a required phase artifact. Its verdict must describe the current code, not the pre-review snapshot that missed four security/contract failures.
Output: an updated `06-SECURITY-REVIEW.md` covering Plans 06-01 through 06-10 plus the Plan 06-11 review refresh, with all five new threats accurately closed or the phase explicitly blocked.
</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/ROADMAP.md
@.planning/REQUIREMENTS.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-SECURITY-REVIEW.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-07-SUMMARY.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-08-SUMMARY.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-09-SUMMARY.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-10-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Re-run Phase 6 security gates and publish the post-gap threat verdict</name>
<files>.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md</files>
<read_first>
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md (current stale header, threat register, findings, accepts, and audit trail; preserve all still-valid evidence)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (authoritative four code gaps plus stale-review gap)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (CR-01 through CR-04 and WR-11 source evidence)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-07-PLAN.md and 06-07-SUMMARY.md (T-06-23/T-06-24 and actual test names/results)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-08-PLAN.md and 06-08-SUMMARY.md (T-06-25 and actual transition tests/results)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-09-PLAN.md and 06-09-SUMMARY.md (T-06-26 and actual recovery tests/results)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-10-PLAN.md and 06-10-SUMMARY.md (T-06-27 and actual byte-contract tests/results)
surf/limiter.go, surf/limiter_store.go, surf/limiter_test.go (executed atomic admission and stable-key code/evidence)
fetchguard/ip.go, fetchguard/ip_test.go, fetchguard/fetch_test.go (executed transition classifier and dial-time evidence)
surf/router.go, surf/router_test.go (executed buffer/discard recovery and partial-write evidence)
../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go and token_scope_test.go (executed no-newline writer and exact-byte evidence)
</read_first>
<action>
Before editing the review, run the repository-level verification commands below. The authoritative suite gates are exactly `go test ./... -count=1 -race -short` from summercms.go and the same command from sibling fonoteka.go; package-targeted race runs or full suites without `-race` are not substitutes. If any command fails, do not set `status: verified`, do not set `threats_open: 0`, and do not add a zero-open audit row; stop and report the failing threat/test as blocking. High-severity T-06-23 through T-06-26 may not be accepted or deferred.
After all gates pass, update `06-SECURITY-REVIEW.md` frontmatter date/status/threat count and scope so it explicitly covers Plans 06-01 through 06-10 and the Plan 06-11 review refresh. Preserve every existing T-06-01..18, T-06-21, T-06-22, and T-06-SC row and its disposition unless the new code invalidates the old evidence; do not erase accepted-risk rationales or prior audit-trail rows.
Add five mitigate rows and matching detailed `Findings by Threat` sections using the actual executed test names from the four summaries: T-06-23 for atomic threshold check+increment and coordinated Max=1 evidence; T-06-24 for the server-controlled `inline:domainless|<ClientIP>` key; T-06-25 for RFC 6052 well-known/local-use NAT64 plus 6to4 embedded loopback/RFC1918/metadata rejection, public controls, and dialControl proof; T-06-26 for buffer/discard recovery with both raw and house partial-write-then-panic exact response assertions; T-06-27 for `wire.WriteJSON` and raw-byte 401/403 assertions with no trimming. For T-06-24, state explicitly that the anonymous key excludes the throttle `param`, `r.Host`, the `Forwarded` host parameter, and `X-Forwarded-Host`. Cite the exact executed names recorded by Plan 06-07's summary/source for all three inline-key regressions under `TestFixedWindowLimiterInlineThrottleKeys`: the Host-rotation subtest, the same-IP shared-budget subtest spanning different inline throttle parameters, and the authenticated-principal subtest proving independent `u:<id>` buckets. Copy the complete slash-qualified names from the executed test source/summary rather than describing unnamed cases. Do not reduce this to a Host-rotation citation or claim that a throttle parameter selects any portion of the anonymous key. Cite concrete source identifiers and named tests, not only plan numbers.
Update the trust-boundary table for concurrent limiter admission, IPv6 transition decoding, buffered response commit, and token-scope serialization. Update summary prose, accepted-risk count, closed/open totals, and Security Audit Trail consistently. With the existing 21 reviewed IDs plus five new mitigations, the successful review total is 26 threats, 26 closed, zero open; T-06-SC remains one of the 26 and no new accepted risk is introduced. Do not change `06-VERIFICATION.md`; re-verification remains the verifier's next step.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go &amp;&amp; go test ./... -count=1 -race -short &amp;&amp; go vet ./... &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./... -count=1 -race -short &amp;&amp; go vet ./... &amp;&amp; cd ../summercms.go &amp;&amp; test "$(awk -F'|' '/^\| T-06-(23|24|25|26|27) / {c++} END {print c+0}' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md)" -eq 5 &amp;&amp; rg -n '26 \| 26 \| 0|threats_open: 0|Plans 06-01 through 06-10' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; rg -n 'inline:domainless\|&lt;ClientIP&gt;' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; rg -n 'TestFixedWindowLimiterInlineThrottleKeys/.+(Host|host)' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; rg -n 'TestFixedWindowLimiterInlineThrottleKeys/.+(param|policy)' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; rg -n 'TestFixedWindowLimiterInlineThrottleKeys/.+(principal|authenticated|user)' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; rg -n 'u:&lt;id&gt;' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md &amp;&amp; ! rg -n 'inline:&lt;param&gt;\|&lt;ClientIP&gt;|param\+ClientIP|anonymous (bucket|key).*(uses|includes|contains|combines).*(throttle )?param' .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md</automated>
</verify>
<acceptance_criteria>
- The review update occurs after all four plan summaries exist and `go test ./... -count=1 -race -short` plus `go vet ./...` exit 0 in both summercms.go and fonoteka.go.
- The Threat Register contains exactly one row each for T-06-23, T-06-24, T-06-25, T-06-26, and T-06-27, all disposition `mitigate`, each naming executed source/test evidence.
- Findings by Threat contains substantive sections for all five new IDs: coordinated concurrency; exact `inline:domainless|<ClientIP>` construction; named Host-rotation, cross-inline-parameter shared-budget, and authenticated `u:<id>` isolation regressions; all three transition prefixes; both partial-write route kinds; and raw exact 401/403 bytes.
- T-06-24 says the anonymous signature excludes throttle `param`, `r.Host`, `Forwarded` host, and `X-Forwarded-Host`; the source/verification grep rejects stale `inline:<param>|<ClientIP>`, `param+ClientIP`, or equivalent parameter-selected anonymous-key claims.
- Frontmatter, summary, accepted-risk log, threat totals, and the newest audit-trail row agree on 26 total, 26 closed, zero open only when every gate passed.
- Every earlier threat row and audit-trail history remains present; no high-severity gap is silently accepted, deferred, or omitted.
- `06-VERIFICATION.md` is not edited by this plan.
</acceptance_criteria>
<done>The Phase 6 security review truthfully reflects the corrected code and supplies auditable, named test evidence for every previously unresolved security/contract gap.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| implementation evidence -> security verdict | A stale or optimistic review can falsely release security-load-bearing primitives to dependent phases |
| test gates -> documentation state | Zero-open status is allowed only when the exact adversarial tests and both repository suites pass |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-23 | Denial of Service | concurrent limiter admission | mitigate | Require atomic Attempt implementation and coordinated Max=1 passing evidence before review closure |
| T-06-24 | Denial of Service | anonymous inline key selection | mitigate | Require exact `inline:domainless|<ClientIP>` key, exclude throttle param and all request/forwarded Host inputs, and cite named Host-rotation, cross-inline-parameter shared-budget, and authenticated `u:<id>` isolation regressions |
| T-06-25 | Elevation of Privilege / Information Disclosure | transition-address SSRF classification | mitigate | Require NAT64 /96, local-use NAT64 /48, and 6to4 embedded-private plus dialControl tests |
| T-06-26 | Information Disclosure | raw/house panic recovery | mitigate | Require partial-write status/header/body discard tests for both route kinds |
| T-06-27 | Tampering | personal-token denial serialization | mitigate | Require `wire.WriteJSON` and exact untrimmed 401/403 byte comparisons |
| T-06-SC | Tampering | package supply chain | accept | Gap plans add no dependencies; retain the existing Phase 6 package-legitimacy disposition |
</threat_model>
<source_coverage_audit>
| Source | ID | Feature / Requirement | Plan | Status | Notes |
|--------|----|-----------------------|------|--------|-------|
| GOAL | Phase 6 | Secure shared auth groups, 1:1 rate limiting, raw OAuth boundary, and SSRF guard | 06-07..06-11 | COVERED | Four defects fixed in parallel; review refresh follows all code/test plans |
| REQ | HTTP-03 | Mutually exclusive groups share handlers | 06-11 | COVERED (existing + regression gate) | Implemented by 06-01/06-05; full suite confirms no regression |
| REQ | HTTP-04 | Named/inline rate limits and stacking | 06-07, 06-11 | COVERED | Atomic admission and exact `inline:domainless|<ClientIP>` anonymous keys close the current blockers without trusting param or Host input |
| REQ | HTTP-05 | Unified guard registry/current-user accessor | 06-10, 06-11 | COVERED | Existing registry retained; exact personal-token denial contract is verified |
| REQ | HTTP-06 | Response conventions and raw exemption | 06-09, 06-10, 06-11 | COVERED | Clean partial-write recovery and no-newline TokenScope bytes |
| REQ | HTTP-07 | Guarded outbound fetch | 06-08, 06-11 | COVERED | Transition addresses receive embedded IPv4 classification at dial time |
| REQ | HTTP-08 | OpenAPI/type generation | 06-11 | COVERED (existing + regression gate) | Implemented by 06-03; no authoritative current gap requests replanning |
| REQ | HTTP-09 | CORS/body limits | 06-11 | COVERED (existing + regression gate) | Implemented by 06-03; no authoritative current gap requests replanning |
| RESEARCH | Fixed-window semantics | First-hit fixed window and PHP headers/body | 06-07 | COVERED | Atomic API preserves the established sequential contract |
| RESEARCH | SSRF dial-time guard | Actual connected address is classified | 06-08 | COVERED | Transition extraction feeds the existing dial-time classifier |
| CONTEXT | D-01..D-05 | Five buckets, fixed-window parity, stdlib store, trusted ClientIP, parameterized names | 06-07 | COVERED | No bucket limit/key ownership or middleware syntax is reduced |
| CONTEXT | D-06..D-10 | Guard registry, real token guard, exact InvScope bodies, no OAuth stub, unchanged JWT | 06-10 | COVERED | D-08 exact bytes repaired; remaining decisions preserved |
| CONTEXT | D-11..D-14 | AllowHosts/PublicOnly, dial-time rejection, typed failures, caps/timeouts | 06-08 | COVERED | Transition forms added without changing caller scope or limits |
| CONTEXT | D-15..D-18 | Group subsets, raw bare recovery, response conventions/OpenAPI, CORS/body limits | 06-09, 06-11 | COVERED | D-16 is upheld after partial writes; unrelated existing outputs regression-tested |
| CONTEXT | Deferred Ideas | Later route handlers/call sites | — | EXCLUDED | Explicitly out of Phase 6 and absent from authoritative `gaps:` |
| REVIEW | Warnings outside verifier gaps | WR-01..WR-10 except promoted WR-11 | — | EXCLUDED | Gap-closure mode plans only authoritative `06-VERIFICATION.md` gaps; these remain review backlog, not silently claimed fixed |
</source_coverage_audit>
<verification>
Run `go test ./... -count=1 -race -short` and `go vet ./...` in both repositories before documentation can claim verified/zero-open. Validate five new unique threat rows, matching detailed findings, consistent 26/26/0 totals, preserved prior evidence, and a scope statement covering the corrective plans. Assert positively that T-06-24 names `inline:domainless|<ClientIP>` and the three Plan 06-07 inline-key regressions, and fail if the review contains `inline:<param>|<ClientIP>`, `param+ClientIP`, or an equivalent parameter-selected anonymous-key claim. If any command or assertion fails, leave the verdict open and stop.
</verification>
<success_criteria>
- The stale Phase 6 security verdict is replaced by evidence matching the post-gap code.
- T-06-23 through T-06-27 are each closed by concrete named tests, never by assertion alone.
- T-06-24 documents only `inline:domainless|<ClientIP>`, explicitly excludes throttle param and all request/forwarded Host input, and cites the named Host-rotation, cross-inline-parameter shared-budget, and authenticated `u:<id>` isolation tests.
- The successful audit is internally consistent at 26 total / 26 closed / 0 open with no new accepted risk.
- A failed full-repository `-race` suite, vet gate, or anonymous-key source assertion in either repository cannot produce a verified/zero-open document.
- All four source categories are fully covered without planning deferred items or non-authoritative warnings.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-11-SUMMARY.md` when done.
</output>