From 4b8d8143f462a3b7bcf0abbecbc0a203b678ad66 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Sat, 2 May 2026 12:04:37 -0700 Subject: [PATCH] feat(server): explicit CORS policy with configurable origin allowlist (#883) (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds explicit CORS policy support to the CoreScope API server, closing #883. ### Problem The API relied on browser same-origin defaults with no way for operators to configure cross-origin access. Operators running dashboards or third-party frontends on different origins had no supported way to make API calls. ### Solution **New config option:** `corsAllowedOrigins` (string array, default `[]`) **Middleware behavior:** | Config | Behavior | |--------|----------| | `[]` (default) | No `Access-Control-*` headers added — browsers enforce same-origin. **Preserves current behavior.** | | `["https://dashboard.example.com"]` | Echoes matching `Origin`, sets `Allow-Methods`/`Allow-Headers` | | `["*"]` | Sets `Access-Control-Allow-Origin: *` (explicit opt-in only) | **Headers set when origin matches:** - `Access-Control-Allow-Origin: ` (or `*`) - `Access-Control-Allow-Methods: GET, POST, OPTIONS` - `Access-Control-Allow-Headers: Content-Type, X-API-Key` - `Vary: Origin` (non-wildcard only) **Preflight handling:** `OPTIONS` → `204 No Content` with CORS headers (or `403` if origin not in allowlist). ### Config example ```json { "corsAllowedOrigins": ["https://dashboard.example.com", "https://monitor.internal"] } ``` ### Files changed | File | Change | |------|--------| | `cmd/server/cors.go` | New CORS middleware | | `cmd/server/cors_test.go` | 7 unit tests covering all branches | | `cmd/server/config.go` | `CORSAllowedOrigins` field | | `cmd/server/routes.go` | Wire middleware before all routes | ### Testing **Unit tests (7):** - Default config → no CORS headers - Allowlist match → headers present with `Vary: Origin` - Allowlist miss → no CORS headers - Preflight allowed → 204 with headers - Preflight rejected → 403 - Wildcard → `*` without `Vary` - No `Origin` header → pass-through **Live verification (Rule 18):** ``` # Default (empty corsAllowedOrigins): $ curl -I -H "Origin: https://evil.example" localhost:19883/api/health HTTP/1.1 200 OK # No Access-Control-* headers ✓ # With corsAllowedOrigins: ["https://good.example"]: $ curl -I -H "Origin: https://good.example" localhost:19884/api/health Access-Control-Allow-Origin: https://good.example Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-API-Key Vary: Origin ✓ $ curl -I -H "Origin: https://evil.example" localhost:19884/api/health # No Access-Control-* headers ✓ $ curl -I -X OPTIONS -H "Origin: https://good.example" localhost:19884/api/health HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://good.example ✓ ``` Closes #883 Co-authored-by: you --- cmd/server/config.go | 5 ++ cmd/server/cors.go | 66 ++++++++++++++++++ cmd/server/cors_test.go | 149 ++++++++++++++++++++++++++++++++++++++++ cmd/server/routes.go | 3 + 4 files changed, 223 insertions(+) create mode 100644 cmd/server/cors.go create mode 100644 cmd/server/cors_test.go diff --git a/cmd/server/config.go b/cmd/server/config.go index d5f5f569..0c1a296c 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -70,6 +70,11 @@ type Config struct { Timestamps *TimestampConfig `json:"timestamps,omitempty"` + // CORSAllowedOrigins is the list of origins permitted to make cross-origin + // requests. When empty (default), no Access-Control-* headers are sent, + // so browsers enforce same-origin policy. Set to ["*"] to allow all origins. + CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"` + DebugAffinity bool `json:"debugAffinity,omitempty"` // ObserverBlacklist is a list of observer public keys to exclude from API diff --git a/cmd/server/cors.go b/cmd/server/cors.go new file mode 100644 index 00000000..9edc1ed6 --- /dev/null +++ b/cmd/server/cors.go @@ -0,0 +1,66 @@ +package main + +import "net/http" + +// corsMiddleware returns a middleware that sets CORS headers based on the +// configured allowed origins. When CORSAllowedOrigins is empty (default), +// no Access-Control-* headers are added, preserving browser same-origin policy. +func (s *Server) corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origins := s.cfg.CORSAllowedOrigins + if len(origins) == 0 { + next.ServeHTTP(w, r) + return + } + + reqOrigin := r.Header.Get("Origin") + if reqOrigin == "" { + next.ServeHTTP(w, r) + return + } + + // Check if origin is allowed + allowed := false + wildcard := false + for _, o := range origins { + if o == "*" { + allowed = true + wildcard = true + break + } + if o == reqOrigin { + allowed = true + break + } + } + + if !allowed { + // Origin not in allowlist — don't add CORS headers + if r.Method == http.MethodOptions { + // Still reject preflight with 403 + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + return + } + + // Set CORS headers + if wildcard { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", reqOrigin) + w.Header().Set("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key") + + // Handle preflight + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/server/cors_test.go b/cmd/server/cors_test.go new file mode 100644 index 00000000..acafaaee --- /dev/null +++ b/cmd/server/cors_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// newTestServerWithCORS creates a minimal Server with the given CORS config. +func newTestServerWithCORS(origins []string) *Server { + cfg := &Config{CORSAllowedOrigins: origins} + srv := &Server{cfg: cfg} + return srv +} + +// dummyHandler is a simple handler that writes 200 OK. +var dummyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +}) + +func TestCORS_DefaultNoHeaders(t *testing.T) { + srv := newTestServerWithCORS(nil) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + req.Header.Set("Origin", "https://evil.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" { + t.Fatalf("expected no ACAO header, got %q", v) + } +} + +func TestCORS_AllowlistMatch(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://good.example"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + req.Header.Set("Origin", "https://good.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" { + t.Fatalf("expected origin echo, got %q", v) + } + if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, POST, OPTIONS" { + t.Fatalf("expected methods header, got %q", v) + } + if v := rr.Header().Get("Access-Control-Allow-Headers"); v != "Content-Type, X-API-Key" { + t.Fatalf("expected headers header, got %q", v) + } + if v := rr.Header().Get("Vary"); v != "Origin" { + t.Fatalf("expected Vary: Origin, got %q", v) + } +} + +func TestCORS_AllowlistNoMatch(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://good.example"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + req.Header.Set("Origin", "https://evil.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" { + t.Fatalf("expected no ACAO header for non-matching origin, got %q", v) + } +} + +func TestCORS_PreflightAllowed(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://good.example"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("OPTIONS", "/api/health", nil) + req.Header.Set("Origin", "https://good.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" { + t.Fatalf("expected origin echo, got %q", v) + } +} + +func TestCORS_PreflightRejected(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://good.example"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("OPTIONS", "/api/health", nil) + req.Header.Set("Origin", "https://evil.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rr.Code) + } +} + +func TestCORS_Wildcard(t *testing.T) { + srv := newTestServerWithCORS([]string{"*"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + req.Header.Set("Origin", "https://anything.example") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "*" { + t.Fatalf("expected *, got %q", v) + } + // Wildcard should NOT set Vary: Origin + if v := rr.Header().Get("Vary"); v == "Origin" { + t.Fatalf("wildcard should not set Vary: Origin") + } +} + +func TestCORS_NoOriginHeader(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://good.example"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + // No Origin header + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("expected 200, got %d", rr.Code) + } + if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" { + t.Fatalf("expected no ACAO without Origin header, got %q", v) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 70cfbbd0..34084304 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -104,6 +104,9 @@ func (s *Server) getMemStats() runtime.MemStats { // RegisterRoutes sets up all HTTP routes on the given router. func (s *Server) RegisterRoutes(r *mux.Router) { s.router = r + // CORS middleware (must run before route handlers) + r.Use(s.corsMiddleware) + // Performance instrumentation middleware r.Use(s.perfMiddleware)