mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
corsPreflightMiddleware only answered OPTIONS preflight for a localhost default set + the GATEWAY_CORS_ORIGINS env var (unset in prod). Browser SPAs on *.hanzo.ai (e.g. https://cowork.hanzo.ai) got no Access-Control-Allow-Origin on their /v1/chat/completions preflight, so login worked but AI calls failed "Failed to fetch". Reuse the SAME baked-in allowlist the widget security middleware already enforces (defaultAllowedOrigins) via the existing widget helpers (widgetAllowedOrigins/extractOriginHost/isAllowedOrigin). The brand set is built once in the closure; any Hanzo/Lux/Zoo/Pars property and its subdomains are allowed by hostname suffix-match. Localhost defaults and GATEWAY_CORS_ORIGINS remain as additive exact-match allowances. One allowlist source, no per-origin env config. Adds cors_preflight_test.go: cowork.hanzo.ai + hanzo.app allowed (204 + ACAO echo + credentials), evil.com and look-alike nothanzo.ai denied (fall-through, no ACAO), env-supplied origin still honored. Co-authored-by: Antje Worring <[email protected]>
115 lines
4.0 KiB
Go
115 lines
4.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// TestCORSPreflight_BrandAllowlist verifies that corsPreflightMiddleware
|
|
// answers OPTIONS preflight for any Hanzo/Lux/Zoo/Pars property (and its
|
|
// subdomains) using the SAME baked-in allowlist as the widget security
|
|
// middleware (defaultAllowedOrigins), without any per-origin env config.
|
|
//
|
|
// This is the fix for browser SPAs (e.g. https://cowork.hanzo.ai) whose
|
|
// AI calls to /v1/chat/completions previously failed "Failed to fetch"
|
|
// because the preflight returned no Access-Control-Allow-Origin.
|
|
func TestCORSPreflight_BrandAllowlist(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cases := []struct {
|
|
name string
|
|
origin string
|
|
allowed bool
|
|
}{
|
|
// Subdomain of a baked-in brand domain — the cowork case.
|
|
{"cowork.hanzo.ai subdomain", "https://cowork.hanzo.ai", true},
|
|
// Apex brand domain.
|
|
{"hanzo.app apex", "https://hanzo.app", true},
|
|
// Other brand properties via suffix match.
|
|
{"hanzo.chat", "https://hanzo.chat", true},
|
|
{"lux.network subdomain", "https://app.lux.network", true},
|
|
{"zoo.ngo apex", "https://zoo.ngo", true},
|
|
// Localhost default (dev).
|
|
{"localhost:3000 default", "http://localhost:3000", true},
|
|
// Not on any allowlist.
|
|
{"evil.com denied", "https://evil.com", false},
|
|
{"look-alike suffix denied", "https://nothanzo.ai", false},
|
|
{"empty origin ignored", "", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
r.Use(corsPreflightMiddleware())
|
|
// Downstream handler returns 200 if the preflight was NOT
|
|
// short-circuited by the middleware (i.e. origin not allowed).
|
|
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
if tc.origin != "" {
|
|
req.Header.Set("Origin", tc.origin)
|
|
}
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
acao := w.Header().Get("Access-Control-Allow-Origin")
|
|
if tc.allowed {
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("allowed origin %q: expected 204, got %d", tc.origin, w.Code)
|
|
}
|
|
if acao != tc.origin {
|
|
t.Fatalf("allowed origin %q: expected ACAO=%q, got %q", tc.origin, tc.origin, acao)
|
|
}
|
|
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Fatalf("allowed origin %q: expected ACA-Credentials=true, got %q", tc.origin, got)
|
|
}
|
|
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
t.Fatalf("allowed origin %q: expected ACA-Methods set", tc.origin)
|
|
}
|
|
} else {
|
|
// Denied: preflight falls through to the handler (200) and
|
|
// MUST NOT carry an Access-Control-Allow-Origin header.
|
|
if acao != "" {
|
|
t.Fatalf("denied origin %q: expected no ACAO, got %q", tc.origin, acao)
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("denied origin %q: expected fall-through 200, got %d", tc.origin, w.Code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCORSPreflight_EnvOriginStillHonored verifies that an explicit
|
|
// fully-qualified origin supplied via GATEWAY_CORS_ORIGINS is still allowed
|
|
// alongside the brand allowlist (additive, no regression).
|
|
func TestCORSPreflight_EnvOriginStillHonored(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("GATEWAY_CORS_ORIGINS", "https://partner.example.com")
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
r.Use(corsPreflightMiddleware())
|
|
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
|
|
req.Header.Set("Origin", "https://partner.example.com")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("env origin: expected 204, got %d", w.Code)
|
|
}
|
|
if acao := w.Header().Get("Access-Control-Allow-Origin"); acao != "https://partner.example.com" {
|
|
t.Fatalf("env origin: expected ACAO echo, got %q", acao)
|
|
}
|
|
}
|