Files
gateway/cors_recovery_test.go
zeekayandHanzo Dev 8592e5db04 gateway: every gate is a value, so the default build carries no gin
The last three first-party gin files were transports wrapped around decisions,
and the decisions could not be reached without the framework. That braiding had
already cost this repo a silent hole once (the fiber adaptor restoring headers
the strip had deleted); it was still costing two more, quietly.

widget_security.go and the CORS half of routes.go had NO zip transport at all.
Not a weaker one — none. So on the HIP-0106 mount, the boundary the unified
cloud binary runs, an hz_ widget key — a public credential anyone can read out
of a page's source — arrived with no origin allowlist and no rate limit against
the model budget, and a browser preflight got whatever the router does with an
unrouted OPTIONS. Both gates existed, both were tested, and neither ran, because
each was written inside a gin closure the zip edge could not call. That is the
same defect class as the deleted gin bridge, one level up: the decision was in a
place instead of being a value.

Now each gate is a value and each edge is a few lines over it — widgetGate.admit
in widget_security.go and corsPolicy.admit in cors.go, alongside the
authGate.admit that already was one. Mount installs all three natively, in the
order legacy_engine.go runs them. Every gin transport moved to
legacy_transports.go behind the tag: one file, one reason to exist, deleted whole
with legacy_engine.go at Phase C.

What that buys, measured on the binary rather than on a test:

  go list -deps ./cmd/gateway | grep gin-gonic   6 packages -> 0
  package count                                  2161 -> 2112

And what it does NOT buy, stated because the opposite is the easy thing to
claim: gin CANNOT leave go.mod, and gin is still in the SHIPPED binary. The
Makefile sets BUILD_TAGS ?= legacy, the Dockerfile runs `make build`, and all
five live workloads run `run -c gateway.json` — so ghcr.io/hanzoai/gateway is
the legacy build, and its gin comes from the vendored KrakenD/Lura tree
(internal/lura, internal/plugin), which is not ours to rewrite.
`go list -deps -tags legacy` still reports gin, unchanged at 2680 packages.
First-party gin is gone; the vendored legacy tree keeps it until Phase C.

The shipped edge is proven unmoved, not asserted to be: both binaries booted
against configs/hanzo/gateway.json and probed on fourteen requests covering
every gate. Route table 19 endpoints before and 19 after, and every response —
status line, CORS headers, security posture, BODY — diffs clean against
origin/main.

Tests: transport_parity_test.go drives one request through BOTH edges over 25
cases (auth classes, widget origin+rate limit, CORS preflight) and fails on
disagreement rather than on either edge alone; it was mutation-checked, and a
broken zip transport reports TRANSPORTS DISAGREE on 6 of them.
TestMount_GatesAreOnTheChainMountInstalls drives the app gateway.Mount BUILT,
because a gate that passes its own test while the mount never installs it is
exactly what was wrong here. The gin-driven suites are `legacy`-tagged — that
build is the only one where a gin transport exists, and it is the one that
ships, so nothing stopped running: 215 tests pass in the legacy pass and the
untagged pass is green too, both with -race. Shared JWKS fixtures moved to an
untagged fixtures_test.go so the two halves cannot grow two signing identities.

The routing table is the one gate with no zip twin, and routes.go says why
rather than deferring: its compiled proxies are net/http, so reaching them from
fasthttp would drop WebSocket upgrade and streaming, and the api-host
passthrough rests on cloud being another process — false inside the unified
binary, where the same rule would proxy it to itself.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:35:08 -07:00

125 lines
4.5 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
//go:build legacy
// +build legacy
// gin-driven: this suite drives the LEGACY Lura edge's transports
// (legacy_transports.go), which exist only under this tag. The policies it
// exercises are framework-free values shared with the zip edge, and
// transport_parity_test.go asserts the two edges answer alike.
package gateway
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// TestCORSRecoveryHandler_F3 is the Great-Audit F3 regression. The gateway's
// panic-recovery handler must never turn a 500 into a
// wildcard-reflect-with-credentials primitive: an allowlisted Origin is
// reflected with Access-Control-Allow-Credentials so the browser sees the real
// error (not a CORS failure), but ANY other Origin — including an attacker's —
// gets NO Access-Control-Allow-Origin / -Credentials on the error response, so
// no site can make a credentialed cross-origin read against a session when the
// app 5xxs.
//
// gin.CustomRecovery(corsRecoveryHandler(newCORSOriginAllower())) is the exact
// wiring the legacy engine installs in NewEngine (legacy_engine.go), so this
// exercises the real handler.
func TestCORSRecoveryHandler_F3(t *testing.T) {
gin.SetMode(gin.TestMode)
newRouter := func() *gin.Engine {
r := gin.New()
r.Use(gin.CustomRecovery(corsRecoveryHandler(newCORSOriginAllower())))
r.GET("/boom", func(c *gin.Context) { panic("kaboom") })
return r
}
cases := []struct {
name string
origin string
reflect bool // expect Origin reflected + credentials on the 500
}{
{"evil origin not reflected", "https://evil.com", false},
{"look-alike suffix not reflected", "https://nothanzo.ai", false},
{"brand apex reflected", "https://hanzo.ai", true},
{"brand subdomain reflected", "https://cowork.hanzo.ai", true},
{"lux subdomain reflected", "https://app.lux.network", true},
{"zoo apex reflected", "https://zoo.ngo", true},
{"localhost dev reflected", "http://localhost:3000", true},
{"no origin no cors", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
newRouter().ServeHTTP(w, req)
// Recovery always returns 500 regardless of origin.
if w.Code != http.StatusInternalServerError {
t.Fatalf("origin %q: expected 500 from recovery, got %d", tc.origin, w.Code)
}
acao := w.Header().Get("Access-Control-Allow-Origin")
acac := w.Header().Get("Access-Control-Allow-Credentials")
if tc.reflect {
if acao != tc.origin {
t.Fatalf("allowlisted origin %q: expected ACAO=%q, got %q", tc.origin, tc.origin, acao)
}
if acac != "true" {
t.Fatalf("allowlisted origin %q: expected ACA-Credentials=true, got %q", tc.origin, acac)
}
if v := w.Header().Get("Vary"); v != "Origin" {
t.Fatalf("allowlisted origin %q: expected Vary=Origin, got %q", tc.origin, v)
}
} else {
// The F3 assertion: a disallowed / absent origin must NEVER get
// a credentialed CORS reflect on the error path.
if acao != "" {
t.Fatalf("disallowed origin %q: expected NO ACAO on 500, got %q", tc.origin, acao)
}
if acac != "" {
t.Fatalf("disallowed origin %q: expected NO ACA-Credentials on 500, got %q", tc.origin, acac)
}
}
})
}
}
// TestCORSRecoveryHandler_SharesHappyPathAllowlist proves the error path uses
// the SAME allowlist as corsPreflightMiddleware (no drift between the two
// callers of newCORSOriginAllower): an origin added via GATEWAY_CORS_ORIGINS is
// reflected with credentials on a 500 exactly as it is on a preflight.
func TestCORSRecoveryHandler_SharesHappyPathAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("GATEWAY_CORS_ORIGINS", "https://partner.example.com")
r := gin.New()
r.Use(gin.CustomRecovery(corsRecoveryHandler(newCORSOriginAllower())))
r.GET("/boom", func(c *gin.Context) { panic("kaboom") })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
req.Header.Set("Origin", "https://partner.example.com")
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, 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 on 500, got %q", acao)
}
if acac := w.Header().Get("Access-Control-Allow-Credentials"); acac != "true" {
t.Fatalf("env origin: expected ACA-Credentials=true on 500, got %q", acac)
}
}