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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user