145 lines
5.0 KiB
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
|
|
}
|