package middleware import ( "net/http" "net/http/httptest" "reflect" "testing" ) const uiOrigin = "https://ui.example.com" // corsChain mirrors cmd/server's ordering: CORS runs before API key auth, so a // preflight is answered without needing the key. func corsChain(t testing.TB, origins []string, production bool, apiKey string) (http.Handler, CORSMode) { cors, mode := CORS(origins, production, apiKey != "") auth, _ := newAuth(t, apiKey) api := auth.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) return cors(api), mode } func preflight(h http.Handler, origin, headers string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodOptions, "/v1/webhooks", nil) req.Header.Set("Origin", origin) req.Header.Set("Access-Control-Request-Method", http.MethodGet) req.Header.Set("Access-Control-Request-Headers", headers) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func get(h http.Handler, origin, apiKey string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/v1/webhooks", nil) req.Header.Set("Origin", origin) if apiKey != "" { req.Header.Set(APIKeyHeader, apiKey) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestCORSModes(t *testing.T) { cases := []struct { name string origins []string production bool auth bool want CORSMode }{ {"allow list", []string{uiOrigin}, true, true, CORSAllowList}, {"allow list in dev", []string{uiOrigin}, false, false, CORSAllowList}, {"production default", nil, true, true, CORSBlockAll}, {"production blank entries", []string{"", " "}, true, false, CORSBlockAll}, {"dev default with key", nil, false, true, CORSAllowAll}, {"dev default without key", nil, false, false, CORSLoopbackOnly}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, got := CORS(tc.origins, tc.production, tc.auth); got != tc.want { t.Fatalf("mode = %v, want %v", got, tc.want) } }) } } func TestCORSProductionDefaultBlocksCrossOrigin(t *testing.T) { // Regression: rs/cors treats an empty AllowedOrigins as "*", so the // production lockdown used to allow every origin. h, _ := corsChain(t, nil, true, "") if got := preflight(h, "https://evil.example.com", "x-api-key").Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("preflight Access-Control-Allow-Origin = %q, want none", got) } if got := get(h, "https://evil.example.com", "").Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("GET Access-Control-Allow-Origin = %q, want none", got) } } func TestCORSAllowListSplitDeploymentWithAPIKey(t *testing.T) { // UI on its own origin, server in production with an API key: the // preflight must succeed without the key, the real request needs it. h, _ := corsChain(t, []string{uiOrigin + "/"}, true, "secret") // Browsers send the requested headers lowercased and sorted. rec := preflight(h, uiOrigin, "content-type,x-api-key") if rec.Code != http.StatusNoContent { t.Fatalf("preflight status = %d, want 204 (must not hit API key auth)", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != uiOrigin { t.Fatalf("preflight Access-Control-Allow-Origin = %q, want %q", got, uiOrigin) } if got := rec.Header().Get("Access-Control-Allow-Headers"); got == "" { t.Fatal("preflight did not allow the X-API-Key header") } if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { t.Fatalf("Access-Control-Allow-Credentials = %q, want none (auth is header based)", got) } rec = get(h, uiOrigin, "secret") if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != uiOrigin { t.Fatalf("GET with key: status %d, ACAO %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin")) } // A 401 must still carry CORS headers so the browser lets the UI read it // and show the API key prompt instead of an opaque network error. rec = get(h, uiOrigin, "") if rec.Code != http.StatusUnauthorized || rec.Header().Get("Access-Control-Allow-Origin") != uiOrigin { t.Fatalf("GET without key: status %d, ACAO %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin")) } if got := preflight(h, "https://other.example.com", "x-api-key").Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("unlisted origin got Access-Control-Allow-Origin = %q", got) } } func TestCORSDevAllowsAnyOriginWithAPIKeyHeader(t *testing.T) { // `make run` + `make run-web`: vite on :5173 calling :8080. h, _ := corsChain(t, nil, false, "secret") rec := preflight(h, "http://localhost:5173", "x-api-key") if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") == "" { t.Fatalf("preflight: status %d, ACAO %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin")) } if got := get(h, "http://localhost:5173", "secret").Code; got != http.StatusOK { t.Fatalf("GET status = %d, want 200", got) } } func TestCORSDevWithoutKeyAllowsOnlyLoopback(t *testing.T) { // No key means every caller is root: a page on another site must not be // able to read responses, while `make run-web` on localhost still works. h, _ := corsChain(t, nil, false, "") for _, origin := range []string{"http://localhost:5173", "http://127.0.0.1:3000", "http://[::1]:5173"} { if got := preflight(h, origin, "content-type").Header().Get("Access-Control-Allow-Origin"); got != origin { t.Errorf("%s: preflight ACAO = %q, want it allowed", origin, got) } } for _, origin := range []string{"https://evil.example.com", "http://localhost.evil.example.com", "http://10.0.0.5:5173", "null"} { if got := preflight(h, origin, "content-type").Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("%s: preflight ACAO = %q, want none", origin, got) } if got := get(h, origin, "").Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("%s: GET ACAO = %q, want none", origin, got) } } } func TestNormalizeOrigins(t *testing.T) { got := NormalizeOrigins([]string{" https://a.example.com/ ", "", "https://b.example.com//", " "}) want := []string{"https://a.example.com", "https://b.example.com"} if !reflect.DeepEqual(got, want) { t.Fatalf("NormalizeOrigins = %v, want %v", got, want) } }