Files
summercms/modules/surf/cors.go
Jakub Zych e7659f2f4b 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
2026-10-15 17:49:35 +02:00

303 lines
9.3 KiB
Go

package surf
import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/compass"
)
// CORSConfig matches Laravel config/cors.php keys.
type CORSConfig struct {
Paths []string `koanf:"paths"`
AllowedMethods []string `koanf:"allowed_methods"`
AllowedOrigins []string `koanf:"allowed_origins"`
AllowedOriginsPatterns []string `koanf:"allowed_origins_patterns"`
AllowedHeaders []string `koanf:"allowed_headers"`
ExposedHeaders []string `koanf:"exposed_headers"`
MaxAge int `koanf:"max_age"`
SupportsCredentials bool `koanf:"supports_credentials"`
}
// LoadCORSConfig reads http.cors. Missing section yields a zero config (no
// 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") {
return out, nil
}
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 {
if re := compileLaravelGlob(p); re != nil {
globs = append(globs, re)
}
}
policy := newCORSPolicy(cfg)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !pathMatchesCORS(globs, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if r.Method == http.MethodOptions {
policy.preflight(w, r)
return
}
policy.apply(w.Header(), r.Header.Get("Origin"))
next.ServeHTTP(w, r)
})
}
// 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
// Cache-Control "no-cache, private". A preflight (an Origin and an
// Access-Control-Request-Method) also carries the Content-Type PHP's SAPI
// adds to a response that sets none ("text/html; charset=UTF-8") and Vary
// on the two request headers; when any method or header is allowed, the
// Allow-Methods and Allow-Headers values echo the requested method
// (upper-cased) and headers, as php-cors does, instead of "*".
func writeOptions(w http.ResponseWriter, r *http.Request, allowed, allowAnyMethod, allowAnyHeader bool) {
h := w.Header()
h.Set("Cache-Control", "no-cache, private")
method := r.Header.Get("Access-Control-Request-Method")
if r.Header.Get("Origin") != "" && method != "" {
h.Set("Content-Type", "text/html; charset=UTF-8")
h.Add("Vary", "Access-Control-Request-Method, Access-Control-Request-Headers")
if allowed && allowAnyMethod {
h.Set("Access-Control-Allow-Methods", strings.ToUpper(method))
}
if allowed && allowAnyHeader {
if requested := r.Header.Get("Access-Control-Request-Headers"); requested != "" {
h.Set("Access-Control-Allow-Headers", requested)
}
}
} else {
h.Add("Vary", "Access-Control-Request-Method")
}
w.WriteHeader(http.StatusNoContent)
}
func corsAllowOrigin(allowAny bool, origins map[string]struct{}, pats []*regexp.Regexp, origin string) (bool, string) {
if allowAny {
return true, "*"
}
if origin == "" {
return false, ""
}
if _, ok := origins[origin]; ok {
return true, origin
}
for _, re := range pats {
if re.MatchString(origin) {
return true, origin
}
}
return false, ""
}
func pathMatchesCORS(globs []*regexp.Regexp, urlPath string) bool {
trimmed := strings.TrimPrefix(urlPath, "/")
for _, re := range globs {
if re.MatchString(trimmed) {
return true
}
}
return false
}
func compileLaravelGlob(pattern string) *regexp.Regexp {
pattern = strings.TrimPrefix(pattern, "/")
var b strings.Builder
b.WriteString("(?s)^")
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '*':
b.WriteString(".*")
case '?':
b.WriteByte('.')
default:
b.WriteString(regexp.QuoteMeta(pattern[i : i+1]))
}
}
b.WriteByte('$')
re, err := regexp.Compile(b.String())
if err != nil {
return nil
}
return re
}
func containsStar(vals []string) bool {
for _, v := range vals {
if v == "*" {
return true
}
}
return false
}