Files
summercms/modules/surf/cors.go
Jakub Zych b5d20b3bfd fix(14-05): surf answers OPTIONS on CORS paths with Laravel's HandleCors headers
- every OPTIONS on a CORS path: 204 with Cache-Control no-cache, private
- a preflight echoes the requested method (upper-cased) and headers when * allows any, with Vary and PHP's default Content-Type, as recorded from PHP
- README and the routing docs describe the answer
2026-10-04 00:00:01 +02:00

192 lines
5.6 KiB
Go

package surf
import (
"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).
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
}
return out, nil
}
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)
}
}
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, ", ")
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)
return
}
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
}