feat(19.3-02): cookie token refresh across subdomains with hardened credentialed CORS

- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
  same_site none without Secure, secure false in production), TokenCookie with
  Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
  CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
  patterns at boot, turns wildcard origins into anchored patterns, echoes the
  exact origin and sends Vary: Origin on every dynamic CORS answer; the
  non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
This commit is contained in:
Jakub Zych
2026-10-15 17:49:35 +02:00
parent 866e4fd3e0
commit e7659f2f4b
7 changed files with 748 additions and 54 deletions

View File

@@ -31,7 +31,7 @@ Use cases:
- 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.
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. `surf.LoadCORSConfig` refuses credentials with a `*` origin and origin patterns that do not compile; an allowed origin is echoed exactly, with `Vary: Origin` on every response whose answer depends on the request origin. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET <storage.uploads.public_path_prefix>/{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`).
@@ -149,13 +149,13 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `http.body_limits.upload_bytes` | none, required | Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot. |
| `http.trusted_proxies` | empty | List of CIDRs whose `X-Forwarded-For` header is trusted when resolving the client IP. Malformed entries are skipped. |
| `http.cors.paths` | empty | Path globs (`api/*`) that get CORS headers. With no `http.cors` section no CORS headers are sent. |
| `http.cors.allowed_origins` | empty | Allowed origins; `*` allows any. |
| `http.cors.allowed_origins_patterns` | empty | Regular expressions matched against the origin. |
| `http.cors.allowed_origins` | empty | Allowed origins, echoed exactly; `*` allows any (without credentials only). An entry with a `*` inside, such as `https://*.example.com`, matches any characters there, as Laravel's `Str::is` does. |
| `http.cors.allowed_origins_patterns` | empty | Go regular expressions matched against the origin. A pattern that does not compile, or one still wrapped in PHP delimiters (`#^...$#`), fails boot. |
| `http.cors.allowed_methods` | empty | Methods sent in `Access-Control-Allow-Methods`; `*` allows any. |
| `http.cors.allowed_headers` | empty | Headers sent in `Access-Control-Allow-Headers`; `*` allows any. |
| `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`. |
| `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.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`. |

View File

@@ -1,6 +1,7 @@
package surf
import (
"fmt"
"net/http"
"regexp"
"strconv"
@@ -22,7 +23,9 @@ type CORSConfig struct {
}
// LoadCORSConfig reads http.cors. Missing section yields a zero config (no
// path matches, so no CORS headers).
// path matches, so no CORS headers). The config is validated: credentials
// with a "*" origin, an origin pattern that does not compile and a pattern
// still wrapped in PHP delimiters (#^...$#) are errors naming the key.
func LoadCORSConfig(cfg *compass.Config) (CORSConfig, error) {
var out CORSConfig
if cfg == nil || !cfg.Has("http.cors") {
@@ -31,9 +34,162 @@ func LoadCORSConfig(cfg *compass.Config) (CORSConfig, error) {
if err := cfg.LoadSection("http.cors", &out); err != nil {
return out, err
}
if err := validateCORSConfig("http.cors", out); err != nil {
return out, err
}
return out, nil
}
// validateCORSConfig checks a CORS block; prefix is its config path
// (http.cors or http.cors_profiles.<name>) for the error messages.
func validateCORSConfig(prefix string, cfg CORSConfig) error {
if cfg.SupportsCredentials && containsStar(cfg.AllowedOrigins) {
return fmt.Errorf("surf: %s.supports_credentials is true with \"*\" in %s.allowed_origins: a credentialed answer would trust every origin; list the exact origins (or use %s.allowed_origins_patterns)", prefix, prefix, prefix)
}
for i, p := range cfg.AllowedOriginsPatterns {
if phpDelimited(p) {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is wrapped in PHP delimiters; drop the leading and trailing %q (and any flags): Go patterns take none", prefix, i, p, p[:1])
}
if _, err := regexp.Compile(p); err != nil {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q does not compile: %v", prefix, i, p, err)
}
}
return nil
}
// phpDelimited reports a preg pattern such as #^https://x$# or /^x$/i: the
// first byte is # or /, the same byte closes the pattern and only flag
// letters follow it. An origin pattern never starts with either byte.
func phpDelimited(p string) bool {
if len(p) < 2 || (p[0] != '#' && p[0] != '/') {
return false
}
end := strings.LastIndexByte(p, p[0])
if end <= 0 {
return false
}
for _, c := range p[end+1:] {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
return false
}
}
return true
}
// wildcardOriginPattern turns an allowed_origins entry containing "*" into an
// anchored pattern, as php-cors convertWildcardToPattern does: the rest is
// quoted and each "*" matches any characters.
func wildcardOriginPattern(origin string) string {
parts := strings.Split(origin, "*")
for i, part := range parts {
parts[i] = regexp.QuoteMeta(part)
}
return "^" + strings.Join(parts, ".*") + "$"
}
// corsPolicy is the per-request CORS logic shared by the global path-scoped
// wrapper and the cors:<profile> middleware.
type corsPolicy struct {
cfg CORSConfig
origins map[string]struct{}
originPats []*regexp.Regexp
allowAnyOrigin bool
allowAnyMethod bool
allowAnyHeader bool
// dynamic is true when the Allow-Origin answer depends on the request
// Origin (credentials, patterns or several origins): every response then
// carries Vary: Origin, so a shared cache never serves one origin's
// answer to another.
dynamic bool
methods string
headers string
exposed string
}
func newCORSPolicy(cfg CORSConfig) *corsPolicy {
p := &corsPolicy{
cfg: cfg,
origins: make(map[string]struct{}, len(cfg.AllowedOrigins)),
allowAnyOrigin: containsStar(cfg.AllowedOrigins),
allowAnyMethod: containsStar(cfg.AllowedMethods),
allowAnyHeader: containsStar(cfg.AllowedHeaders),
methods: strings.Join(cfg.AllowedMethods, ", "),
headers: strings.Join(cfg.AllowedHeaders, ", "),
exposed: strings.Join(cfg.ExposedHeaders, ", "),
}
for _, pat := range cfg.AllowedOriginsPatterns {
re, err := regexp.Compile(pat)
if err != nil {
continue // LoadCORSConfig refuses it; a hand-built config skips it
}
p.originPats = append(p.originPats, re)
}
for _, o := range cfg.AllowedOrigins {
switch {
case o == "" || o == "*":
case strings.Contains(o, "*"):
if !p.allowAnyOrigin {
p.originPats = append(p.originPats, regexp.MustCompile(wildcardOriginPattern(o)))
}
default:
p.origins[o] = struct{}{}
}
}
p.dynamic = cfg.SupportsCredentials || len(p.originPats) > 0 || len(p.origins) > 1
return p
}
// apply sets the CORS response headers for origin and reports whether the
// origin is allowed.
func (p *corsPolicy) apply(h http.Header, origin string) bool {
allowed, value := corsAllowOrigin(p.allowAnyOrigin, p.origins, p.originPats, origin)
if p.dynamic || (allowed && value != "*") {
addVary(h, "Origin")
}
if !allowed {
return false
}
h.Set("Access-Control-Allow-Origin", value)
if p.allowAnyMethod {
h.Set("Access-Control-Allow-Methods", "*")
} else if p.methods != "" {
h.Set("Access-Control-Allow-Methods", p.methods)
}
if p.allowAnyHeader {
h.Set("Access-Control-Allow-Headers", "*")
} else if p.headers != "" {
h.Set("Access-Control-Allow-Headers", p.headers)
}
if p.exposed != "" {
h.Set("Access-Control-Expose-Headers", p.exposed)
}
if p.cfg.MaxAge > 0 {
h.Set("Access-Control-Max-Age", strconv.Itoa(p.cfg.MaxAge))
}
if p.cfg.SupportsCredentials {
h.Set("Access-Control-Allow-Credentials", "true")
}
return true
}
// preflight answers an OPTIONS request from the policy.
func (p *corsPolicy) preflight(w http.ResponseWriter, r *http.Request) {
allowed := p.apply(w.Header(), r.Header.Get("Origin"))
writeOptions(w, r, allowed, p.allowAnyMethod, p.allowAnyHeader)
}
// addVary adds value to the Vary header unless a Vary line already names it.
func addVary(h http.Header, value string) {
for _, line := range h.Values("Vary") {
for _, v := range strings.Split(line, ",") {
if strings.EqualFold(strings.TrimSpace(v), value) {
return
}
}
}
h.Add("Vary", value)
}
func pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler {
globs := make([]*regexp.Regexp, 0, len(cfg.Paths))
for _, p := range cfg.Paths {
@@ -41,62 +197,17 @@ func pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler {
globs = append(globs, re)
}
}
originPats := make([]*regexp.Regexp, 0, len(cfg.AllowedOriginsPatterns))
for _, p := range cfg.AllowedOriginsPatterns {
re, err := regexp.Compile(p)
if err != nil {
continue
}
originPats = append(originPats, re)
}
allowAnyOrigin := containsStar(cfg.AllowedOrigins)
allowAnyMethod := containsStar(cfg.AllowedMethods)
allowAnyHeader := containsStar(cfg.AllowedHeaders)
origins := make(map[string]struct{}, len(cfg.AllowedOrigins))
for _, o := range cfg.AllowedOrigins {
if o != "" && o != "*" {
origins[o] = struct{}{}
}
}
methods := strings.Join(cfg.AllowedMethods, ", ")
headers := strings.Join(cfg.AllowedHeaders, ", ")
exposed := strings.Join(cfg.ExposedHeaders, ", ")
policy := newCORSPolicy(cfg)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !pathMatchesCORS(globs, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
allowed, value := corsAllowOrigin(allowAnyOrigin, origins, originPats, origin)
if allowed {
w.Header().Set("Access-Control-Allow-Origin", value)
if value != "*" {
w.Header().Set("Vary", "Origin")
}
if allowAnyMethod {
w.Header().Set("Access-Control-Allow-Methods", "*")
} else if methods != "" {
w.Header().Set("Access-Control-Allow-Methods", methods)
}
if allowAnyHeader {
w.Header().Set("Access-Control-Allow-Headers", "*")
} else if headers != "" {
w.Header().Set("Access-Control-Allow-Headers", headers)
}
if exposed != "" {
w.Header().Set("Access-Control-Expose-Headers", exposed)
}
if cfg.MaxAge > 0 {
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
}
if cfg.SupportsCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
}
if r.Method == http.MethodOptions {
writeOptions(w, r, allowed, allowAnyMethod, allowAnyHeader)
policy.preflight(w, r)
return
}
policy.apply(w.Header(), r.Header.Get("Origin"))
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,184 @@
package surf
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke tests for plan 02 of phase 19.3 (cookie transport, credentialed CORS,
// CORS profiles, Turnstile, api-docs); plan 05 owns full coverage.
const transportBodyLimits = `body_limits:
default_bytes: 1048576
upload_bytes: 1048576
`
// transportApp builds an app from config files (name -> YAML body) in
// environment env.
func transportApp(t *testing.T, env string, files map[string]string) *backpack.App {
t.Helper()
dir := t.TempDir()
if _, ok := files["app.yaml"]; !ok {
files["app.yaml"] = "name: transport-test\n"
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=" + env}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
// routesPluginFunc is a test plugin whose routes come from a func.
type routesPluginFunc struct {
id string
routes func(pact.Router)
}
func (p routesPluginFunc) ID() string { return p.id }
func (p routesPluginFunc) Requires() []string { return nil }
func (p routesPluginFunc) Register(*backpack.App) error { return nil }
func (p routesPluginFunc) Boot(*backpack.App) error { return nil }
func (p routesPluginFunc) Routes(r pact.Router) error {
p.routes(r)
return nil
}
func varyHas(h http.Header, value string) bool {
for _, line := range h.Values("Vary") {
for _, v := range strings.Split(line, ",") {
if strings.EqualFold(strings.TrimSpace(v), value) {
return true
}
}
}
return false
}
func TestCookieRefreshAcrossSubdomainsSmoke(t *testing.T) {
const secret = "transport-smoke-secret-with-32-bytes-at-least"
const issuer = "https://api.example.com/api/auth/login"
app := transportApp(t, "development", map[string]string{
"app.yaml": "name: transport-test\nurl: https://api.example.com\n",
"auth.yaml": "cookie:\n domain: example.com\n",
"http.yaml": transportBodyLimits + `cors:
paths: ["api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
`,
})
cookieCfg, err := bouncer.LoadCookieConfig(app.Config)
if err != nil {
t.Fatal(err)
}
refreshTTL := 20160 * time.Minute
bl := bouncer.NewMemoryBlacklist()
refresh, err := bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
Cookie: cookieCfg,
RefreshTTL: refreshTTL,
Refresh: func(_ context.Context, token string) (string, error) {
return bouncer.Refresh(secret, token, refreshTTL, bl, 0, issuer)
},
Write: func(w http.ResponseWriter, _ *http.Request, res bouncer.CookieResult) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Status)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": res.Err == nil})
},
})
if err != nil {
t.Fatal(err)
}
plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) {
r.Post("/api/auth/refresh", refresh.ServeHTTP)
}}
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
old, oldJTI, err := bouncer.Mint(secret, "42", issuer, time.Hour)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", nil)
req.Header.Set("Origin", "https://www.example.com")
req.AddCookie(&http.Cookie{Name: "token", Value: old})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("refresh status = %d body %s", rec.Code, rec.Body.String())
}
setCookie := rec.Header().Get("Set-Cookie")
c, err := http.ParseSetCookie(setCookie)
if err != nil {
t.Fatalf("Set-Cookie %q: %v", setCookie, err)
}
if c.Name != "token" || c.Value == "" || c.Value == old {
t.Fatalf("cookie %s=%q, want a rotated token", c.Name, c.Value)
}
for _, want := range []string{"Domain=example.com", "Path=/", "Max-Age=1209600", "HttpOnly", "Secure", "SameSite=Lax"} {
if !strings.Contains(setCookie, want) {
t.Errorf("Set-Cookie %q lacks %s", setCookie, want)
}
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Allow-Credentials = %q", got)
}
if !varyHas(rec.Header(), "Origin") {
t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary"))
}
if revoked, _ := bl.IsBlacklisted(context.Background(), oldJTI); !revoked {
t.Error("the old jti is not blacklisted")
}
pre := httptest.NewRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", nil)
pre.Header.Set("Origin", "https://www.example.com")
pre.Header.Set("Access-Control-Request-Method", "POST")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, pre)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", rec.Code)
}
if !varyHas(rec.Header(), "Origin") {
t.Errorf("preflight Vary = %q, want Origin", rec.Header().Values("Vary"))
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
t.Errorf("preflight Allow-Origin = %q", got)
}
}
func TestCORSCredentialsWildcardRefusedSmoke(t *testing.T) {
app := transportApp(t, "development", map[string]string{
"http.yaml": transportBodyLimits + `cors:
paths: ["api/*"]
allowed_origins: ["*"]
supports_credentials: true
`,
})
_, err := BuildRouter(app, nil)
if err == nil || !strings.Contains(err.Error(), "http.cors") {
t.Fatalf("BuildRouter error = %v, want one naming http.cors", err)
}
}