mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 22:24:41 +00:00
4b8d8143f4
## 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: <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 <you@example.com>
150 lines
4.4 KiB
Go
150 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|