feat: add configurable transport-independent CORS policy
This commit is contained in:
187
cors/cors.go
Normal file
187
cors/cors.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Policy is immutable after New and safe to share between concurrent requests.
|
||||
type Policy struct {
|
||||
enabled, wildcard, credentials bool
|
||||
origins, methods, headers map[string]struct{}
|
||||
allowMethods, allowHeaders, exposeHeaders, maxAge string
|
||||
}
|
||||
|
||||
// New validates and snapshots policy. It performs no network or DNS operations.
|
||||
func New(c Config) (*Policy, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &Policy{enabled: c.Enabled, credentials: c.AllowCredentials}
|
||||
if !c.Enabled {
|
||||
return p, nil
|
||||
}
|
||||
p.origins = make(map[string]struct{}, len(c.AllowedOrigins))
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if origin == "*" {
|
||||
p.wildcard = true
|
||||
continue
|
||||
}
|
||||
normalized, _ := normalizeOrigin(origin)
|
||||
p.origins[normalized] = struct{}{}
|
||||
}
|
||||
methods := c.AllowedMethods
|
||||
if len(methods) == 0 {
|
||||
methods = []string{http.MethodGet, http.MethodHead, http.MethodPost}
|
||||
}
|
||||
p.methods, p.allowMethods = compileList(methods, false)
|
||||
p.headers, p.allowHeaders = compileList(c.AllowedHeaders, true)
|
||||
_, p.exposeHeaders = compileList(c.ExposedHeaders, true)
|
||||
if c.MaxAge > 0 {
|
||||
p.maxAge = strconv.FormatInt(int64(c.MaxAge/time.Second), 10)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func compileList(values []string, header bool) (map[string]struct{}, string) {
|
||||
set := make(map[string]struct{}, len(values))
|
||||
list := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
key := value
|
||||
if header {
|
||||
key = strings.ToLower(value)
|
||||
value = http.CanonicalHeaderKey(value)
|
||||
}
|
||||
if _, exists := set[key]; exists {
|
||||
continue
|
||||
}
|
||||
set[key] = struct{}{}
|
||||
list = append(list, value)
|
||||
}
|
||||
return set, strings.Join(list, ", ")
|
||||
}
|
||||
|
||||
// Wrap installs CORS outside routing/authentication. Only actual CORS preflights
|
||||
// are short-circuited. Actual requests always continue through normal handlers;
|
||||
// an unlisted Origin receives no permission to read the response in a browser.
|
||||
// The original ResponseWriter, Request and body pass through without buffering.
|
||||
func (p *Policy) Wrap(next http.Handler) http.Handler {
|
||||
if next == nil {
|
||||
panic("cors: nil handler")
|
||||
}
|
||||
if p == nil {
|
||||
panic("cors: nil policy")
|
||||
}
|
||||
if !p.enabled {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
vary(h, "Origin")
|
||||
origins := r.Header.Values("Origin")
|
||||
// An OPTIONS request without Origin or Access-Control-Request-Method is
|
||||
// ordinary application traffic, including user-defined OPTIONS handlers.
|
||||
requestedMethods := r.Header.Values("Access-Control-Request-Method")
|
||||
preflight := r.Method == http.MethodOptions && len(origins) > 0 && len(requestedMethods) > 0
|
||||
if preflight {
|
||||
vary(h, "Access-Control-Request-Method")
|
||||
vary(h, "Access-Control-Request-Headers")
|
||||
}
|
||||
if len(origins) != 1 || !p.allowsOrigin(origins[0]) {
|
||||
if preflight {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if preflight {
|
||||
if len(requestedMethods) != 1 || !p.allowsMethod(requestedMethods[0]) || !p.allowsHeaders(r.Header.Values("Access-Control-Request-Headers")) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
p.allowOrigin(h, origins[0])
|
||||
h.Set("Access-Control-Allow-Methods", p.allowMethods)
|
||||
if p.allowHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", p.allowHeaders)
|
||||
}
|
||||
if p.maxAge != "" {
|
||||
h.Set("Access-Control-Max-Age", p.maxAge)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Match the actual method too: this grants response access, not permission
|
||||
// to execute a side effect. Authentication/CSRF checks remain in the app.
|
||||
if p.allowsMethod(r.Method) {
|
||||
p.allowOrigin(h, origins[0])
|
||||
if p.exposeHeaders != "" {
|
||||
h.Set("Access-Control-Expose-Headers", p.exposeHeaders)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Policy) allowsOrigin(origin string) bool {
|
||||
if !p.wildcard {
|
||||
_, ok := p.origins[origin]
|
||||
return ok
|
||||
}
|
||||
normalized, ok := normalizeOrigin(origin)
|
||||
return ok && normalized == origin
|
||||
}
|
||||
func (p *Policy) allowsMethod(method string) bool { _, ok := p.methods[method]; return ok }
|
||||
func (p *Policy) allowOrigin(h http.Header, origin string) {
|
||||
if p.wildcard {
|
||||
origin = "*"
|
||||
}
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
if p.credentials {
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
}
|
||||
func (p *Policy) allowsHeaders(lines []string) bool {
|
||||
count, size := 0, 0
|
||||
for _, line := range lines {
|
||||
size += len(line)
|
||||
if size > 8192 {
|
||||
return false
|
||||
}
|
||||
// Parse a header list without allocating an attacker-sized split slice.
|
||||
for {
|
||||
name, rest, more := strings.Cut(line, ",")
|
||||
name = strings.Trim(name, " \t")
|
||||
count++
|
||||
if count > 128 || !token(name) {
|
||||
return false
|
||||
}
|
||||
if _, ok := p.headers[strings.ToLower(name)]; !ok {
|
||||
return false
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
line = rest
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func vary(h http.Header, name string) {
|
||||
for _, line := range h.Values("Vary") {
|
||||
for {
|
||||
v, rest, more := strings.Cut(line, ",")
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "*" || strings.EqualFold(v, name) {
|
||||
return
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
line = rest
|
||||
}
|
||||
}
|
||||
h.Add("Vary", name)
|
||||
}
|
||||
Reference in New Issue
Block a user