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.