feat(06-03): add path-scoped CORS and per-route body limits

- CORS matches Laravel path globs (api/* includes nested segments); unlisted paths get no headers
- Non-raw routes wrap http.MaxBytesReader from http.body_limits.default_bytes; body.limit:N overrides innermost
- Raw routes stay uncapped at this layer
This commit is contained in:
Jakub Zych
2026-09-19 20:10:02 +02:00
parent 9ecf2d1868
commit 30539b954f
7 changed files with 510 additions and 34 deletions

View File

@@ -41,16 +41,19 @@ type route struct {
// Router compiles group declarations onto net/http ServeMux.
type Router struct {
pluginID string
prefix string
middleware []string
named map[string]namedMiddleware
factories map[string]namedMiddlewareFactory
routes []route
seen map[string]string
origins []string
compileErr error
limiter *FixedWindowLimiter
pluginID string
prefix string
middleware []string
named map[string]namedMiddleware
factories map[string]namedMiddlewareFactory
routes []route
seen map[string]string
origins []string
compileErr error
limiter *FixedWindowLimiter
corsCfg CORSConfig
defaultBytes int64
uploadBytes int64
}
var (
@@ -342,11 +345,18 @@ func (r *Router) compile() (http.Handler, error) {
}
mux.Handle(rt.method+" "+rt.path, h)
}
return cors(r.origins, mux), nil
return pathScopedCORS(r.corsCfg, mux), nil
}
func (r *Router) wrap(rt route) (http.Handler, error) {
h := constrain(rt.handler, rt.constraints)
limit, err := routeBodyLimit(rt, r.defaultBytes)
if err != nil {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
}
if limit > 0 {
h = bodyLimit(limit)(h)
}
h = orgSlot(h)
for i := len(rt.middleware) - 1; i >= 0; i-- {
name := rt.middleware[i]
@@ -368,6 +378,11 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
}
}
if base == "body.limit" {
if _, err := parseBodyLimit(param); err != nil {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
}
}
h = factory.fn(param)(h)
continue
}
@@ -408,6 +423,23 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
}); err != nil {
return nil, err
}
if err := r.RegisterMiddlewareFactory("surf", "body.limit", func(param string) pact.Middleware {
// Limit is applied innermost in wrap(); the factory only occupies the name.
return func(next http.Handler) http.Handler { return next }
}); err != nil {
return nil, err
}
if app != nil {
corsCfg, err := LoadCORSConfig(app.Config)
if err != nil {
return nil, err
}
r.corsCfg = corsCfg
if app.Config != nil {
r.defaultBytes = int64(app.Config.Int("http.body_limits.default_bytes"))
r.uploadBytes = int64(app.Config.Int("http.body_limits.upload_bytes"))
}
}
for _, p := range plugins {
if hm, ok := p.(pact.HasMiddleware); ok {
for name, fn := range hm.Middlewares() {
@@ -509,27 +541,6 @@ func recoverBare(next http.Handler) http.Handler {
})
}
func cors(origins []string, next http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := allowed[origin]; ok && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func locale(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))))