package surf import ( "bytes" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // Unit tests for the CORS hardening (D-04), the cors: policies // (D-05) and the turnstile factory parameters (D-18) of phase 19.3. func corsRequest(method, target, origin string, preflight bool) *http.Request { r := httptest.NewRequest(method, target, nil) if origin != "" { r.Header.Set("Origin", origin) } if preflight { r.Header.Set("Access-Control-Request-Method", "POST") r.Header.Set("Access-Control-Request-Headers", "content-type") } return r } func serveCORS(h http.Handler, r *http.Request) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, r) return rec } func okHandler(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } func TestCORSCredentialsWildcardRefused(t *testing.T) { _, err := LoadCORSConfig(writeHTTPConfig(t, ` cors: paths: ["api/*"] allowed_origins: ["https://www.example.com", "*"] supports_credentials: true `)) if err == nil || !strings.Contains(err.Error(), "http.cors.supports_credentials") || !strings.Contains(err.Error(), "http.cors.allowed_origins") { t.Fatalf("global error = %v", err) } _, err = LoadCORSProfiles(writeHTTPConfig(t, ` cors_profiles: spa: allowed_origins: ["*"] supports_credentials: true `)) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.supports_credentials") { t.Fatalf("profile error = %v", err) } // The same origins without credentials load. if _, err := LoadCORSConfig(writeHTTPConfig(t, "cors:\n paths: [\"api/*\"]\n allowed_origins: [\"*\"]\n")); err != nil { t.Fatalf("* without credentials: %v", err) } // Credentials with exact origins and patterns load. if _, err := LoadCORSConfig(writeHTTPConfig(t, ` cors: paths: ["api/*"] allowed_origins: ["https://www.example.com", "https://*.example.com"] allowed_origins_patterns: ['^https://[a-z]+\.example\.org$'] supports_credentials: true `)); err != nil { t.Fatalf("credentials with exact origins: %v", err) } // A wildcard host and an unanchored pattern are the same exposure under // another spelling (WR-04): refused with credentials, loaded without. for _, tc := range []struct{ key, value, want string }{ {"allowed_origins", "https://*", "http.cors.allowed_origins[1]"}, {"allowed_origins_patterns", `https://.*\.example\.com`, "http.cors.allowed_origins_patterns[1]"}, } { list := `["https://www.example.com", '` + tc.value + `']` if tc.key == "allowed_origins_patterns" { list = `['^https://www\.example\.com$', '` + tc.value + `']` } yaml := "cors:\n paths: [\"api/*\"]\n " + tc.key + ": " + list + "\n" if _, err := LoadCORSConfig(writeHTTPConfig(t, yaml+" supports_credentials: true\n")); err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s with credentials: error = %v, want one naming %s", tc.value, err, tc.want) } if _, err := LoadCORSConfig(writeHTTPConfig(t, yaml)); err != nil { t.Errorf("%s without credentials: %v", tc.value, err) } } } func TestCORSPatternErrors(t *testing.T) { for _, tc := range []struct { name, pattern, want string }{ {"uncompilable", `^https://(www\.example\.com$`, "does not compile"}, {"hash delimited", `#^https://www\.example\.com$#`, "PHP delimiters"}, {"slash delimited with flags", `/^https://www\.example\.com$/i`, "PHP delimiters"}, } { t.Run(tc.name, func(t *testing.T) { yaml := "cors:\n paths: [\"api/*\"]\n allowed_origins_patterns:\n - 'https://ok\\.example\\.com'\n - '" + tc.pattern + "'\n" _, err := LoadCORSConfig(writeHTTPConfig(t, yaml)) if err == nil || !strings.Contains(err.Error(), "http.cors.allowed_origins_patterns[1]") || !strings.Contains(err.Error(), tc.want) { t.Fatalf("global error = %v", err) } pyaml := "cors_profiles:\n spa:\n allowed_origins_patterns:\n - '" + tc.pattern + "'\n" _, err = LoadCORSProfiles(writeHTTPConfig(t, pyaml)) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.allowed_origins_patterns[0]") { t.Fatalf("profile error = %v", err) } }) } // A pattern that merely contains # or / later is not delimited. if phpDelimited(`^https://x\.example\.com/#$`) || !phpDelimited(`#x#`) || phpDelimited(`#x#1`) { t.Fatal("phpDelimited misclassifies") } } func TestCORSWildcardOriginEntry(t *testing.T) { h := pathScopedCORS(CORSConfig{ Paths: []string{"api/*"}, AllowedOrigins: []string{"https://*.example.com"}, AllowedMethods: []string{"GET"}, SupportsCredentials: true, }, http.HandlerFunc(okHandler)) for origin, allowed := range map[string]bool{ "https://www.example.com": true, "https://a.b.example.com": true, "https://example.com": false, "https://example.com.evil.org": false, "https://wwwexample.com": false, "http://www.example.com": false, "https://www.example.com.": false, } { rec := serveCORS(h, corsRequest(http.MethodGet, "/api/x", origin, false)) got := rec.Header().Get("Access-Control-Allow-Origin") if allowed && got != origin { t.Errorf("%s: Allow-Origin = %q, want the origin", origin, got) } if !allowed && got != "" { t.Errorf("%s: Allow-Origin = %q, want none", origin, got) } } if got := wildcardOriginPattern("https://*.example.com"); got != `^https://.*\.example\.com$` { t.Fatalf("pattern = %q", got) } } func TestCORSVaryOnDynamicAnswers(t *testing.T) { for _, tc := range []struct { name string cfg CORSConfig }{ {"credentials", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com"}, AllowedMethods: []string{"GET", "POST"}, SupportsCredentials: true}}, {"patterns", CORSConfig{Paths: []string{"api/*"}, AllowedOriginsPatterns: []string{`^https://www\.example\.com$`}, AllowedMethods: []string{"*"}}}, {"several origins", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com", "https://admin.example.com"}, AllowedMethods: []string{"*"}}}, } { t.Run(tc.name, func(t *testing.T) { h := pathScopedCORS(tc.cfg, http.HandlerFunc(okHandler)) for _, req := range []struct { label string r *http.Request allow bool }{ {"allowed GET", corsRequest(http.MethodGet, "/api/x", "https://www.example.com", false), true}, {"disallowed GET", corsRequest(http.MethodGet, "/api/x", "https://evil.example.org", false), false}, {"no origin GET", corsRequest(http.MethodGet, "/api/x", "", false), false}, {"allowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://www.example.com", true), true}, {"disallowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://evil.example.org", true), false}, } { rec := serveCORS(h, req.r) if !varyHas(rec.Header(), "Origin") { t.Errorf("%s: Vary = %q, want Origin", req.label, rec.Header().Values("Vary")) } got := rec.Header().Get("Access-Control-Allow-Origin") if req.allow && got != "https://www.example.com" { t.Errorf("%s: Allow-Origin = %q", req.label, got) } if !req.allow && got != "" { t.Errorf("%s: Allow-Origin = %q, want none", req.label, got) } if !req.allow && rec.Header().Get("Access-Control-Allow-Credentials") != "" { t.Errorf("%s: credentials header on a refused origin", req.label) } } }) } // Vary is added once, next to an existing Vary value. h := http.Header{} h.Add("Vary", "Accept-Encoding, origin") addVary(h, "Origin") if got := h.Values("Vary"); len(got) != 1 { t.Fatalf("Vary = %q", got) } } func TestCORSSubdomainCredentialed(t *testing.T) { const www = "https://www.example.com" app := transportApp(t, "production", map[string]string{ "app.yaml": "name: transport-test\nurl: https://api.example.com\n", "http.yaml": transportBodyLimits + `cors: paths: ["api/*"] allowed_origins: ["https://www.example.com"] allowed_methods: ["GET", "POST"] allowed_headers: ["Content-Type", "X-Requested-With"] exposed_headers: ["X-Request-Id"] max_age: 600 supports_credentials: true `, }) plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) { r.Get("/api/me", okHandler) r.Post("/api/auth/refresh", okHandler) }} h, err := Assemble(app, []party.Plugin{plugin}) if err != nil { t.Fatal(err) } for _, tc := range []struct { label string r *http.Request code int }{ {"GET", corsRequest(http.MethodGet, "https://api.example.com/api/me", www, false), http.StatusOK}, {"POST", corsRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", www, false), http.StatusOK}, {"preflight", corsRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", www, true), http.StatusNoContent}, } { rec := serveCORS(h, tc.r) hd := rec.Header() if rec.Code != tc.code { t.Fatalf("%s: status %d", tc.label, rec.Code) } if hd.Get("Access-Control-Allow-Origin") != www || hd.Get("Access-Control-Allow-Credentials") != "true" { t.Errorf("%s: Allow-Origin %q Allow-Credentials %q", tc.label, hd.Get("Access-Control-Allow-Origin"), hd.Get("Access-Control-Allow-Credentials")) } if !varyHas(hd, "Origin") { t.Errorf("%s: Vary = %q", tc.label, hd.Values("Vary")) } if hd.Get("Access-Control-Allow-Methods") != "GET, POST" || hd.Get("Access-Control-Allow-Headers") != "Content-Type, X-Requested-With" { t.Errorf("%s: methods %q headers %q", tc.label, hd.Get("Access-Control-Allow-Methods"), hd.Get("Access-Control-Allow-Headers")) } if hd.Get("Access-Control-Expose-Headers") != "X-Request-Id" || hd.Get("Access-Control-Max-Age") != "600" { t.Errorf("%s: expose %q max-age %q", tc.label, hd.Get("Access-Control-Expose-Headers"), hd.Get("Access-Control-Max-Age")) } } // The apex host and another site get nothing. for _, origin := range []string{"https://example.com", "https://www.example.org"} { rec := serveCORS(h, corsRequest(http.MethodGet, "https://api.example.com/api/me", origin, false)) if rec.Header().Get("Access-Control-Allow-Origin") != "" || rec.Header().Get("Access-Control-Allow-Credentials") != "" { t.Errorf("%s: answered with CORS headers %v", origin, rec.Header()) } } } func TestCORSStarUnchanged(t *testing.T) { cfg := CORSConfig{ Paths: []string{"api/*"}, AllowedMethods: []string{"*"}, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"*"}, } h := pathScopedCORS(cfg, http.HandlerFunc(okHandler)) rec := serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "https://app.example.test", false)) want := http.Header{ "Access-Control-Allow-Origin": {"*"}, "Access-Control-Allow-Methods": {"*"}, "Access-Control-Allow-Headers": {"*"}, } assertHeaders(t, "GET", rec.Header(), want) // Without an Origin the * answer is the same. rec = serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "", false)) assertHeaders(t, "GET without Origin", rec.Header(), want) rec = serveCORS(h, corsRequest(http.MethodOptions, "/api/genres", "https://app.example.test", true)) assertHeaders(t, "OPTIONS", rec.Header(), http.Header{ "Access-Control-Allow-Origin": {"*"}, "Access-Control-Allow-Methods": {"POST"}, "Access-Control-Allow-Headers": {"content-type"}, "Cache-Control": {"no-cache, private"}, "Content-Type": {"text/html; charset=UTF-8"}, "Vary": {"Access-Control-Request-Method, Access-Control-Request-Headers"}, }) if rec.Code != http.StatusNoContent { t.Fatalf("OPTIONS status %d", rec.Code) } } // assertHeaders requires got to carry exactly the header set want. func assertHeaders(t *testing.T, label string, got, want http.Header) { t.Helper() for k, v := range want { if g := got.Values(k); strings.Join(g, "|") != strings.Join(v, "|") { t.Errorf("%s: %s = %q, want %q", label, k, g, v) } } for k := range got { if _, ok := want[k]; !ok { t.Errorf("%s: unexpected header %s: %q", label, k, got.Values(k)) } } } const spaProfileYAML = `cors_profiles: spa: allowed_origins: ["https://www.example.com"] allowed_methods: ["GET", "POST"] allowed_headers: ["Content-Type"] supports_credentials: true open: allowed_origins: ["*"] allowed_methods: ["GET"] ` func TestCORSProfilePreflightRoute(t *testing.T) { const www = "https://www.example.com" plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Group("/x", Use("cors:spa"), func(g pact.Router) { g.Get("/me", okHandler) g.Post("/me", okHandler) g.Get("/items/{id}", okHandler) }) r.Get("/public", okHandler, "cors:open") r.Get("/plain", okHandler) }} app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}) router, err := BuildRouter(app, []party.Plugin{plugin}) if err != nil { t.Fatal(err) } options := map[string]int{} for _, ri := range router.Routes() { if ri.Method == http.MethodOptions { if ri.PluginID != corsPreflightOwner { t.Errorf("OPTIONS %s owned by %q", ri.Pattern, ri.PluginID) } options[ri.Pattern]++ } } if len(options) != 3 || options["/x/me"] != 1 || options["/x/items/{id}"] != 1 || options["/public"] != 1 { t.Fatalf("OPTIONS routes = %v, want one per profiled path", options) } h, err := router.compile() if err != nil { t.Fatal(err) } rec := serveCORS(h, corsRequest(http.MethodOptions, "/x/items/7", www, true)) if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Methods") != "GET, POST" { t.Fatalf("spa preflight = %d %v", rec.Code, rec.Header()) } rec = serveCORS(h, corsRequest(http.MethodOptions, "/x/me", "https://evil.example.org", true)) if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" || !varyHas(rec.Header(), "Origin") { t.Fatalf("refused preflight = %d %v", rec.Code, rec.Header()) } rec = serveCORS(h, corsRequest(http.MethodOptions, "/public", "https://any.example.net", true)) if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "*" || rec.Header().Get("Access-Control-Allow-Credentials") != "" { t.Fatalf("open preflight = %d %v", rec.Code, rec.Header()) } // A path without a profile gets no preflight route. rec = serveCORS(h, corsRequest(http.MethodOptions, "/plain", www, true)) if rec.Code == http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" { t.Fatalf("plain OPTIONS = %d %v", rec.Code, rec.Header()) } // The profile also applies to the real request. rec = serveCORS(h, corsRequest(http.MethodPost, "/x/me", www, false)) if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Credentials") != "true" { t.Fatalf("POST = %d %v", rec.Code, rec.Header()) } } func TestCORSProfileUnknown(t *testing.T) { plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/x/me", okHandler, "cors:missing") }} _, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) if err == nil || !strings.Contains(err.Error(), `"missing"`) || !strings.Contains(err.Error(), "http.cors_profiles.missing") { t.Fatalf("error = %v", err) } // No profiles configured at all. _, err = Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}), []party.Plugin{plugin}) if err == nil || !strings.Contains(err.Error(), "missing") { t.Fatalf("error without profiles = %v", err) } // A profile name outside the allowed characters. _, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles:\n Spa.App:\n allowed_origins: [\"https://x.example.com\"]\n")) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.") { t.Fatalf("bad name error = %v", err) } // cors_profiles that is not a map. _, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles: [spa]\n")) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles") { t.Fatalf("list error = %v", err) } } func TestCORSProfileWithPathsRefused(t *testing.T) { _, err := LoadCORSProfiles(writeHTTPConfig(t, `cors_profiles: spa: paths: ["api/*"] allowed_origins: ["https://www.example.com"] `)) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") || !strings.Contains(err.Error(), "cors:spa") { t.Fatalf("error = %v", err) } _, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles: spa: paths: ["x/*"] `}), nil) if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") { t.Fatalf("BuildRouter error = %v", err) } } func TestCORSProfileOverlapGlobal(t *testing.T) { plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/api/me", okHandler, "cors:spa") }} app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML + `cors: paths: ["api/*"] allowed_origins: ["*"] `}) _, err := BuildRouter(app, []party.Plugin{plugin}) if err == nil || !strings.Contains(err.Error(), "http.cors.paths") || !strings.Contains(err.Error(), "/api/me") { t.Fatalf("error = %v", err) } // An OPTIONS route already registered on a profiled path collides. r := New(nil) r.corsProfiles = map[string]*corsPolicy{"spa": newCORSPolicy(CORSConfig{AllowedOrigins: []string{"https://www.example.com"}})} r.add("acme.spa", "", nil, http.MethodGet, "/x/me", okHandler, []string{"cors:spa"}, false) r.add("acme.other", "", nil, http.MethodOptions, "/x/me", okHandler, nil, false) err = r.addCORSPreflights() if err == nil || !strings.Contains(err.Error(), "OPTIONS /x/me") || !strings.Contains(err.Error(), "acme.other") { t.Fatalf("own OPTIONS error = %v", err) } } func TestCORSProfileConflictingProfilesSamePath(t *testing.T) { plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/x/me", okHandler, "cors:spa") r.Post("/x/me", okHandler, "cors:open") }} _, err := BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) if err == nil || !strings.Contains(err.Error(), "/x/me") || !strings.Contains(err.Error(), "cors:spa") || !strings.Contains(err.Error(), "cors:open") { t.Fatalf("error = %v", err) } two := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Group("/x", Use("cors:spa"), func(g pact.Router) { g.Get("/me", okHandler, "cors:open") }) }} _, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{two}) if err == nil || !strings.Contains(err.Error(), "two CORS profiles") { t.Fatalf("two profiles on one route error = %v", err) } } func TestTurnstileFactoryParams(t *testing.T) { files := func() map[string]string { return map[string]string{"http.yaml": transportBodyLimits} } for _, mw := range []string{"turnstile:open", "turnstile:closed"} { if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")}); err != nil { t.Fatalf("%s: %v", mw, err) } } for _, mw := range []string{"turnstile:maybe", "turnstile:Closed", "turnstile:"} { _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")}) if err == nil || !strings.Contains(err.Error(), "turnstile:open or turnstile:closed") { t.Fatalf("%s: error = %v", mw, err) } } if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile", "turnstile:open")}); err == nil || !strings.Contains(err.Error(), `"turnstile"`) { t.Fatalf("bare turnstile: error = %v", err) } } // guardRoutesPlugin is a routesPluginFunc that also registers named // middleware. type guardRoutesPlugin struct { routesPluginFunc mw map[string]pact.Middleware } func (p guardRoutesPlugin) Middlewares() map[string]pact.Middleware { return p.mw } // refusalMiddlewares answers 401 (deny) and 403 (forbid) without calling // next, and 413 from readall when the body exceeds the route's limit. func refusalMiddlewares() map[string]pact.Middleware { refuse := func(status int) pact.Middleware { return func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(`{"error":"refused"}`)) }) } } return map[string]pact.Middleware{ "deny": refuse(http.StatusUnauthorized), "forbid": refuse(http.StatusForbidden), "readall": func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := io.ReadAll(r.Body); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { w.WriteHeader(http.StatusRequestEntityTooLarge) return } w.WriteHeader(http.StatusBadRequest) return } next.ServeHTTP(w, r) }) }, } } // assertSPAProfileHeaders requires the spa profile's answer for an allowed // origin. func assertSPAProfileHeaders(t *testing.T, label string, rec *httptest.ResponseRecorder, status int) { t.Helper() const www = "https://www.example.com" if rec.Code != status { t.Fatalf("%s: status %d, want %d (body %q)", label, rec.Code, status, rec.Body.String()) } hd := rec.Header() if got := hd.Get("Access-Control-Allow-Origin"); got != www { t.Errorf("%s: Allow-Origin = %q, want %q", label, got, www) } if got := hd.Get("Access-Control-Allow-Credentials"); got != "true" { t.Errorf("%s: Allow-Credentials = %q, want true", label, got) } if !varyHas(hd, "Origin") { t.Errorf("%s: Vary = %q, want Origin", label, hd.Values("Vary")) } } func TestCORSProfileHeadersOnRefusals(t *testing.T) { const www = "https://www.example.com" panicky := func(http.ResponseWriter, *http.Request) { panic("boom") } plugin := guardRoutesPlugin{ routesPluginFunc: routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/deny", okHandler, "deny", "cors:spa") r.Get("/forbid", okHandler, "forbid", "cors:spa") r.Group("/guarded", Use("deny"), func(g pact.Router) { g.Get("/me", okHandler, "cors:spa") }) r.Get("/throttled", okHandler, "throttle:1,1", "cors:spa") r.Post("/upload", okHandler, "body.limit:16", "readall", "cors:spa") r.Get("/panic", panicky, "cors:spa") r.GroupRaw("/raw", Use("cors:spa"), func(g pact.Router) { g.Get("/panic", panicky) }) }}, mw: refusalMiddlewares(), } h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) if err != nil { t.Fatal(err) } t.Run("guard_401", func(t *testing.T) { assertSPAProfileHeaders(t, "deny", serveCORS(h, corsRequest(http.MethodGet, "/deny", www, false)), http.StatusUnauthorized) }) t.Run("guard_403", func(t *testing.T) { assertSPAProfileHeaders(t, "forbid", serveCORS(h, corsRequest(http.MethodGet, "/forbid", www, false)), http.StatusForbidden) }) t.Run("group_guard_401", func(t *testing.T) { assertSPAProfileHeaders(t, "group deny", serveCORS(h, corsRequest(http.MethodGet, "/guarded/me", www, false)), http.StatusUnauthorized) }) t.Run("throttle_429", func(t *testing.T) { assertSPAProfileHeaders(t, "first", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusOK) assertSPAProfileHeaders(t, "second", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusTooManyRequests) }) t.Run("body_limit_413", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader(strings.Repeat("x", 64))) req.Header.Set("Origin", www) assertSPAProfileHeaders(t, "upload", serveCORS(h, req), http.StatusRequestEntityTooLarge) }) t.Run("panic_500_json", func(t *testing.T) { rec := serveCORS(h, corsRequest(http.MethodGet, "/panic", www, false)) assertSPAProfileHeaders(t, "panic", rec, http.StatusInternalServerError) if !strings.Contains(rec.Header().Get("Content-Type"), "json") { t.Errorf("panic Content-Type = %q, want JSON", rec.Header().Get("Content-Type")) } }) t.Run("panic_500_raw", func(t *testing.T) { assertSPAProfileHeaders(t, "raw panic", serveCORS(h, corsRequest(http.MethodGet, "/raw/panic", www, false)), http.StatusInternalServerError) }) t.Run("disallowed_origin", func(t *testing.T) { rec := serveCORS(h, corsRequest(http.MethodGet, "/deny", "https://evil.example.org", false)) if rec.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Allow-Origin = %q, want none", got) } if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { t.Errorf("Allow-Credentials = %q, want none", got) } if !varyHas(rec.Header(), "Origin") { t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary")) } }) turnstilePlugin := routesPluginFunc{id: "acme.guest", routes: func(r pact.Router) { r.Post("/guest", okHandler, "turnstile:closed", "cors:spa") }} post := func(h http.Handler) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/guest", strings.NewReader(`{"name":"x"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", www) return serveCORS(h, req) } t.Run("turnstile_422", func(t *testing.T) { // BuildRouter builds one verifier per router, so each Turnstile // case assembles its own app. An empty token is refused before any // siteverify call. app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}) if err := app.Publish[*bouncer.Turnstile](bouncer.NewTurnstile("test-secret", false)); err != nil { t.Fatal(err) } h, err := Assemble(app, []party.Plugin{turnstilePlugin}) if err != nil { t.Fatal(err) } assertSPAProfileHeaders(t, "turnstile without a token", post(h), http.StatusUnprocessableEntity) }) t.Run("turnstile_503", func(t *testing.T) { h, err := Assemble(transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{turnstilePlugin}) if err != nil { t.Fatal(err) } assertSPAProfileHeaders(t, "turnstile without a secret in production", post(h), http.StatusServiceUnavailable) }) } func TestCORSProfileVaryKeptWithHandlerVary(t *testing.T) { const www = "https://www.example.com" plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/x/me", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Vary", "Accept-Language") w.WriteHeader(http.StatusOK) }, "cors:spa") }} h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) if err != nil { t.Fatal(err) } rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", www, false)) assertSPAProfileHeaders(t, "handler Vary", rec, http.StatusOK) if !varyHas(rec.Header(), "Accept-Language") { t.Errorf("Vary = %q, want Accept-Language too", rec.Header().Values("Vary")) } } func TestTurnstileEmptySecretWarnsAtBoot(t *testing.T) { // boot builds a router on an app whose published logger writes JSON // lines to a buffer and returns the warnings naming turnstile.secret. boot := func(t *testing.T, env string, verifier *bouncer.Turnstile, plugins ...party.Plugin) []map[string]any { t.Helper() app := transportApp(t, env, map[string]string{"http.yaml": transportBodyLimits}) var buf bytes.Buffer if err := app.Publish(slog.New(slog.NewJSONHandler(&buf, nil))); err != nil { t.Fatal(err) } if verifier != nil { if err := app.Publish[*bouncer.Turnstile](verifier); err != nil { t.Fatal(err) } } if _, err := BuildRouter(app, plugins); err != nil { t.Fatal(err) } var out []map[string]any for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("log line %q: %v", line, err) } if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "surf: turnstile.secret is empty") { out = append(out, rec) } } return out } got := boot(t, "production", nil, turnstileRoutes("turnstile:closed", "turnstile:open")) if len(got) != 1 { t.Fatalf("warnings = %v, want one", got) } if got[0]["level"] != "WARN" || got[0]["routes"] != float64(2) || got[0]["production"] != true { t.Fatalf("warning = %v, want WARN with routes 2 and production true", got[0]) } if msg := got[0]["msg"].(string); !strings.Contains(msg, "turnstile:open passes every request") || !strings.Contains(msg, "503 in production") { t.Errorf("message = %q", msg) } // A published verifier with a secret: no warning. if got := boot(t, "production", bouncer.NewTurnstile("test-secret", true), turnstileRoutes("turnstile:closed", "turnstile:open")); len(got) != 0 { t.Fatalf("warnings with a secret = %v", got) } // No turnstile routes: no warning. plain := routesPluginFunc{id: "acme.plain", routes: func(r pact.Router) { r.Get("/x", okHandler) }} if got := boot(t, "development", nil, plain); len(got) != 0 { t.Fatalf("warnings without turnstile routes = %v", got) } } func TestCORSCredentialedOriginRules(t *testing.T) { // Each block lists a good entry first, so a refusal names index [1]. blocks := []struct{ name, prefix, yamlHead, indent string }{ {"global", "http.cors", "cors:\n paths: [\"api/*\"]\n", " "}, {"profile", "http.cors_profiles.spa", "cors_profiles:\n spa:\n", " "}, } load := func(t *testing.T, block int, body string) error { t.Helper() b := blocks[block] cfg := writeHTTPConfig(t, b.yamlHead+body) if b.name == "global" { _, err := LoadCORSConfig(cfg) return err } _, err := LoadCORSProfiles(cfg) return err } entry := func(indent, key, good, value string, credentials bool) string { out := indent + key + ": ['" + good + "', '" + value + "']\n" if credentials { out += indent + "supports_credentials: true\n" } return out } const goodOrigin, goodPattern = "https://www.example.com", `^https://www\.example\.com$` accepted := []struct{ key, value string }{ {"allowed_origins", "https://www.example.com"}, {"allowed_origins", "https://*.example.com"}, {"allowed_origins", "https://*.api.example.com:8443"}, {"allowed_origins_patterns", `^https://[a-z]+\.example\.org$`}, } refused := []struct{ key, value string }{ {"allowed_origins", "https://*"}, {"allowed_origins", "*://*"}, {"allowed_origins", "https://*example.com"}, {"allowed_origins", "https://*.com"}, {"allowed_origins", "http*://www.example.com"}, {"allowed_origins_patterns", `https://.*\.example\.com`}, {"allowed_origins_patterns", `^https://.*\.example\.com`}, {"allowed_origins_patterns", `^https?://.*$`}, {"allowed_origins_patterns", `^.*$`}, } for i, b := range blocks { for _, tc := range accepted { good := goodOrigin if tc.key == "allowed_origins_patterns" { good = goodPattern } if err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)); err != nil { t.Errorf("%s %s %q with credentials: %v", b.name, tc.key, tc.value, err) } } for _, tc := range refused { good := goodOrigin if tc.key == "allowed_origins_patterns" { good = goodPattern } want := b.prefix + "." + tc.key + "[1]" err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)) if err == nil || !strings.Contains(err.Error(), want) { t.Errorf("%s %q with credentials: error = %v, want one naming %s", b.name, tc.value, err, want) } if err := load(t, i, entry(b.indent, tc.key, good, tc.value, false)); err != nil { t.Errorf("%s %q without credentials: %v", b.name, tc.value, err) } } } } func TestCORSAnchoredPatternNoSuffixMatch(t *testing.T) { plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { r.Get("/x/me", okHandler, "cors:spa") }} app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles: spa: allowed_origins_patterns: ['^https://[a-z]+\.example\.com$'] allowed_methods: ["GET"] supports_credentials: true `}) h, err := Assemble(app, []party.Plugin{plugin}) if err != nil { t.Fatal(err) } rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com", false)) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" || rec.Header().Get("Access-Control-Allow-Credentials") != "true" { t.Fatalf("allowed origin: Allow-Origin %q Allow-Credentials %q", got, rec.Header().Get("Access-Control-Allow-Credentials")) } rec = serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com.attacker.net", false)) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("suffixed origin: Allow-Origin = %q, want none", got) } if rec.Header().Get("Access-Control-Allow-Credentials") != "" { t.Fatal("suffixed origin: Allow-Credentials set") } if !varyHas(rec.Header(), "Origin") { t.Fatalf("suffixed origin: Vary = %q, want Origin", rec.Header().Values("Vary")) } }