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)