// Package cors implements optional, transport-independent browser CORS policy. // It does not authenticate requests or provide CSRF protection. package cors import ( "net" "net/netip" "net/url" "strconv" "strings" "time" "git.yuntuo.hk/k2safe/go-omni/internal/configdiag" ) // Config is disabled by default. Origins are exact HTTP(S) origins, or a single // "*" for public responses without credentials. Patterns and "null" are rejected. // Empty methods default to GET, HEAD, POST; empty header lists grant no extra // permissions. MaxAge is a nonnegative whole number of seconds; zero omits it. type Config struct { Enabled bool `json:"enabled"` AllowedOrigins []string `json:"allowedOrigins"` AllowedMethods []string `json:"allowedMethods"` AllowedHeaders []string `json:"allowedHeaders"` ExposedHeaders []string `json:"exposedHeaders"` AllowCredentials bool `json:"allowCredentials"` MaxAge time.Duration `json:"maxAge"` } func (c Config) Clone() Config { c.AllowedOrigins = append([]string(nil), c.AllowedOrigins...) c.AllowedMethods = append([]string(nil), c.AllowedMethods...) c.AllowedHeaders = append([]string(nil), c.AllowedHeaders...) c.ExposedHeaders = append([]string(nil), c.ExposedHeaders...) return c } // Validate never echoes configured values in diagnostics. Invalid settings are // rejected even when disabled; an empty origin list is legal only when disabled. func (c Config) Validate() error { if c.Enabled && len(c.AllowedOrigins) == 0 { return configdiag.New("allowedOrigins", "is required when CORS is enabled") } if c.MaxAge < 0 || c.MaxAge%time.Second != 0 { return configdiag.New("maxAge", "must be a nonnegative whole number of seconds") } if len(c.AllowedOrigins) > 1024 { return configdiag.New("allowedOrigins", "must contain at most 1024 entries") } for i, origin := range c.AllowedOrigins { field := "allowedOrigins[" + strconv.Itoa(i) + "]" if origin == "*" { if c.AllowCredentials || len(c.AllowedOrigins) != 1 { return configdiag.New(field, "wildcard requires a single origin entry and allowCredentials=false") } } else if _, ok := normalizeOrigin(origin); !ok { return configdiag.New(field, "must be an HTTP(S) origin without credentials, path, query, fragment or wildcard") } } for _, list := range []struct { name string values []string }{{"allowedMethods", c.AllowedMethods}, {"allowedHeaders", c.AllowedHeaders}, {"exposedHeaders", c.ExposedHeaders}} { if len(list.values) > 128 { return configdiag.New(list.name, "must contain at most 128 entries") } for i, value := range list.values { field := list.name + "[" + strconv.Itoa(i) + "]" if !token(value) || value == "*" { return configdiag.New(field, "must be an explicit HTTP token, not a wildcard") } if list.name == "allowedMethods" && (value != strings.ToUpper(value) || value == "CONNECT" || value == "TRACE" || value == "TRACK") { return configdiag.New(field, "must be an uppercase browser-allowed HTTP method") } } } return nil } // Normalize configured scheme/host case and default ports once. Request values // match serialized browser origins; arbitrary URL prefixes/suffixes never match. func normalizeOrigin(origin string) (string, bool) { if len(origin) == 0 || len(origin) > 4096 { return "", false } for _, b := range []byte(origin) { if b <= 32 || b >= 127 || strings.ContainsRune("\\%?#*", rune(b)) { return "", false } } u, err := url.Parse(origin) if err != nil || u.User != nil || u.Opaque != "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Host == "" { return "", false } scheme, host := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()) if (scheme != "http" && scheme != "https") || host == "" || strings.HasSuffix(u.Host, ":") { return "", false } if strings.Contains(host, ":") { ip, err := netip.ParseAddr(host) if err != nil || !ip.Is6() || ip.Zone() != "" { return "", false } host = "[" + ip.String() + "]" if ip.Is4In6() { // Browsers serialize mapped IPv4 as hex IPv6 pieces; netip.String // uses a dotted-decimal tail instead. b := ip.As16() host = "[::ffff:" + strconv.FormatUint(uint64(b[12])<<8|uint64(b[13]), 16) + ":" + strconv.FormatUint(uint64(b[14])<<8|uint64(b[15]), 16) + "]" } } else { if strings.ContainsAny(u.Host, "[]") { return "", false } for _, b := range []byte(host) { if !(b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '-') { return "", false } } } port := u.Port() if port != "" { n, err := strconv.Atoi(port) if err != nil || n < 1 || n > 65535 { return "", false } if !(scheme == "https" && n == 443 || scheme == "http" && n == 80) { host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(n)) } } return scheme + "://" + host, true } func token(s string) bool { if s == "" || len(s) > 256 { return false } for _, b := range []byte(s) { if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))) { return false } } return true }