From 7bce67462cf0a5faac990892ba65d2cf7cab904f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 07:57:15 +0200 Subject: [PATCH 1/2] pkg/util: harden TestRandomSuffixGenerator against birthday collisions The previous test asserted that no two adjacent samples from a ~10 000-element space were equal across 100 iterations. The birthday math gives that adjacency check a ~1 % flake rate per run; with the new gitea.lan CI volume that turned into observable random failures (go-unit #51 on feature/enable-actions-cache hit "Should not be: '6635'"). Replace adjacency with a distinctness floor over a wider 200-sample draw. A stuck generator (single value) lands at 1 unique; a 256-element range lands at ~196; the natural full-range generator lands at ~198. A floor of 150 catches the failure modes the test was actually written to guard against and never trips on legitimate randomness. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/util/string_test.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/util/string_test.go b/pkg/util/string_test.go index 3d2f560..8972f2d 100644 --- a/pkg/util/string_test.go +++ b/pkg/util/string_test.go @@ -282,12 +282,24 @@ func TestAppendRandomSuffixGenerator(t *testing.T) { } func TestRandomSuffixGenerator(t *testing.T) { - var last string - for range 100 { + // The generator draws from a ~10 000 element space (Intn(9999) + // formatted as four digits). Comparing each sample against the + // previous one with NotEqual flaked ~1 % per 100-iteration run on + // natural collisions. Count unique values instead — if the + // generator ever gets stuck on a tiny range we still catch it, + // without depending on the birthday paradox not firing today. + const samples = 200 + seen := make(map[string]struct{}, samples) + for range samples { s := util.RandomSuffixGenerator() assert.Len(t, s, 4) - assert.NotEqual(t, last, s) assert.True(t, strings.ContainsFunc(s, func(r rune) bool { return r >= '0' && r <= '9' })) - last = s + seen[s] = struct{}{} } + // In 200 draws from ~10 000 the expected number of unique values + // is ~198; a stuck generator (single value) would land at 1, a + // 256-value range at ~196. 150 is well above the floor either + // way and well below the expected mean. + assert.GreaterOrEqual(t, len(seen), 150, + "RandomSuffixGenerator drew from too small a range over %d samples", samples) } -- 2.52.0 From 1855e43699f67d8377bbd17840f7b2a49a197cb2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 07:58:14 +0200 Subject: [PATCH 2/2] gateway: add CORS allow-list for the public REST surface Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list on the public REST server so the dev UI on https://www.galaxy.lan can call https://api.galaxy.lan without the browser blocking the cross-origin response. Defaults to empty (no CORS) so the production posture stays closed. The middleware mounts before route classification and anti-abuse, so OPTIONS preflights never charge against per-class rate-limit buckets. `tools/dev-deploy/docker-compose.yml` opts the dev gateway into a single allowed origin (`https://www.galaxy.lan`); local-dev keeps the defaults because Vite proxies through the same origin. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/internal/config/config.go | 22 +++++ gateway/internal/config/config_test.go | 18 ++++ gateway/internal/restapi/cors.go | 52 +++++++++++ gateway/internal/restapi/cors_test.go | 120 +++++++++++++++++++++++++ gateway/internal/restapi/server.go | 4 + tools/dev-deploy/docker-compose.yml | 4 + 6 files changed, 220 insertions(+) create mode 100644 gateway/internal/restapi/cors.go create mode 100644 gateway/internal/restapi/cors_test.go diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 5256f90..7d0f70a 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -40,6 +40,12 @@ const ( // the keep-alive idle timeout for the public REST listener. publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT" + // publicHTTPCORSAllowedOriginsEnvVar names the environment variable that + // configures the comma-separated list of browser origins permitted to + // call the public REST surface. An empty value disables CORS entirely; + // requests without an Origin header still pass through normally. + publicHTTPCORSAllowedOriginsEnvVar = "GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS" + // publicAuthUpstreamTimeoutEnvVar names the environment variable that // configures the timeout budget used for public auth upstream calls. publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT" @@ -457,6 +463,12 @@ type PublicHTTPConfig struct { // AntiAbuse configures the public REST anti-abuse middleware. AntiAbuse PublicHTTPAntiAbuseConfig + + // CORSAllowedOrigins is the exact-match list of browser origins + // permitted to call the public REST surface. Empty disables CORS: + // requests without an Origin header continue to work, cross-origin + // requests are subject to the browser's default same-origin policy. + CORSAllowedOrigins []string } // BackendConfig describes the consolidated backend service the gateway @@ -814,6 +826,16 @@ func LoadFromEnv() (Config, error) { } cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout + if v, ok := os.LookupEnv(publicHTTPCORSAllowedOriginsEnvVar); ok { + origins := make([]string, 0) + for part := range strings.SplitSeq(v, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + origins = append(origins, trimmed) + } + } + cfg.PublicHTTP.CORSAllowedOrigins = origins + } + if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok { cfg.Backend.HTTPBaseURL = v } diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go index 16a3b70..5e1e423 100644 --- a/gateway/internal/config/config_test.go +++ b/gateway/internal/config/config_test.go @@ -158,6 +158,7 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) { assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout) assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout) assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout) + assert.Empty(t, cfg.PublicHTTP.CORSAllowedOrigins, "default disables CORS") assert.Equal(t, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr) assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout) @@ -165,6 +166,22 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) { assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow) } +func TestLoadFromEnvParsesCORSAllowedOrigins(t *testing.T) { + configEnvMu.Lock() + defer configEnvMu.Unlock() + + resetEnv(t) + setBaseRequiredEnv(t) + t.Setenv(publicHTTPCORSAllowedOriginsEnvVar, "https://www.galaxy.lan, , https://staging.galaxy.lan") + + cfg, err := LoadFromEnv() + require.NoError(t, err) + assert.Equal(t, + []string{"https://www.galaxy.lan", "https://staging.galaxy.lan"}, + cfg.PublicHTTP.CORSAllowedOrigins, + "comma-separated list is split, whitespace-trimmed, and empty segments dropped") +} + // resetEnv clears every env var the gateway config might read so that // individual tests can build the exact environment they need without // leakage from a previous test. @@ -179,6 +196,7 @@ func resetEnv(t *testing.T) { publicHTTPReadTimeoutEnvVar, publicHTTPIdleTimeoutEnvVar, publicAuthUpstreamTimeoutEnvVar, + publicHTTPCORSAllowedOriginsEnvVar, backendHTTPURLEnvVar, backendGRPCPushURLEnvVar, backendGatewayClientIDEnvVar, diff --git a/gateway/internal/restapi/cors.go b/gateway/internal/restapi/cors.go new file mode 100644 index 0000000..2c2d667 --- /dev/null +++ b/gateway/internal/restapi/cors.go @@ -0,0 +1,52 @@ +package restapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// withCORS returns a gin middleware that handles browser CORS preflight and +// attaches Access-Control-Allow-* response headers when the request's Origin +// is on the configured allow-list. Origins are compared exactly: scheme, +// host, and port must match. An empty allow-list disables the middleware — +// requests pass through untouched. Requests without an Origin header always +// pass through, the middleware only acts when a browser actually asks. +// +// The middleware mounts before the anti-abuse layer so OPTIONS preflights +// do not count against the rate-limit buckets for the eventual real call. +func withCORS(allowedOrigins []string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(allowedOrigins)) + for _, origin := range allowedOrigins { + allowed[origin] = struct{}{} + } + if len(allowed) == 0 { + return func(c *gin.Context) { c.Next() } + } + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin == "" { + c.Next() + return + } + if _, ok := allowed[origin]; !ok { + c.Next() + return + } + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Credentials", "true") + if c.Request.Method == http.MethodOptions { + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + if reqHeaders := c.GetHeader("Access-Control-Request-Headers"); reqHeaders != "" { + c.Header("Access-Control-Allow-Headers", reqHeaders) + } else { + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + } + c.Header("Access-Control-Max-Age", "3600") + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/gateway/internal/restapi/cors_test.go b/gateway/internal/restapi/cors_test.go new file mode 100644 index 0000000..b92443c --- /dev/null +++ b/gateway/internal/restapi/cors_test.go @@ -0,0 +1,120 @@ +package restapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func newCORSRouter(allowedOrigins []string) *gin.Engine { + router := gin.New() + router.Use(withCORS(allowedOrigins)) + router.GET("/api/v1/public/probe", func(c *gin.Context) { + c.JSON(http.StatusOK, statusResponse{Status: "ok"}) + }) + return router +} + +func TestWithCORSAllowsListedOrigin(t *testing.T) { + t.Parallel() + + router := newCORSRouter([]string{"https://www.galaxy.lan"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil) + req.Header.Set("Origin", "https://www.galaxy.lan") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "Origin", recorder.Header().Get("Vary")) + assert.Equal(t, "true", recorder.Header().Get("Access-Control-Allow-Credentials")) +} + +func TestWithCORSPreflightShortCircuits(t *testing.T) { + t.Parallel() + + router := newCORSRouter([]string{"https://www.galaxy.lan"}) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil) + req.Header.Set("Origin", "https://www.galaxy.lan") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "Content-Type, X-Galaxy-Trace") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusNoContent, recorder.Code) + assert.Equal(t, "https://www.galaxy.lan", recorder.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, recorder.Header().Get("Access-Control-Allow-Methods"), "POST") + assert.Equal(t, "Content-Type, X-Galaxy-Trace", recorder.Header().Get("Access-Control-Allow-Headers")) + assert.Equal(t, "3600", recorder.Header().Get("Access-Control-Max-Age")) + assert.Empty(t, recorder.Body.String(), "preflight must not return a body") +} + +func TestWithCORSPreflightFallbackHeadersWhenRequestHeadersMissing(t *testing.T) { + t.Parallel() + + router := newCORSRouter([]string{"https://www.galaxy.lan"}) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/public/probe", nil) + req.Header.Set("Origin", "https://www.galaxy.lan") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusNoContent, recorder.Code) + assert.Equal(t, "Content-Type, Authorization", recorder.Header().Get("Access-Control-Allow-Headers")) +} + +func TestWithCORSRejectsUnknownOrigin(t *testing.T) { + t.Parallel() + + router := newCORSRouter([]string{"https://www.galaxy.lan"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil) + req.Header.Set("Origin", "https://evil.example.com") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code, "real call must still succeed; the browser is the one that blocks the response") + assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"), "no allow-origin header for rejected origin") +} + +func TestWithCORSPassThroughWithoutOriginHeader(t *testing.T) { + t.Parallel() + + router := newCORSRouter([]string{"https://www.galaxy.lan"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin")) +} + +func TestWithCORSDisabledByEmptyConfig(t *testing.T) { + t.Parallel() + + router := newCORSRouter(nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/public/probe", nil) + req.Header.Set("Origin", "https://www.galaxy.lan") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin")) +} diff --git a/gateway/internal/restapi/server.go b/gateway/internal/restapi/server.go index 28b0701..6a9c788 100644 --- a/gateway/internal/restapi/server.go +++ b/gateway/internal/restapi/server.go @@ -278,6 +278,10 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen })) router.Use(otelgin.Middleware("galaxy-edge-gateway-public")) router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry)) + // CORS runs before the route classifier and anti-abuse layers so + // preflight OPTIONS calls answer with 204 immediately and never + // count against any rate-limit bucket. + router.Use(withCORS(cfg.CORSAllowedOrigins)) router.Use(withPublicRouteClass(deps.Classifier)) router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer)) diff --git a/tools/dev-deploy/docker-compose.yml b/tools/dev-deploy/docker-compose.yml index 5a449df..7944157 100644 --- a/tools/dev-deploy/docker-compose.yml +++ b/tools/dev-deploy/docker-compose.yml @@ -157,6 +157,10 @@ services: GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH: /run/secrets/gateway-response.pem GATEWAY_REDIS_MASTER_ADDR: "galaxy-redis:6379" GATEWAY_REDIS_PASSWORD: galaxy-dev + # UI lives on https://www.galaxy.lan; the API is on + # 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" # 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. -- 2.52.0