Compare commits
3 Commits
2be7e5c110
...
9d65bf5157
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d65bf5157 | |||
| 1855e43699 | |||
| 7bce67462c |
@@ -40,6 +40,12 @@ const (
|
|||||||
// the keep-alive idle timeout for the public REST listener.
|
// the keep-alive idle timeout for the public REST listener.
|
||||||
publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT"
|
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
|
// publicAuthUpstreamTimeoutEnvVar names the environment variable that
|
||||||
// configures the timeout budget used for public auth upstream calls.
|
// configures the timeout budget used for public auth upstream calls.
|
||||||
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
|
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
|
||||||
@@ -457,6 +463,12 @@ type PublicHTTPConfig struct {
|
|||||||
|
|
||||||
// AntiAbuse configures the public REST anti-abuse middleware.
|
// AntiAbuse configures the public REST anti-abuse middleware.
|
||||||
AntiAbuse PublicHTTPAntiAbuseConfig
|
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
|
// BackendConfig describes the consolidated backend service the gateway
|
||||||
@@ -814,6 +826,16 @@ func LoadFromEnv() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout
|
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 {
|
if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok {
|
||||||
cfg.Backend.HTTPBaseURL = v
|
cfg.Backend.HTTPBaseURL = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout)
|
assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout)
|
||||||
assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout)
|
assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout)
|
||||||
assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout)
|
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, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr)
|
||||||
assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout)
|
assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout)
|
||||||
@@ -165,6 +166,22 @@ func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow)
|
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
|
// resetEnv clears every env var the gateway config might read so that
|
||||||
// individual tests can build the exact environment they need without
|
// individual tests can build the exact environment they need without
|
||||||
// leakage from a previous test.
|
// leakage from a previous test.
|
||||||
@@ -179,6 +196,7 @@ func resetEnv(t *testing.T) {
|
|||||||
publicHTTPReadTimeoutEnvVar,
|
publicHTTPReadTimeoutEnvVar,
|
||||||
publicHTTPIdleTimeoutEnvVar,
|
publicHTTPIdleTimeoutEnvVar,
|
||||||
publicAuthUpstreamTimeoutEnvVar,
|
publicAuthUpstreamTimeoutEnvVar,
|
||||||
|
publicHTTPCORSAllowedOriginsEnvVar,
|
||||||
backendHTTPURLEnvVar,
|
backendHTTPURLEnvVar,
|
||||||
backendGRPCPushURLEnvVar,
|
backendGRPCPushURLEnvVar,
|
||||||
backendGatewayClientIDEnvVar,
|
backendGatewayClientIDEnvVar,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -278,6 +278,10 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen
|
|||||||
}))
|
}))
|
||||||
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
|
router.Use(otelgin.Middleware("galaxy-edge-gateway-public"))
|
||||||
router.Use(withPublicObservability(deps.Logger.Named("public_http"), deps.Telemetry))
|
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(withPublicRouteClass(deps.Classifier))
|
||||||
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
|
router.Use(withPublicAntiAbuse(cfg.AntiAbuse, deps.Limiter, deps.Observer))
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -282,12 +282,24 @@ func TestAppendRandomSuffixGenerator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRandomSuffixGenerator(t *testing.T) {
|
func TestRandomSuffixGenerator(t *testing.T) {
|
||||||
var last string
|
// The generator draws from a ~10 000 element space (Intn(9999)
|
||||||
for range 100 {
|
// 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()
|
s := util.RandomSuffixGenerator()
|
||||||
assert.Len(t, s, 4)
|
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' }))
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ services:
|
|||||||
GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH: /run/secrets/gateway-response.pem
|
GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH: /run/secrets/gateway-response.pem
|
||||||
GATEWAY_REDIS_MASTER_ADDR: "galaxy-redis:6379"
|
GATEWAY_REDIS_MASTER_ADDR: "galaxy-redis:6379"
|
||||||
GATEWAY_REDIS_PASSWORD: galaxy-dev
|
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
|
# Anti-abuse defaults are looser than production: the dev
|
||||||
# environment is shared by a handful of trusted testers who
|
# environment is shared by a handful of trusted testers who
|
||||||
# frequently hammer the same identity to reproduce flows.
|
# frequently hammer the same identity to reproduce flows.
|
||||||
|
|||||||
Reference in New Issue
Block a user