gateway: add CORS allow-list for the public REST surface
Tests · Go / test (push) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 1m45s
Tests · Integration / integration (pull_request) Successful in 1m36s

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:
Ilia Denisov
2026-05-15 07:58:14 +02:00
parent 7bce67462c
commit 1855e43699
6 changed files with 220 additions and 0 deletions
+120
View File
@@ -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"))
}