feat: add configurable transport-independent CORS policy

This commit is contained in:
2026-09-26 19:53:28 +08:00
parent 9fade3bedb
commit 800974cfff
33 changed files with 1645 additions and 23 deletions

303
cors/cors_test.go Normal file
View File

@@ -0,0 +1,303 @@
package cors
import (
"context"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func exampleConfig() Config {
return Config{Enabled: true, AllowedOrigins: []string{"https://admin.example", "https://merchant.example"}, AllowedMethods: []string{"GET", "POST", "DELETE"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"X-Request-ID", "Content-Disposition"}, MaxAge: 10 * time.Minute}
}
func TestPolicyPreflightAndActual(t *testing.T) {
for _, tc := range []struct {
name, method, origin, requestedMethod, requestedHeaders string
status int
called, allowed bool
}{
{"preflight", "OPTIONS", "https://admin.example", "POST", "authorization, CONTENT-TYPE", 204, false, true},
{"no request headers", "OPTIONS", "https://admin.example", "DELETE", "", 204, false, true},
{"unknown origin", "OPTIONS", "https://other.example", "POST", "authorization", 403, false, false},
{"suffix attack", "OPTIONS", "https://admin.example.evil", "POST", "", 403, false, false},
{"scheme mismatch", "OPTIONS", "http://admin.example", "POST", "", 403, false, false},
{"port mismatch", "OPTIONS", "https://admin.example:444", "POST", "", 403, false, false},
{"opaque origin", "OPTIONS", "null", "POST", "", 403, false, false},
{"unknown method", "OPTIONS", "https://admin.example", "PATCH", "", 403, false, false},
{"case sensitive method", "OPTIONS", "https://admin.example", "post", "", 403, false, false},
{"unknown header", "OPTIONS", "https://admin.example", "POST", "X-Internal", 403, false, false},
{"trailing empty header", "OPTIONS", "https://admin.example", "POST", "Authorization,", 403, false, false},
{"control header", "OPTIONS", "https://admin.example", "POST", "Authorization\r\nX-Evil", 403, false, false},
{"duplicate allowed header", "OPTIONS", "https://admin.example", "POST", "Authorization, authorization", 204, false, true},
{"oversized list", "OPTIONS", "https://admin.example", "POST", strings.Repeat("Authorization,", 129) + "Authorization", 403, false, false},
{"actual error response", "GET", "https://merchant.example", "", "", 401, true, true},
{"actual denied origin still authenticates", "POST", "https://other.example", "", "", 401, true, false},
{"actual denied method still authenticates", "PUT", "https://admin.example", "", "", 401, true, false},
{"server callback", "POST", "", "", "", 401, true, false},
{"ordinary OPTIONS", "OPTIONS", "https://admin.example", "", "", 401, true, false},
{"OPTIONS without Origin", "OPTIONS", "", "POST", "", 401, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
policy, err := New(exampleConfig())
if err != nil {
t.Fatal(err)
}
called := false
h := policy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(401)
io.WriteString(w, "authentication required")
}))
r := httptest.NewRequest(tc.method, "http://api.example/resource", nil)
if tc.origin != "" {
r.Header.Set("Origin", tc.origin)
}
if tc.requestedMethod != "" {
r.Header.Set("Access-Control-Request-Method", tc.requestedMethod)
}
if tc.requestedHeaders != "" {
r.Header.Set("Access-Control-Request-Headers", tc.requestedHeaders)
}
w := httptest.NewRecorder()
w.Header().Add("Vary", "Accept-Encoding")
h.ServeHTTP(w, r)
if w.Code != tc.status || called != tc.called {
t.Fatalf("status=%d called=%v", w.Code, called)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); (got != "") != tc.allowed || tc.allowed && got != tc.origin {
t.Fatal(w.Header())
}
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Origin") || !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Accept-Encoding") {
t.Fatal(w.Header())
}
if !tc.called {
if w.Body.Len() != 0 {
t.Fatal("preflight wrapped in business response")
}
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Access-Control-Request-Headers") {
t.Fatal(w.Header())
}
if tc.allowed && (w.Header().Get("Access-Control-Max-Age") != "600" || !strings.Contains(w.Header().Get("Access-Control-Allow-Headers"), "Authorization")) {
t.Fatal(w.Header())
}
} else if w.Body.String() != "authentication required" {
t.Fatal(w.Body.String())
}
if tc.called && tc.allowed && w.Header().Get("Access-Control-Expose-Headers") != "X-Request-Id, Content-Disposition" {
t.Fatal(w.Header())
}
})
}
}
func TestPolicyValidation(t *testing.T) {
for _, origin := range []string{"https://admin.example/", "https://user:password@admin.example", "null", "https://*.example", "https://admin.example?secret", "https://admin.example#secret", "file://host", "https://host:", "https://host:0", "https://host:65536", "https://host%2eevil", "https://host\r\nX: value", "https://例子.example", "https://[localhost]", "https://[::1%25zone]"} {
c := exampleConfig()
c.AllowedOrigins = []string{origin}
err := c.Validate()
if err == nil || strings.Contains(err.Error(), origin) {
t.Fatalf("invalid origin accepted or exposed: %q %v", origin, err)
}
}
for _, change := range []func(*Config){
func(c *Config) { c.AllowedOrigins = nil },
func(c *Config) { c.AllowedOrigins = []string{"*"}; c.AllowCredentials = true },
func(c *Config) { c.AllowedOrigins = []string{"*", "https://admin.example"} },
func(c *Config) { c.AllowedHeaders = []string{"*"} },
func(c *Config) { c.ExposedHeaders = []string{"header\r\n"} },
func(c *Config) { c.AllowedMethods = []string{"get"} },
func(c *Config) { c.AllowedMethods = []string{"TRACE"} },
func(c *Config) { c.MaxAge = -time.Second },
func(c *Config) { c.MaxAge = time.Millisecond },
} {
c := exampleConfig()
change(&c)
if _, err := New(c); err == nil {
t.Fatal(c)
}
}
if err := (Config{}).Validate(); err != nil {
t.Fatal(err)
}
for input, want := range map[string]string{"https://[::ffff:192.0.2.1]": "https://[::ffff:c000:201]", "https://[::ffff:c000:201]": "https://[::ffff:c000:201]", "https://ADMIN.EXAMPLE:443": "https://admin.example", "http://localhost:80": "http://localhost", "http://localhost:8080": "http://localhost:8080", "https://[::1]:443": "https://[::1]", "http://[::1]:8080": "http://[::1]:8080"} {
got, ok := normalizeOrigin(input)
if !ok || got != want {
t.Fatalf("%q => %q %v", input, got, ok)
}
}
}
func TestPolicyCredentialsWildcardAndDuplicates(t *testing.T) {
for _, credentials := range []bool{false, true} {
c := exampleConfig()
c.AllowCredentials = credentials
if !credentials {
c.AllowedOrigins = []string{"*"}
}
p, _ := New(c)
for _, origin := range []string{"https://admin.example", "null", "https://admin.example/path", "https://bad\r\n"} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, r)
if origin != "https://admin.example" {
if w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal(w.Header())
}
continue
}
want := "*"
if credentials {
want = origin
}
if w.Header().Get("Access-Control-Allow-Origin") != want || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != credentials {
t.Fatal(w.Header())
}
}
}
p, _ := New(exampleConfig())
for _, header := range []string{"Origin", "Access-Control-Request-Method"} {
r := httptest.NewRequest("OPTIONS", "/", nil)
r.Header.Set("Origin", "https://admin.example")
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Add(header, r.Header.Get(header))
w := httptest.NewRecorder()
p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("ambiguous preflight reached handler") })).ServeHTTP(w, r)
if w.Code != 403 || w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal(w.Code, w.Header())
}
}
}
func TestPolicyPreservesHTTPAndSnapshot(t *testing.T) {
c := exampleConfig()
p, _ := New(c)
c.AllowedOrigins[0] = "https://evil.example"
c.AllowedHeaders[0] = "X-Evil"
r := httptest.NewRequest("GET", "/", strings.NewReader("request"))
r.Header.Set("Origin", "https://admin.example")
type key struct{}
r = r.WithContext(context.WithValue(r.Context(), key{}, "kept"))
w := httptest.NewRecorder()
w.Header().Set("Vary", "Accept-Encoding, origin")
next := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if writer != w || request != r || request.Context().Value(key{}) != "kept" {
t.Fatal("HTTP capabilities replaced")
}
io.WriteString(writer, "chunk")
writer.(http.Flusher).Flush()
})
p.Wrap(next).ServeHTTP(w, r)
if !w.Flushed || w.Body.String() != "chunk" || w.Header().Get("Access-Control-Allow-Origin") != "https://admin.example" || len(w.Header().Values("Vary")) != 1 {
t.Fatal(w)
}
for _, prior := range []string{"*", "Accept-Encoding, Origin"} {
h := http.Header{"Vary": {prior}}
vary(h, "Origin")
if !reflect.DeepEqual(h.Values("Vary"), []string{prior}) {
t.Fatal(h)
}
}
disabled, _ := New(Config{})
if reflect.ValueOf(disabled.Wrap(next)).Pointer() != reflect.ValueOf(next).Pointer() {
t.Fatal("disabled policy not passthrough")
}
var wg sync.WaitGroup
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
for i := 0; i < 32; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100; j++ {
r := httptest.NewRequest("GET", "/", nil)
origin := "https://admin.example"
if i%2 == 1 {
origin = "https://merchant.example"
}
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Header().Get("Access-Control-Allow-Origin") != origin {
t.Error("cross-request origin leak")
}
}
}(i)
}
wg.Wait()
}
func FuzzCORSHeaders(f *testing.F) {
f.Add("https://admin.example", "POST", "authorization, content-type")
f.Add("https://admin.example.evil", "POST", "")
f.Add("null", "GET", "\r\n")
p, _ := New(exampleConfig())
h := p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("preflight reached business") }))
f.Fuzz(func(t *testing.T, origin, method, headers string) {
r := httptest.NewRequest("OPTIONS", "/", nil)
r.Header.Set("Origin", origin)
r.Header.Set("Access-Control-Request-Method", method)
if headers != "" {
r.Header.Set("Access-Control-Request-Headers", headers)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 204 && w.Code != 403 {
t.Fatal(w.Code)
}
if w.Body.Len() != 0 {
t.Fatal("unexpected body")
}
if value := w.Header().Get("Access-Control-Allow-Origin"); value != "" {
if value != "https://admin.example" && value != "https://merchant.example" {
t.Fatal("untrusted reflection")
}
}
})
}
type benchmarkWriter struct{ header http.Header }
func (w benchmarkWriter) Header() http.Header { return w.header }
func (w benchmarkWriter) Write(p []byte) (int, error) { return len(p), nil }
func (w benchmarkWriter) WriteHeader(int) {}
func BenchmarkCORS(b *testing.B) {
for _, mode := range []string{"disabled", "noOrigin", "allowed", "denied", "preflight", "wildcard"} {
b.Run(mode, func(b *testing.B) {
c := exampleConfig()
if mode == "disabled" {
c.Enabled = false
}
if mode == "wildcard" {
c.AllowedOrigins = []string{"*"}
}
p, _ := New(c)
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Origin", "https://admin.example")
if mode == "noOrigin" {
r.Header.Del("Origin")
}
if mode == "denied" {
r.Header.Set("Origin", "https://other.example")
}
if mode == "preflight" {
r.Method = "OPTIONS"
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "authorization, content-type")
}
w := benchmarkWriter{make(http.Header)}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
clear(w.header)
h.ServeHTTP(w, r)
}
})
}
}