Files
go-omni/cors/config.go

145 lines
5.0 KiB
Go

// 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
}