feat(19.3-02): per-group CORS policies through cors:<profile> with router-registered preflights
- surf: LoadCORSProfiles reads http.cors_profiles.<name> (http.cors keys without paths, validated the same way); factory cors:<profile> sets the profile's headers; an unknown profile fails boot naming plugin and route - BuildRouter registers OPTIONS <path> (owner surf, recovery only) for every path whose routes name cors:<profile>; two profiles on one path, an existing OPTIONS route or overlap with http.cors.paths fail boot - docs: CORS profiles section, middleware table and README rows
This commit is contained in:
@@ -27,7 +27,8 @@ Use cases:
|
||||
- Overlapping constrained routes: two routes that ServeMux would refuse side by side, such as `/shelves/items/{id}` and `/shelves/{shelfId}/follow`, boot as one overlap family when their constraints keep them apart. Requests are tried against the family's routes in registration order, as Laravel does, and each route keeps its own path values and middleware.
|
||||
- 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>`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
|
||||
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `cors:<profile>`, `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.
|
||||
- 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.
|
||||
@@ -134,6 +135,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.ClientIP` | Resolves the client IP, honouring trusted proxies. |
|
||||
| `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. |
|
||||
| `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. |
|
||||
| `surf.LoadCORSProfiles` | Reads and validates the `http.cors_profiles.<name>` blocks the `cors:<profile>` middleware uses. |
|
||||
| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. |
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
@@ -156,6 +158,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. |
|
||||
| `http.cors.max_age` | `0` | Preflight cache time in seconds. |
|
||||
| `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. Combined with `*` in `allowed_origins` it fails boot. With credentials, a pattern or several origins, every response on a CORS path carries `Vary: Origin`, preflights and disallowed origins included. |
|
||||
| `http.cors_profiles.<name>.*` | none | A CORS profile for `cors:<name>`: the `http.cors` keys except `paths` (`allowed_origins`, `allowed_origins_patterns`, `allowed_methods`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`), validated the same way. Names use lower-case letters, digits, underscore and dash. |
|
||||
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
@@ -212,6 +212,145 @@ func pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// corsProfileName is the character set of a cors_profiles name, the plugin-id
|
||||
// set without dots: lower-case letters, digits, underscore and dash.
|
||||
var corsProfileName = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
||||
|
||||
// LoadCORSProfiles reads every child of http.cors_profiles. A profile takes
|
||||
// the http.cors keys except paths (it applies to the routes that name
|
||||
// cors:<profile>) and is validated like http.cors, with errors naming
|
||||
// http.cors_profiles.<name>.<key>. A missing section yields an empty map.
|
||||
func LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error) {
|
||||
out := map[string]CORSConfig{}
|
||||
if cfg == nil || !cfg.Has("http.cors_profiles") {
|
||||
return out, nil
|
||||
}
|
||||
raw, _ := cfg.Lookup("http.cors_profiles")
|
||||
tree, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("surf: http.cors_profiles must be a map of profile names, got %T", raw)
|
||||
}
|
||||
for name := range tree {
|
||||
if !corsProfileName.MatchString(name) {
|
||||
return nil, fmt.Errorf("surf: http.cors_profiles.%s: a profile name uses lower-case letters, digits, underscore and dash", name)
|
||||
}
|
||||
prefix := "http.cors_profiles." + name
|
||||
var pc CORSConfig
|
||||
if err := cfg.LoadSection(prefix, &pc); err != nil {
|
||||
return nil, fmt.Errorf("surf: %s: %w", prefix, err)
|
||||
}
|
||||
if len(pc.Paths) > 0 {
|
||||
return nil, fmt.Errorf("surf: %s.paths is not allowed: a profile applies to the routes that name cors:%s", prefix, name)
|
||||
}
|
||||
if err := validateCORSConfig(prefix, pc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = pc
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// profileMiddleware sets the profile's CORS headers on a non-OPTIONS request;
|
||||
// preflights reach the OPTIONS route compile registers instead.
|
||||
func (p *corsPolicy) profileMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodOptions {
|
||||
p.apply(w.Header(), r.Header.Get("Origin"))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// routeCORSProfile returns the profile a route names through cors:<profile>,
|
||||
// or "" when it names none. Two different profiles on one route are an error.
|
||||
func routeCORSProfile(rt route) (string, error) {
|
||||
profile := ""
|
||||
for _, name := range rt.middleware {
|
||||
base, param, ok := strings.Cut(name, ":")
|
||||
if !ok || base != "cors" {
|
||||
continue
|
||||
}
|
||||
if profile != "" && profile != param {
|
||||
return "", fmt.Errorf("surf: route %s %s (plugin %q) names two CORS profiles, %q and %q", rt.method, rt.path, rt.pluginID, profile, param)
|
||||
}
|
||||
profile = param
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// addCORSPreflights registers one OPTIONS route per path whose routes name
|
||||
// cors:<profile>, answering the preflight from that profile. ServeMux would
|
||||
// otherwise answer such a preflight with 405 before any route middleware
|
||||
// runs. A path that two profiles claim, a path the global http.cors.paths
|
||||
// also matches (the global wrapper would answer its preflight first) and an
|
||||
// OPTIONS route a plugin already declared are boot errors.
|
||||
func (r *Router) addCORSPreflights() error {
|
||||
if len(r.corsProfiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
globs := make([]*regexp.Regexp, 0, len(r.corsCfg.Paths))
|
||||
for _, p := range r.corsCfg.Paths {
|
||||
if re := compileLaravelGlob(p); re != nil {
|
||||
globs = append(globs, re)
|
||||
}
|
||||
}
|
||||
type claim struct {
|
||||
profile string
|
||||
owner route
|
||||
}
|
||||
claims := map[string]claim{}
|
||||
var order []string
|
||||
for _, rt := range r.routes {
|
||||
if rt.preflight {
|
||||
continue
|
||||
}
|
||||
profile, err := routeCORSProfile(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := r.corsProfiles[profile]; !ok {
|
||||
return fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, profile, profile)
|
||||
}
|
||||
if pathMatchesCORS(globs, rt.path) {
|
||||
return fmt.Errorf("surf: route %s %s (plugin %q) names cors:%s but its path also matches http.cors.paths; the global CORS wrapper would answer its preflight first", rt.method, rt.path, rt.pluginID, profile)
|
||||
}
|
||||
if prev, ok := claims[rt.path]; ok {
|
||||
if prev.profile != profile {
|
||||
return fmt.Errorf("surf: path %s has routes with different CORS profiles: %s %s (plugin %q) names cors:%s, %s %s (plugin %q) names cors:%s", rt.path, prev.owner.method, prev.owner.path, prev.owner.pluginID, prev.profile, rt.method, rt.path, rt.pluginID, profile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
claims[rt.path] = claim{profile: profile, owner: rt}
|
||||
order = append(order, rt.path)
|
||||
}
|
||||
for _, path := range order {
|
||||
c := claims[path]
|
||||
key := http.MethodOptions + " " + path
|
||||
if prev, ok := r.seen[key]; ok {
|
||||
return fmt.Errorf("surf: route %s registered by %s collides with the preflight route cors:%s needs", key, prev, c.profile)
|
||||
}
|
||||
r.seen[key] = corsPreflightOwner
|
||||
policy := r.corsProfiles[c.profile]
|
||||
r.routes = append(r.routes, route{
|
||||
pluginID: corsPreflightOwner,
|
||||
method: http.MethodOptions,
|
||||
path: path,
|
||||
handler: http.HandlerFunc(policy.preflight),
|
||||
constraints: append([]Constraint(nil), c.owner.constraints...),
|
||||
raw: true,
|
||||
preflight: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// corsPreflightOwner is the plugin ID route:list shows for the OPTIONS
|
||||
// routes addCORSPreflights registers.
|
||||
const corsPreflightOwner = "surf"
|
||||
|
||||
// writeOptions answers an OPTIONS request on a CORS path with 204 and the
|
||||
// headers Laravel's HandleCors (fruitcake/php-cors) sends, so recorded PHP
|
||||
// preflights replay unchanged. Every answer carries Symfony's default
|
||||
|
||||
@@ -41,6 +41,9 @@ type route struct {
|
||||
middleware []string
|
||||
constraints []Constraint
|
||||
raw bool
|
||||
// preflight marks an OPTIONS route addCORSPreflights registered for a
|
||||
// cors:<profile> path: wrapped only in recovery and recordPattern.
|
||||
preflight bool
|
||||
}
|
||||
|
||||
// Router compiles group declarations onto net/http ServeMux.
|
||||
@@ -56,6 +59,7 @@ type Router struct {
|
||||
compileErr error
|
||||
limiter *FixedWindowLimiter
|
||||
corsCfg CORSConfig
|
||||
corsProfiles map[string]*corsPolicy
|
||||
defaultBytes int64
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
@@ -397,6 +401,11 @@ func handleRoute(mux *http.ServeMux, rt route, h http.Handler) (err error) {
|
||||
}
|
||||
|
||||
func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
if rt.preflight {
|
||||
// Preflights carry no credentials: no auth, throttle, body or locale.
|
||||
h := recoverBare(constrain(rt.handler, rt.constraints))
|
||||
return recordPattern(rt.method+" "+rt.path, h), nil
|
||||
}
|
||||
h := constrain(rt.handler, rt.constraints)
|
||||
limit, err := routeBodyLimit(rt, r.defaultBytes)
|
||||
if err != nil {
|
||||
@@ -423,6 +432,11 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
|
||||
}
|
||||
}
|
||||
if base == "cors" {
|
||||
if _, ok := r.corsProfiles[param]; !ok {
|
||||
return nil, fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, param, param)
|
||||
}
|
||||
}
|
||||
if base == "body.limit" {
|
||||
if _, err := parseBodyLimit(param); err != nil {
|
||||
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
|
||||
@@ -500,12 +514,26 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if err := r.RegisterMiddleware("surf", "locale.from-principal", LocaleFromPrincipal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.corsProfiles = map[string]*corsPolicy{}
|
||||
if err := r.RegisterMiddlewareFactory("surf", "cors", func(param string) pact.Middleware {
|
||||
// wrap refuses an unknown profile before the factory runs.
|
||||
return r.corsProfiles[param].profileMiddleware
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if app != nil {
|
||||
corsCfg, err := LoadCORSConfig(app.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.corsCfg = corsCfg
|
||||
profiles, err := LoadCORSProfiles(app.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, pc := range profiles {
|
||||
r.corsProfiles[name] = newCORSPolicy(pc)
|
||||
}
|
||||
if app.Config != nil {
|
||||
d, err := requiredBytes(app, "http.body_limits.default_bytes")
|
||||
if err != nil {
|
||||
@@ -586,6 +614,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := r.addCORSPreflights(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -182,3 +182,83 @@ func TestCORSCredentialsWildcardRefusedSmoke(t *testing.T) {
|
||||
t.Fatalf("BuildRouter error = %v, want one naming http.cors", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSProfileSmoke(t *testing.T) {
|
||||
const www = "https://www.example.com"
|
||||
files := func() map[string]string {
|
||||
return map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
|
||||
spa:
|
||||
allowed_origins: ["https://www.example.com"]
|
||||
allowed_methods: ["GET", "POST"]
|
||||
allowed_headers: ["Content-Type"]
|
||||
supports_credentials: true
|
||||
`}
|
||||
}
|
||||
plugin := func(profile string) routesPluginFunc {
|
||||
return routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
||||
r.Group("/x", Use("cors:"+profile), func(g pact.Router) {
|
||||
g.Get("/me", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
})
|
||||
}}
|
||||
}
|
||||
app := transportApp(t, "development", files())
|
||||
router, err := BuildRouter(app, []party.Plugin{plugin("spa")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed := false
|
||||
for _, ri := range router.Routes() {
|
||||
if ri.Method == http.MethodOptions && ri.Pattern == "/x/me" && ri.PluginID == "surf" {
|
||||
listed = true
|
||||
}
|
||||
}
|
||||
if !listed {
|
||||
t.Errorf("route table lacks OPTIONS /x/me owned by surf: %+v", router.Routes())
|
||||
}
|
||||
h, err := router.compile()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pre := httptest.NewRequest(http.MethodOptions, "/x/me", nil)
|
||||
pre.Header.Set("Origin", www)
|
||||
pre.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, pre)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != www {
|
||||
t.Errorf("preflight Allow-Origin = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("preflight Allow-Credentials = %q", got)
|
||||
}
|
||||
if !varyHas(rec.Header(), "Origin") {
|
||||
t.Errorf("preflight Vary = %q", rec.Header().Values("Vary"))
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
||||
get.Header.Set("Origin", www)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www {
|
||||
t.Errorf("GET = %d, Allow-Origin %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
evil := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
||||
evil.Header.Set("Origin", "https://evil.example.org")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, evil)
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("disallowed origin Allow-Origin = %q", got)
|
||||
}
|
||||
if !varyHas(rec.Header(), "Origin") {
|
||||
t.Errorf("disallowed origin Vary = %q", rec.Header().Values("Vary"))
|
||||
}
|
||||
|
||||
_, err = BuildRouter(transportApp(t, "development", files()), []party.Plugin{plugin("missing")})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("BuildRouter with cors:missing error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user