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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user