From 57e6c1d253fe263482d0d17fe7396012c7ec24d4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 16 May 2026 22:15:11 +0200 Subject: [PATCH] gateway: CORS allow-list for the authenticated Connect-Web surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public REST listener already exposes `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS`; the authenticated Connect-Web listener on the separate gRPC port had no equivalent. That worked in `tools/local-dev` (Vite proxy makes everything same-origin) and would work in production once UI and gateway share a single hostname, but the long-lived dev environment serves the UI from `https://www.galaxy.lan` and the gateway from `https://api.galaxy.lan` — every `/galaxy.gateway.v1.EdgeGateway/*` fetch failed in the browser with the WebKit "Load failed" generic message because the response carried no `Access-Control-Allow-Origin` header. Lobby rendered as "[unknown] Load failed" with no game. Mirror the public-REST CORS surface for the authenticated handler: - new env `GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS`; - new `AuthenticatedGRPCConfig.CORSAllowedOrigins` field; - new `grpcapi.withCORS` middleware wrapping the Connect mux; - dev-deploy stack sets the env to `https://www.galaxy.lan`. The middleware speaks plain net/http (the Connect handler is mounted on a ServeMux, not gin), handles preflight 204 immediately, and exposes the Connect-Web header set the browser needs to read the response (`Grpc-Status`, `Grpc-Message`, `Connect-Protocol-Version`). Empty allow-list disables the middleware — production stays at "single hostname" by default. Co-Authored-By: Claude Opus 4.7 --- gateway/internal/config/config.go | 27 ++++++++++++ gateway/internal/grpcapi/cors.go | 64 +++++++++++++++++++++++++++++ gateway/internal/grpcapi/server.go | 5 ++- tools/dev-deploy/docker-compose.yml | 1 + 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 gateway/internal/grpcapi/cors.go diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 7d0f70a..128e400 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -101,6 +101,16 @@ const ( // the authenticated gRPC listener address. authenticatedGRPCAddrEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ADDR" + // authenticatedGRPCCORSAllowedOriginsEnvVar names the environment + // variable that configures the comma-separated list of browser + // origins permitted to call the authenticated Connect-Web surface. + // An empty value disables CORS entirely; the listener then refuses + // to send Access-Control-* headers and browsers block cross-origin + // fetches. Set this in any deployment that fronts the gateway + // behind a different hostname than the SvelteKit bundle (e.g. + // `https://www.galaxy.lan` calling `https://api.galaxy.lan`). + authenticatedGRPCCORSAllowedOriginsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS" + // authenticatedGRPCConnectionTimeoutEnvVar names the environment variable // that configures the inbound connection handshake timeout for the // authenticated gRPC listener. @@ -542,6 +552,13 @@ type AuthenticatedGRPCConfig struct { // AntiAbuse configures the authenticated gRPC rate limits enforced after // the request passes the transport authenticity checks. AntiAbuse AuthenticatedGRPCAntiAbuseConfig + + // CORSAllowedOrigins is the exact-match list of browser origins + // permitted to call the authenticated Connect-Web surface. Empty + // disables CORS — requests without an Access-Control-Allow-Origin + // response will be blocked by the browser, which is the production + // posture when the UI and the gateway share a single hostname. + CORSAllowedOrigins []string } // SessionCacheConfig describes the bounds of the gateway's in-memory @@ -836,6 +853,16 @@ func LoadFromEnv() (Config, error) { cfg.PublicHTTP.CORSAllowedOrigins = origins } + if v, ok := os.LookupEnv(authenticatedGRPCCORSAllowedOriginsEnvVar); ok { + origins := make([]string, 0) + for part := range strings.SplitSeq(v, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + origins = append(origins, trimmed) + } + } + cfg.AuthenticatedGRPC.CORSAllowedOrigins = origins + } + if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok { cfg.Backend.HTTPBaseURL = v } diff --git a/gateway/internal/grpcapi/cors.go b/gateway/internal/grpcapi/cors.go new file mode 100644 index 0000000..ea23557 --- /dev/null +++ b/gateway/internal/grpcapi/cors.go @@ -0,0 +1,64 @@ +package grpcapi + +import ( + "net/http" +) + +// withCORS wraps next so that CORS preflight (OPTIONS) requests with an +// allow-listed Origin receive 204 plus the `Access-Control-Allow-*` +// headers Connect-Web needs, and actual requests get the matching +// `Access-Control-Allow-Origin` header echoed back. Origins are +// compared exactly: scheme, host, and port must match. An empty +// allow-list passes through untouched — the production posture when +// the UI and the gateway share one hostname. +// +// The wrapper mirrors `restapi.withCORS` but speaks plain `net/http` +// because the Connect handler is mounted on a `http.ServeMux`, not a +// gin engine. Connect-Web POSTs use `Content-Type: application/connect+json` +// which triggers a browser preflight; without these headers the +// browser surfaces "Load failed" before the Connect handler even sees +// the request. +func withCORS(allowedOrigins []string, next http.Handler) http.Handler { + allowed := make(map[string]struct{}, len(allowedOrigins)) + for _, origin := range allowedOrigins { + allowed[origin] = struct{}{} + } + if len(allowed) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + next.ServeHTTP(w, r) + return + } + if _, ok := allowed[origin]; !ok { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + } else { + // Defaults cover the Connect-Web preflight set: protocol + // version, content type, timeout, and the signed-request + // metadata the gateway interceptor expects. + w.Header().Set("Access-Control-Allow-Headers", + "Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Authorization") + } + // Expose the response headers Connect-Web needs to read on + // the client (e.g. trailers folded into headers for unary). + w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message") + w.Header().Set("Access-Control-Max-Age", "3600") + w.WriteHeader(http.StatusNoContent) + return + } + // Expose the same response headers on the actual call. + w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message") + next.ServeHTTP(w, r) + }) +} diff --git a/gateway/internal/grpcapi/server.go b/gateway/internal/grpcapi/server.go index e101b58..93cf13e 100644 --- a/gateway/internal/grpcapi/server.go +++ b/gateway/internal/grpcapi/server.go @@ -169,7 +169,10 @@ func (s *Server) Run(ctx context.Context) error { ) mux.Handle(path, handler) - tracedHandler := otelhttp.NewHandler(mux, "authenticated_edge") + // CORS runs OUTSIDE the otelhttp wrapper so preflight OPTIONS calls + // answer with 204 immediately and never enter the trace path. + corsMux := withCORS(s.cfg.CORSAllowedOrigins, mux) + tracedHandler := otelhttp.NewHandler(corsMux, "authenticated_edge") http2Server := &http2.Server{IdleTimeout: s.cfg.ConnectionTimeout} httpServer := &http.Server{ Handler: h2c.NewHandler(tracedHandler, http2Server), diff --git a/tools/dev-deploy/docker-compose.yml b/tools/dev-deploy/docker-compose.yml index 3deb385..0962b3e 100644 --- a/tools/dev-deploy/docker-compose.yml +++ b/tools/dev-deploy/docker-compose.yml @@ -171,6 +171,7 @@ services: # https://api.galaxy.lan. Browsers therefore issue cross-origin # requests to the gateway and need an explicit allow-list. GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS: "https://www.galaxy.lan" + GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS: "https://www.galaxy.lan" # Anti-abuse defaults are looser than production: the dev # environment is shared by a handful of trusted testers who # frequently hammer the same identity to reproduce flows.