feat: add configurable transport-independent CORS policy
This commit is contained in:
303
cors/cors_test.go
Normal file
303
cors/cors_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user