gateway: CORS allow-list for the authenticated Connect-Web surface
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user