---
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"
---
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.
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
@.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
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.
Task 1: Buffer route responses so panic recovery can discard partial output
summercms.go/surf/router.go, summercms.go/surf/router_test.go
- 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.
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)
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.
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./surf -run 'Test.*(Recover|Panic|BufferedResponse|RawGroup)' -count=1 -race -short && go vet ./surf && go test ./surf -count=1 -race -short
- 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`.
House and raw panics produce clean, contract-exact 500 responses even after status/body writes, while successful responses commit normally.
## 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 |
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.
- 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.