fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)

- Router.wrap applies the route's profile outside recovery, body limit and
  locale, whatever its position in the list or the group nesting
- bufferedResponse.commit merges Vary instead of replacing it
- TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary
  required by name in the gate
This commit is contained in:
Jakub Zych
2026-10-15 22:24:08 +02:00
parent e9ad640999
commit 072353663e
7 changed files with 226 additions and 11 deletions

View File

@@ -28,7 +28,7 @@ Use cases:
- Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot.
- Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500.
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `cors:<profile>`, `turnstile:open` and `turnstile:closed` (Cloudflare Turnstile through `bouncer.Turnstile`, built from `turnstile.secret`, with the client IP from `surf.ClientIP` and refusals written by a published `bouncer.TurnstileWriter` when there is one), `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
- Named CORS profiles: `cors:<profile>` applies `http.cors_profiles.<profile>` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route group, and surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot.
- Named CORS profiles: `cors:<profile>` applies `http.cors_profiles.<profile>` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route or route group. The profile wraps every other layer of the route, whatever its position in the list or the group nesting, so every response carries its headers: guard refusals (401, 403), throttle (429), Turnstile (422, 503), the body limit (413) and recovered panics (500) included. A handler's own `Vary` is merged with the profile's `Vary: Origin`, not replacing it. surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot.
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`).
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.