diff --git a/backend/internal/cors/cors.go b/backend/internal/cors/cors.go new file mode 100644 index 0000000000..a745d82856 --- /dev/null +++ b/backend/internal/cors/cors.go @@ -0,0 +1,36 @@ +// Copyright Contributors to the Open Cluster Management project + +package cors + +import ( + "net/http" +) + +// Comment to be removed as a part of the backend-node decommissioning, see ACM-42603 +// Middleware mirrors backend-node/src/lib/cors.ts: reflect Origin and answer OPTIONS. +// with 200 in non-production so standalone dev (webpack on :3000/:3001/:3002) can call :4000. +func Middleware(production bool) func(http.Handler) http.Handler { + if production { + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin, Access-Control-Allow-Origin") + } + w.Header().Set("Access-Control-Allow-Credentials", "true") + if r.Method == http.MethodOptions { + if v := r.Header.Get("Access-Control-Request-Method"); v != "" { + w.Header().Set("Access-Control-Allow-Methods", v) + } + if v := r.Header.Get("Access-Control-Request-Headers"); v != "" { + w.Header().Set("Access-Control-Allow-Headers", v) + } + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/internal/cors/cors_test.go b/backend/internal/cors/cors_test.go new file mode 100644 index 0000000000..dbf2b30dcb --- /dev/null +++ b/backend/internal/cors/cors_test.go @@ -0,0 +1,102 @@ +// Copyright Contributors to the Open Cluster Management project + +package cors_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stolostron/console/backend/internal/cors" +) + +func TestMiddleware_ProductionPassthrough(t *testing.T) { + var called bool + h := cors.Middleware(true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if !called { + t.Fatal("expected handler to run in production") + } + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("unexpected CORS headers in production") + } +} + +func TestMiddleware_DevelopmentOptionsPreflight(t *testing.T) { + var called bool + h := cors.Middleware(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization,content-type") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if called { + t.Fatal("handler should not run for OPTIONS preflight") + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3000" { + t.Fatalf("allow-origin %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("missing allow-credentials") + } + if resp.Header.Get("Access-Control-Allow-Methods") != "GET" { + t.Fatalf("allow-methods %q", resp.Header.Get("Access-Control-Allow-Methods")) + } + if resp.Header.Get("Access-Control-Allow-Headers") != "authorization,content-type" { + t.Fatalf("allow-headers %q", resp.Header.Get("Access-Control-Allow-Headers")) + } +} + +func TestMiddleware_DevelopmentNonOptionsAddsHeaders(t *testing.T) { + h := cors.Middleware(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3001") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3001" { + t.Fatalf("allow-origin %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("missing allow-credentials") + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index dc447cd372..3f06d79b80 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stolostron/console/backend/internal/config" + "github.com/stolostron/console/backend/internal/cors" "github.com/stolostron/console/backend/internal/health" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/oauth" @@ -221,6 +222,7 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { sidecar := proxy.New(target, sidecarTLS) r := chi.NewRouter() + r.Use(cors.Middleware(cfg.Production)) r.Use(requestLogger) r.Get("/livenessProbe", probes.Liveness) r.Get("/readinessProbe", probes.Readiness) diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 50b0f7c3a0..50fae6b324 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -460,6 +460,52 @@ func TestConfigureWithoutLoginNotProxied(t *testing.T) { } } +func TestDevelopmentCORSOptionsPreflight(t *testing.T) { + var k8sCalled bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("sidecar should not be called") + })) + defer sidecar.Close() + + k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + k8sCalled = true + w.WriteHeader(http.StatusOK) + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/api", "/multicloud/api"} { + k8sCalled = false + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization,content-type") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if k8sCalled { + t.Fatalf("%s reached k8s proxy", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3000" { + t.Fatalf("%s allow-origin %q", path, resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("%s missing allow-credentials", path) + } + } +} + func TestK8sProxyNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {