package surf import ( "fmt" "net/http" "regexp" "regexp/syntax" "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. With // credentials, a wildcard origin must be the whole leftmost host label // (https://*.example.com), a pattern must be anchored with ^ and $, and no // origin rule may match an origin outside the configured domains. 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.) 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]) } re, err := regexp.Compile(p) if err != nil { return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q does not compile: %v", prefix, i, p, err) } if !cfg.SupportsCredentials { continue } if !anchoredOriginPattern(p) { return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is not anchored: with %s.supports_credentials true a pattern must start with ^ and end with $, as in ^https://[a-z]+\\.example\\.com$, or it matches inside any origin", prefix, i, p, prefix) } if probe, ok := matchesCORSProbe(re); ok { return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a pattern must name your own domains", prefix, i, p, probe, prefix) } } if !cfg.SupportsCredentials { return nil } for i, o := range cfg.AllowedOrigins { if o == "*" || !strings.Contains(o, "*") { continue } if !wildcardHostLabel(o) { return fmt.Errorf("surf: %s.allowed_origins[%d] %q: with %s.supports_credentials true a wildcard must be the whole leftmost host label after a literal scheme, as in https://*.example.com", prefix, i, o, prefix) } if probe, ok := matchesCORSProbe(regexp.MustCompile(wildcardOriginPattern(o))); ok { return fmt.Errorf("surf: %s.allowed_origins[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a wildcard must sit in front of your own domain, as in https://*.example.com", prefix, i, o, probe, prefix) } } return nil } // corsProbeOrigins are origins no credentialed origin rule may match: a // rule that matches them trusts origins outside the configured domains // (https://*, https://*.com, ^https?://.*$). var corsProbeOrigins = []string{ "https://cors-probe.invalid", "http://cors-probe.invalid", "https://cors-probe.com", "null", } // matchesCORSProbe reports the first probe origin re matches. func matchesCORSProbe(re *regexp.Regexp) (string, bool) { for _, probe := range corsProbeOrigins { if re.MatchString(probe) { return probe, true } } return "", false } // wildcardHostLabel reports an allowed_origins entry whose only "*" is the // whole leftmost host label after a literal scheme: https://*.example.com. func wildcardHostLabel(origin string) bool { scheme, host, ok := strings.Cut(origin, "://") if !ok || scheme == "" || strings.Contains(scheme, "*") { return false } rest, ok := strings.CutPrefix(host, "*.") return ok && rest != "" && !strings.Contains(rest, "*") } // anchoredOriginPattern reports a pattern anchored at both ends: it starts // with ^ and ends with $, and every alternative of the parsed expression // stays anchored, so ^a|b$ is refused. func anchoredOriginPattern(p string) bool { if !strings.HasPrefix(p, "^") || !strings.HasSuffix(p, "$") { return false } re, err := syntax.Parse(p, syntax.Perl) if err != nil { return false } return anchoredAt(re, true) && anchoredAt(re, false) } // anchoredAt reports whether every match of re begins (start true) or ends // at the text boundary. func anchoredAt(re *syntax.Regexp, start bool) bool { switch re.Op { case syntax.OpBeginText: return start case syntax.OpEndText: return !start case syntax.OpCapture: return anchoredAt(re.Sub[0], start) case syntax.OpConcat: if len(re.Sub) == 0 { return false } if start { return anchoredAt(re.Sub[0], start) } return anchoredAt(re.Sub[len(re.Sub)-1], start) case syntax.OpAlternate: for _, sub := range re.Sub { if !anchoredAt(sub, start) { return false } } return len(re.Sub) > 0 } return false } // 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: 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) }) } // 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:) and is validated like http.cors, with errors naming // http.cors_profiles... 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. wrap runs it // outermost, outside the body limit, locale and recovery, so every answer of // the route carries the headers, refusals and recovered panics included. 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:, // 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:, 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 // 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 }