gateway: CORS allow-list for the authenticated Connect-Web surface
Tests · Go / test (push) Successful in 2m9s
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Successful in 2m52s

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:
Ilia Denisov
2026-05-16 22:15:11 +02:00
parent 4b2a949f12
commit 57e6c1d253
4 changed files with 96 additions and 1 deletions
+27
View File
@@ -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
}