Merge pull request #386 from hanzo-inc/cors-verified-site-hosts

CORS: allowed origins derive from verified site hosts, not a static list
This commit is contained in:
z
2026-08-06 01:23:50 -07:00
committed by GitHub
7 changed files with 883 additions and 90 deletions
+9 -1
View File
@@ -135,8 +135,16 @@ func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
// public client IP; in-cluster direct callers (no X-Forwarded-For) are
// exempt, matching the standalone gateway's public-only scope. See
// middleware_edge.go.
app.Use(EdgeCORS(deps.GatewayPolicy))
//
// RATE LIMIT FIRST. EdgeCORS now resolves an unknown origin against the site-host
// store, which in production is a plane hop, and the Origin header is chosen by
// the caller — so an attacker rotating a fresh hostname per request would defeat
// the answer cache and turn each inbound request into an internal one. The
// per-IP counter is a map increment and bounds that structurally, with no second
// mechanism to tune. The cost is that a flood of PREFLIGHTS is capped too, which
// is the correct answer to a flood of preflights.
app.Use(EdgeRateLimit(deps.GatewayPolicy))
app.Use(EdgeCORS(deps.GatewayPolicy))
Identify(app, cfg)
return app
+47
View File
@@ -24,6 +24,7 @@ import (
aictl "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
airouters "github.com/hanzoai/ai/routers"
aiweb "github.com/hanzoai/ai/web"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/manifest"
"github.com/hanzoai/cloud/openapi"
@@ -257,6 +258,52 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
return f(ctx, subject, namespace)
})
// ONE CORS AUTHORITY. cloud.EdgeCORS decides which browser origins may read
// this edge; this takes ai's own answer out of the request.
//
// hanzoai/ai carries routers.CorsFilter, a filter it inserts ahead of every
// route, which REFUSES with 403 any origin outside `allowedOriginSuffixes` — 21
// apex domains compiled into the module. That list cannot name a customer's
// domain, and a deployment cannot change it, so the shipped feature (fork
// hanzoai/console, deploy it on your own domain, call this API) was structurally
// impossible: cloud would admit the origin, ai would refuse the call. The
// preflight short-circuits in EdgeCORS and never reaches ai, so the browser saw
// a clean 204 followed by a 403 — allowed preflight, denied request, the classic
// asymmetry.
//
// The filter's POSITIVE half is already dead in production: setCorsHeaders
// returns early whenever X-Forwarded-Host is set, which the ingress always sets,
// so ai has not added a CORS header at the edge in a long time. Only its refusal
// is live. Clearing Origin takes its own `origin == ""` early return, which adds
// nothing and refuses nothing — so what is removed is exactly the second verdict,
// and nothing else.
//
// SCOPED AND CONDITIONAL, both deliberately:
//
// - only for origins cloud ALREADY ADMITTED (cloud.CORSAllows — the same
// predicate EdgeCORS used, same instance, same cache, so the two cannot
// disagree). A denied origin keeps its header and ai still answers 403
// exactly as it does today: this changes the allow path only.
// - only for non-upgrade requests. controllers/dev_bridge.go guards cross-site
// WebSocket hijacking with CheckOrigin, which returns TRUE on an empty Origin
// to admit CLI clients — clearing it there would fail OPEN. A socket has no
// preflight and no ACAO; its Origin check is a different mechanism and stays
// with the handler that owns the socket.
//
// BeforeStatic, so it runs ahead of every BeforeRouter filter including
// CorsFilter regardless of the order InstallFilters ran in.
airouters.App.InsertFilter("*", aiweb.BeforeStatic, func(ctx *aiweb.Context) {
r := ctx.Request
if r == nil || r.Header.Get("Origin") == "" {
return
}
if r.Header.Get("Upgrade") != "" {
return
}
if cloud.CORSAllows(r.Context(), r.Header.Get("Origin")) {
r.Header.Del("Origin")
}
})
// The MCP door's inventory, registered BEFORE the wildcard below so the
// reading order is the routing order (see mcp.go — the router would pick the
// static path over All("/v1/*") either way).
+30
View File
@@ -193,6 +193,36 @@ func currentResolver() Resolver {
// was added to close, one layer down.
func CurrentResolver() Resolver { return currentResolver() }
// VerifiedHost reports the org that owns host as a VERIFIED public site host.
//
// It is the SAME read the site edge serves from — Resolver.Resolve, which is
// Store.ResolveHost, which filters `status='verified'` — asked for the one fact a
// caller outside this package can need about a hostname: whose is it, and has the
// owner PROVED it. A host with only a pending claim resolves to nothing here,
// because a pending row holds its name against the PK but never routes; that
// filter is the hostname-hijack boundary, and asking through this function is what
// keeps every caller on the right side of it instead of growing a second lookup
// that could forget the status.
//
// found=false on a miss AND on a resolver error, which is deliberate and is the
// difference between this and Resolve: the serve path must tell "no such site"
// (404) from "could not ask" (503), because serving a 404 for a live customer site
// during a transient failure looks exactly like deletion. A caller asking "is this
// host proven" has no such distinction to make — an answer we could not obtain is
// not a proof — so the error collapses into "no", and a caller cannot forget to
// check a second return.
func VerifiedHost(ctx context.Context, host string) (string, bool) {
r := currentResolver()
if r == nil {
return "", false
}
site, ok, err := r.Resolve(ctx, host)
if err != nil || !ok || site.Org == "" {
return "", false
}
return site.Org, true
}
// Config configures the site host-router. Apex is the zone whose subdomains are
// site hosts (hanzo.app). Reserved is the set of subdomain labels that are NOT
// sites (they belong to real app hosts) and must fall through to the normal
+268
View File
@@ -0,0 +1,268 @@
package cloud
// The CORS origin predicate: may this browser origin read an answer from this
// edge, with the caller's credentials attached?
//
// It is ONE function (corsOrigins.allowed) over TWO sources, and the split is the
// whole design:
//
// DECLARED — the platform allowlist (edge.Policy.CORSOrigins). The brand hosts an
// operator names. SuperAdmin-writable, evaluated before identity.
// PROVEN — a host whose site_hosts row is VERIFIED and bound to an org. The
// customer proved control of the name over DNS; nobody had to type it
// into a config.
//
// PROVEN is what makes the shipped product feature possible: a customer forks
// hanzoai/console, deploys it on their own domain, and it can call this API. Under
// a static list alone it could not, because the list can only ever name domains we
// own. It is not a new trust mechanism — it is the EXISTING one, asked a question:
//
// site_hosts.status separates HOLDING a name from SERVING it. `pending` takes the
// name against the primary key and carries a 128-bit DNS-01 challenge token;
// `verified` is what a caller gets after fqdn.Verify finds that token published as
// TXT at _hanzo-challenge.<host>. Store.ResolveHost filters status='verified' and
// is the sole routing read — the hostname-hijack boundary. sites.VerifiedHost asks
// exactly that, so a claim on a name the claimant does not own grants nothing here
// either. One proof, one boundary, two readers.
//
// Everything below the two sources is guard rails on an ATTACKER-CONTROLLED string.
// The Origin header is chosen by whoever is calling, and the PROVEN lookup is a
// plane hop on the pre-auth path, so the cheap total rules run first and the
// expensive one runs last and is cached in both directions.
import (
"context"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hanzoai/cloud/apps/sites"
"github.com/hanzoai/cloud/internal/fqdn"
)
// verifiedHostFn is the PROVEN source. A seam, not a config: the tests drive it
// directly, and production is sites.VerifiedHost, which prefers the in-process
// projects store and falls back to the plane. Production genuinely needs the
// fallback — the pod boots ~25 single-app processes, so the projects store is not
// co-resident with the edge that reads it.
type verifiedHostFn func(ctx context.Context, host string) (string, bool)
// corsOrigins answers the one question, over the two sources, with a bounded cache
// in front of the expensive one.
//
// declared is ATOMIC because this value has two readers on different goroutines —
// the edge middleware and, through CORSAllows, the ai filter — while the middleware
// recompiles it whenever an operator retunes the live allowlist. A plain field
// would be a write racing two reads on every policy change.
type corsOrigins struct {
declared atomic.Pointer[originMatcher]
proven verifiedHostFn
mu sync.Mutex
cache map[string]provenEntry
lastSweep time.Time
}
// setDeclared installs a freshly compiled allowlist.
func (o *corsOrigins) setDeclared(m *originMatcher) { o.declared.Store(m) }
// provenEntry caches a PROVEN answer in BOTH directions. Caching only the hits
// would leave every forged origin paying for a full plane hop, which turns one
// attacker request into one internal request — the pre-auth path is exactly where
// that must not be true.
type provenEntry struct {
org string
ok bool
expiry time.Time
}
const (
// provenTTL bounds how stale a PROVEN answer may be. Short, because it is how
// long a released or newly-unverified domain keeps working; long enough that a
// live console does not re-ask on every request. Mirrors edge.resolveTTL.
provenTTL = 5 * time.Second
// maxProvenCache bounds the cache. The ATTACKER PICKS THE KEY — every distinct
// forged Origin is a distinct entry — so an unbounded map here is a memory
// exhaustion primitive reachable before authentication.
maxProvenCache = 4096
)
// allowed is the predicate. Both the preflight and the actual request are decided
// by this one call, so there is no way for them to disagree.
func (o *corsOrigins) allowed(ctx context.Context, origin string) bool {
// An origin the operator DECLARED is admitted on its own terms, including the
// wildcard and bare-host forms that allowlist has always used. Checked first: it
// is a map lookup, it never touches the store, and it must keep working when the
// projects app is unreachable.
if d := o.declared.Load(); d != nil && d.allowed(origin) {
return true
}
host, ok := provableHost(origin)
if !ok {
return false
}
return o.provenHost(ctx, host)
}
// provableHost reduces an Origin header to the hostname a DNS proof could be about,
// and reports false for every string that is not one. It is a TOTAL rule applied
// before any lookup, and each clause closes something specific:
//
// - exactly `scheme://host`, reconstructed and compared — so a path, a query, a
// fragment, userinfo, a trailing slash, an upper-case scheme, and (via url.Parse,
// which refuses them) any embedded control character are misses rather than
// near-hits. This is what makes echoing the header back safe: the only strings
// that can reach the response already equal their own canonical serialization.
// - https only. Access-Control-Allow-Credentials over cleartext puts the
// credential on the wire, and control of a zone's DNS is not evidence that the
// zone is served over TLS.
// - no port. A proof is about a NAME. `https://proven.example.com:8443` is a
// different origin and admitting it widens the reflected set for no product
// gain — the feature is a console on 443.
// - fqdn.Valid — the SAME syntactic rule the bind path applies, so this surface
// can only ever address names that surface could have created. It is also what
// keeps BARE PROJECT SLUGS out: site_hosts holds each project's bare slug as a
// structural row, always status='verified', and a bare label is not a valid
// FQDN. Without this a tenant holding the slug `intranet` would have made
// `Origin: https://intranet` a credentialed origin. It rejects `null`, an IP
// literal and `localhost` for the same reason.
func provableHost(origin string) (string, bool) {
u, err := url.Parse(origin)
if err != nil || u.Scheme != "https" || u.Host == "" {
return "", false
}
if origin != u.Scheme+"://"+u.Host {
return "", false
}
if u.Port() != "" {
return "", false
}
host := u.Hostname()
// fqdn.Valid expects Clean's output and does not normalize, so compare against
// it rather than calling Clean: a host that is not ALREADY canonical (upper
// case, a trailing root dot) is a different origin string and is refused here
// instead of being silently folded onto a row it does not name.
if host != fqdn.Clean(host) || !fqdn.Valid(host) {
return "", false
}
return host, true
}
// provenHost asks the PROVEN source through the cache.
func (o *corsOrigins) provenHost(ctx context.Context, host string) bool {
if o.proven == nil {
return false
}
now := time.Now()
o.mu.Lock()
if e, hit := o.cache[host]; hit && now.Before(e.expiry) {
o.mu.Unlock()
return e.ok
}
o.mu.Unlock()
// Resolved OUTSIDE the lock: this is a plane hop in production, and holding the
// mutex across it would serialize every concurrent request behind the slowest
// lookup. A duplicate in-flight lookup for the same host is cheaper than that.
//
// sites.VerifiedHost folds a resolver ERROR into found=false, so an unreachable
// projects app narrows CORS to the DECLARED list. It never opens it, and it
// never takes the edge down.
org, ok := o.proven(ctx, host)
o.mu.Lock()
o.sweepLocked(now)
if o.cache == nil {
o.cache = map[string]provenEntry{}
}
o.cache[host] = provenEntry{org: org, ok: ok && org != "", expiry: now.Add(provenTTL)}
o.mu.Unlock()
return ok && org != ""
}
// sweepLocked keeps the cache bounded. Expired entries go first; if the map is
// still at the cap, it is dropped whole rather than grown. Dropping wholesale
// costs a re-resolve for the live consoles in it, which is bounded and self-heals
// within provenTTL — growing without bound does not. Caller holds o.mu.
func (o *corsOrigins) sweepLocked(now time.Time) {
if len(o.cache) < maxProvenCache && now.Sub(o.lastSweep) < provenTTL {
return
}
o.lastSweep = now
for h, e := range o.cache {
if now.After(e.expiry) {
delete(o.cache, h)
}
}
if len(o.cache) >= maxProvenCache {
o.cache = map[string]provenEntry{}
}
}
// originMatcher decides whether a request Origin is on the DECLARED allowlist. Each
// config entry is either an EXACT origin ("https://hanzo.ai") or a host wildcard
// ("*.hanzo.ai", which matches the apex `hanzo.ai` AND any subdomain
// `<sub>.hanzo.ai`) — the two forms the gateway/ingress allowlists use, expressed
// once. Bare-host entries ("hanzo.ai") are treated as a host match on any scheme.
type originMatcher struct {
exact map[string]struct{} // full origin strings, e.g. "https://hanzo.ai"
hosts map[string]struct{} // bare hosts matched regardless of scheme
suffix []string // wildcard hosts: apex value, e.g. "hanzo.ai"
}
// newOriginMatcher compiles the allowlist. Returns nil when empty, so the caller
// can tell "the operator declared nothing" from "the operator declared nothing that
// matched".
func newOriginMatcher(origins []string) *originMatcher {
if len(origins) == 0 {
return nil
}
m := &originMatcher{exact: map[string]struct{}{}, hosts: map[string]struct{}{}}
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
switch {
case strings.HasPrefix(o, "*."):
m.suffix = append(m.suffix, strings.ToLower(o[2:]))
case strings.Contains(o, "://"):
m.exact[o] = struct{}{}
default:
m.hosts[strings.ToLower(o)] = struct{}{}
}
}
if len(m.exact) == 0 && len(m.hosts) == 0 && len(m.suffix) == 0 {
return nil
}
return m
}
func (m *originMatcher) allowed(origin string) bool {
if _, ok := m.exact[origin]; ok {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
if _, ok := m.hosts[host]; ok {
return true
}
for _, sfx := range m.suffix {
if host == sfx || strings.HasSuffix(host, "."+sfx) {
return true
}
}
return false
}
// corsVerifiedHost is the production PROVEN source, named so the middleware reads
// as one word and the tests have something to substitute.
func corsVerifiedHost(ctx context.Context, host string) (string, bool) {
return sites.VerifiedHost(ctx, host)
}
+421
View File
@@ -0,0 +1,421 @@
package cloud
// Tests for the CORS origin predicate (cors_origin.go) — the boundary that decides
// which browser origins may read this edge with credentials attached.
//
// They drive real requests through the zip/fiber stack wherever the answer is
// observable on the wire, because the thing under test is a header contract, and
// they drive the predicate directly where the point is a rule rather than a header.
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/hanzoai/cloud/apps/gateway/edge"
"github.com/zap-proto/zip"
)
// provenSet is a PROVEN source over a fixed set of verified hosts, counting calls
// so a test can prove the guards ran BEFORE the lookup and that answers are cached.
type provenSet struct {
hosts map[string]string // host → org
calls atomic.Int64
}
func (p *provenSet) fn(_ context.Context, host string) (string, bool) {
p.calls.Add(1)
org, ok := p.hosts[host]
return org, ok
}
// newCORSOriginsForTest builds the predicate over an explicit declared list, going
// through setDeclared so a test exercises the same atomic install the middleware
// uses when an operator retunes the live allowlist.
func newCORSOriginsForTest(declared []string, proven verifiedHostFn) *corsOrigins {
o := &corsOrigins{proven: proven}
o.setDeclared(newOriginMatcher(declared))
return o
}
// ── provableHost: the total rules that run before any lookup ─────────────────
func TestProvableHost(t *testing.T) {
cases := []struct {
origin string
want string // "" ⇒ refused
why string
}{
{"https://console.acme.com", "console.acme.com", "the shipped feature: a customer's own console"},
{"https://acme.co.uk", "acme.co.uk", "multi-label TLD"},
{"http://console.acme.com", "", "cleartext: credentialed CORS must not put the token on the wire"},
{"https://console.acme.com:8443", "", "a proof is about a NAME, not a port"},
{"https://localhost", "", "not a public FQDN"},
{"https://127.0.0.1", "", "IP literal is not a name anyone can prove by DNS-01"},
{"https://[::1]", "", "IPv6 literal"},
{"null", "", "the sandboxed-iframe origin"},
{"", "", "absent"},
{"garbage", "", "not a URL"},
{"intranet", "", "a BARE PROJECT SLUG: site_hosts holds these verified by construction"},
{"https://intranet", "", "a bare slug dressed as an origin is still not an FQDN"},
{"https://console.acme.com/", "", "trailing slash is not a serialized origin"},
{"https://console.acme.com/path", "", "a path is not a serialized origin"},
{"https://console.acme.com?q=1", "", "a query is not a serialized origin"},
{"https://console.acme.com#f", "", "a fragment is not a serialized origin"},
{"https://user:pw@console.acme.com", "", "userinfo is not a serialized origin"},
{"HTTPS://console.acme.com", "", "upper-case scheme is not canonical"},
{"https://CONSOLE.acme.com", "", "upper-case host is a different string than the stored row"},
{"https://console.acme.com.", "", "trailing root dot: same resolution, different origin"},
{" https://console.acme.com", "", "padding"},
{"https://console.acme.com\r\nX: y", "", "header smuggling"},
}
for _, tc := range cases {
got, ok := provableHost(tc.origin)
if tc.want == "" {
if ok {
t.Errorf("provableHost(%q) = %q, want refused — %s", tc.origin, got, tc.why)
}
continue
}
if !ok || got != tc.want {
t.Errorf("provableHost(%q) = %q,%v want %q — %s", tc.origin, got, ok, tc.want, tc.why)
}
}
}
// TestProvableHostRunsBeforeLookup is the point of the guards: a string that could
// never be a proven name must not cost a store read. The bare-slug case is the one
// that matters — site_hosts holds every project's bare slug as a row that is
// ALWAYS status='verified', so a resolver that saw it would say yes.
func TestProvableHostRunsBeforeLookup(t *testing.T) {
p := &provenSet{hosts: map[string]string{"intranet": "attacker-org", "console.acme.com": "acme"}}
o := &corsOrigins{proven: p.fn}
for _, origin := range []string{
"https://intranet", "intranet", "http://console.acme.com",
"https://console.acme.com:8443", "null", "https://127.0.0.1",
} {
if o.allowed(context.Background(), origin) {
t.Fatalf("%q must not be allowed", origin)
}
}
if n := p.calls.Load(); n != 0 {
t.Fatalf("guards must reject before any lookup; the store was asked %d times", n)
}
}
// ── the predicate over its two sources ──────────────────────────────────────
func TestAllowedProvenAndDeclared(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
o := newCORSOriginsForTest([]string{"*.hanzo.ai"}, p.fn)
ctx := context.Background()
if !o.allowed(ctx, "https://console.acme.com") {
t.Fatal("a VERIFIED site host must be allowed — this is the shipped feature")
}
if !o.allowed(ctx, "https://console.hanzo.ai") {
t.Fatal("a DECLARED origin must still be allowed")
}
if o.allowed(ctx, "https://unverified.acme.com") {
t.Fatal("a host with no verified row must be refused")
}
if o.allowed(ctx, "https://evil.example") {
t.Fatal("an arbitrary domain must be refused")
}
}
// TestDeclaredNeverAsksTheStore: the declared allowlist must keep working when the
// projects app is unreachable, so it is answered without a lookup.
func TestDeclaredNeverAsksTheStore(t *testing.T) {
p := &provenSet{}
o := newCORSOriginsForTest([]string{"*.hanzo.ai"}, p.fn)
if !o.allowed(context.Background(), "https://console.hanzo.ai") {
t.Fatal("declared origin must be allowed")
}
if n := p.calls.Load(); n != 0 {
t.Fatalf("a declared origin must not cost a store read; asked %d times", n)
}
}
// TestUnresolvableSourceDenies: sites.VerifiedHost folds a resolver ERROR into
// found=false, so an unreachable projects app narrows CORS to the declared list. It
// must never open it, and a nil source must not panic.
func TestUnresolvableSourceDenies(t *testing.T) {
o := newCORSOriginsForTest([]string{"*.hanzo.ai"}, nil)
if o.allowed(context.Background(), "https://console.acme.com") {
t.Fatal("no PROVEN source installed must deny, not open")
}
if !o.allowed(context.Background(), "https://console.hanzo.ai") {
t.Fatal("the declared list must survive an unreachable store")
}
errSrc := &corsOrigins{proven: func(context.Context, string) (string, bool) { return "", false }}
if errSrc.allowed(context.Background(), "https://console.acme.com") {
t.Fatal("a source that cannot answer must deny")
}
}
// TestVerifiedHostMustNameAnOrg: "verified" is only half the requirement — the row
// has to be bound to a real org, or there is no tenant the grant belongs to.
func TestVerifiedHostMustNameAnOrg(t *testing.T) {
o := &corsOrigins{proven: func(context.Context, string) (string, bool) { return "", true }}
if o.allowed(context.Background(), "https://console.acme.com") {
t.Fatal("a verified row with no org must not be a CORS grant")
}
}
// ── caching: bounded, and negative as well as positive ──────────────────────
func TestProvenAnswersAreCachedBothWays(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
o := &corsOrigins{proven: p.fn}
ctx := context.Background()
for i := 0; i < 5; i++ {
if !o.allowed(ctx, "https://console.acme.com") {
t.Fatal("verified host must stay allowed")
}
if o.allowed(ctx, "https://nope.acme.com") {
t.Fatal("unverified host must stay refused")
}
}
// One per distinct host. Caching the MISSES is what stops a forged Origin from
// turning each inbound request into an internal one on the pre-auth path.
if n := p.calls.Load(); n != 2 {
t.Fatalf("lookups = %d, want 2 (one per host, both directions cached)", n)
}
}
// TestProvenCacheIsBounded: the ATTACKER PICKS THE KEY, so the cache must not grow
// with the number of distinct forged origins.
func TestProvenCacheIsBounded(t *testing.T) {
p := &provenSet{}
o := &corsOrigins{proven: p.fn}
ctx := context.Background()
for i := 0; i < maxProvenCache*3; i++ {
o.allowed(ctx, fmt.Sprintf("https://h%d.attacker.example", i))
}
o.mu.Lock()
n := len(o.cache)
o.mu.Unlock()
if n > maxProvenCache {
t.Fatalf("cache holds %d entries, cap is %d — unbounded growth is reachable pre-auth", n, maxProvenCache)
}
}
// TestPredicateIsRaceFreeAcrossItsTwoReaders: the predicate has two readers on
// different goroutines — the edge middleware and, through CORSAllows, the ai filter
// — while an operator retuning the live allowlist recompiles it underneath them.
// Run with -race, this is what proves the shared instance is safe to share.
func TestPredicateIsRaceFreeAcrossItsTwoReaders(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
o := newCORSOriginsForTest([]string{"*.hanzo.ai"}, p.fn)
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for n := 0; n < 200; n++ {
switch i % 3 {
case 0: // the edge middleware recompiling a retuned allowlist
o.setDeclared(newOriginMatcher([]string{fmt.Sprintf("*.h%d.example", n%4)}))
case 1: // a declared/proven read
o.allowed(ctx, "https://console.acme.com")
default: // a miss, which writes the cache
o.allowed(ctx, fmt.Sprintf("https://m%d.example", n))
}
}
}(i)
}
wg.Wait()
}
// ── the wire contract ───────────────────────────────────────────────────────
// provenApp mounts EdgeCORS over an explicit declared list and PROVEN source.
func provenApp(t *testing.T, declared []string, proven verifiedHostFn) *zip.App {
t.Helper()
app := zip.New(zip.Config{})
app.Use(edgeCORS(staticPol(t, edge.Policy{CORSOrigins: declared}), proven))
app.Get("/probe", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
app.Post("/probe", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
return app
}
func do(t *testing.T, app *zip.App, method, origin string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, "/probe", nil)
if origin != "" {
req.Header.Set("Origin", origin)
}
res, err := app.Test(req)
if err != nil {
t.Fatalf("test: %v", err)
}
return res
}
// TestVerifiedHostGetsCredentialedCORS is the feature, end to end on the wire: a
// customer's forked console on their own proven domain can call this API.
func TestVerifiedHostGetsCredentialedCORS(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
app := provenApp(t, nil, p.fn) // NOTHING declared: the proof alone carries it.
res := do(t, app, http.MethodGet, "https://console.acme.com")
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "https://console.acme.com" {
t.Fatalf("ACAO = %q, want the reflected verified origin", got)
}
if got := res.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("ACAC = %q, want true", got)
}
if got := res.Header.Get("Access-Control-Allow-Origin"); got == "*" {
t.Fatal("wildcard with credentials is invalid per the Fetch standard")
}
}
// TestPreflightAndActualAgree is the defect this whole change exists to prevent:
// a browser told YES at the preflight and NO at the request, or the reverse. Both
// hang off one predicate, so they are asserted together for both verdicts.
func TestPreflightAndActualAgree(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
app := provenApp(t, []string{"*.hanzo.ai"}, p.fn)
for _, tc := range []struct {
origin string
allow bool
}{
{"https://console.acme.com", true}, // proven
{"https://console.hanzo.ai", true}, // declared
{"https://unverified.acme.com", false},
{"https://evil.example", false},
{"https://console.acme.com:8443", false},
{"http://console.acme.com", false},
} {
pre := do(t, app, http.MethodOptions, tc.origin)
act := do(t, app, http.MethodPost, tc.origin)
preACAO := pre.Header.Get("Access-Control-Allow-Origin")
actACAO := act.Header.Get("Access-Control-Allow-Origin")
if (preACAO != "") != tc.allow || (actACAO != "") != tc.allow {
t.Errorf("%s: preflight ACAO=%q actual ACAO=%q, want allow=%v",
tc.origin, preACAO, actACAO, tc.allow)
}
if preACAO != actACAO {
t.Errorf("%s: preflight and actual DISAGREE (%q vs %q)", tc.origin, preACAO, actACAO)
}
if tc.allow && pre.StatusCode != 204 {
t.Errorf("%s: preflight status = %d, want 204", tc.origin, pre.StatusCode)
}
}
}
// TestVaryOriginOnEveryOriginDependentAnswer — including the DENIAL. A shared cache
// that keys on the URL alone would otherwise hand one tenant the answer computed
// for another, and the answer that carries no ACAO depends on Origin just as much
// as the one that does.
func TestVaryOriginOnEveryOriginDependentAnswer(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
app := provenApp(t, nil, p.fn)
for _, tc := range []struct{ origin, what string }{
{"https://console.acme.com", "allowed"},
{"https://evil.example", "DENIED"},
} {
for _, m := range []string{http.MethodGet, http.MethodOptions} {
res := do(t, app, m, tc.origin)
if !strings.Contains(res.Header.Get("Vary"), "Origin") {
t.Errorf("%s %s (%s): Vary = %q, must contain Origin",
m, tc.origin, tc.what, res.Header.Get("Vary"))
}
}
}
// No Origin at all ⇒ the answer does not depend on one, so nothing is added.
if v := do(t, app, http.MethodGet, "").Header.Get("Vary"); strings.Contains(v, "Origin") {
t.Errorf("a request with no Origin must not be marked origin-dependent; Vary = %q", v)
}
}
// TestVaryIsAppendedNotAssigned: the previous code used SetHeader, which OVERWRITES,
// so it fought middleware_markdown's `Vary: Accept` and whichever ran last silently
// erased the other's protection.
func TestVaryIsAppendedNotAssigned(t *testing.T) {
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
app := zip.New(zip.Config{})
app.Use(zip.H(func(c *zip.Ctx) error { c.SetHeader("Vary", "Accept"); return c.Continue() }))
app.Use(edgeCORS(staticPol(t, edge.Policy{}), p.fn))
app.Get("/probe", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
v := do(t, app, http.MethodGet, "https://console.acme.com").Header.Get("Vary")
if !strings.Contains(v, "Origin") || !strings.Contains(v, "Accept") {
t.Fatalf("Vary = %q, want BOTH Accept and Origin — appending must not clobber", v)
}
}
// TestUnprovenOriginIsNotReflected: reflecting the request Origin before it resolved
// to a verified record is the classic hole this design exists to close.
func TestUnprovenOriginIsNotReflected(t *testing.T) {
app := provenApp(t, nil, (&provenSet{}).fn)
for _, origin := range []string{
"https://evil.example", "https://console.acme.com", "null",
"https://hanzo.ai.evil.example",
} {
res := do(t, app, http.MethodGet, origin)
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("origin %q was reflected as %q with nothing proving it", origin, got)
}
if got := res.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("origin %q got credentials with nothing proving it", origin)
}
}
}
// TestCORSAllowsIsFailSecureBeforeWiring — apps/ai asks this to decide whether the
// one authority already answered. Before an edge exists there is no authority, and
// the answer has to be no.
func TestCORSAllowsIsFailSecureBeforeWiring(t *testing.T) {
saved := corsPredicate.Load()
t.Cleanup(func() { corsPredicate.Store(saved) })
corsPredicate.Store(nil)
if CORSAllows(context.Background(), "https://console.hanzo.ai") {
t.Fatal("with no predicate built, nothing is allowed")
}
}
// TestCORSAllowsSharesTheEdgeVerdict: the ai filter and the edge middleware must be
// the SAME predicate, or they can drift into disagreeing about one origin — which is
// exactly the two-authority defect being removed.
func TestCORSAllowsSharesTheEdgeVerdict(t *testing.T) {
saved := corsPredicate.Load()
t.Cleanup(func() { corsPredicate.Store(saved) })
p := &provenSet{hosts: map[string]string{"console.acme.com": "acme"}}
app := provenApp(t, []string{"*.hanzo.ai"}, p.fn)
// Drive one request so the live allowlist is compiled onto the published instance.
do(t, app, http.MethodGet, "https://console.hanzo.ai")
ctx := context.Background()
for _, tc := range []struct {
origin string
want bool
}{
{"https://console.acme.com", true},
{"https://console.hanzo.ai", true},
{"https://evil.example", false},
{"https://unverified.acme.com", false},
} {
if got := CORSAllows(ctx, tc.origin); got != tc.want {
t.Errorf("CORSAllows(%q) = %v, want %v — the two layers disagree", tc.origin, got, tc.want)
}
}
}
+97 -87
View File
@@ -34,9 +34,10 @@ package cloud
// router rate limit did.
import (
"net/url"
"context"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hanzoai/cloud/apps/gateway/edge"
@@ -61,58 +62,94 @@ const (
corsMaxAge = "86400"
)
// EdgeCORS returns the browser-CORS middleware for the public /v1 edge. The origin
// allowlist is the PLATFORM policy's CORSOrigins, read live (recompiled only when
// it changes) so a SuperAdmin can add/remove origins via PUT /v1/gateway/config.
// EdgeCORS returns the browser-CORS middleware for the public /v1 edge, and it is
// THE CORS authority for this binary: one predicate decides the preflight and the
// actual request, and nothing downstream re-decides.
//
// DEFAULT OFF (empty allowlist ⇒ no-op passthrough). On the RECOMMENDED rollout —
// the shared Traefik ingress keeps fronting api.hanzo.ai and its `cors-allow-all`
// middleware already answers CORS there — enabling cloud CORS too would emit a
// SECOND Access-Control-Allow-Origin header and break every browser preflight. So
// CORS stays owned by exactly ONE layer: the ingress until/unless the edge moves to
// a direct DO-LB→cloud path (cloud terminates TLS for api.hanzo.ai), at which point
// the operator sets CLOUD_CORS_ORIGINS (or PUTs it) and cloud becomes the sole CORS
// authority. One policy, one place — never both.
// An origin is admitted from either of two sources (cors_origin.go):
//
// When enabled it handles the OPTIONS preflight itself (204, short-circuit) and
// reflects the allowlisted Origin on the actual response, then continues the chain.
// DECLARED — the PLATFORM policy's CORSOrigins, read live (recompiled only when it
// changes) so a SuperAdmin can retune it via PUT /v1/gateway/config.
// PROVEN — a host whose site_hosts row is VERIFIED and bound to an org, i.e. the
// customer published the DNS-01 challenge token for it.
//
// PROVEN is why this is not a static list any more. The product is that a customer
// forks hanzoai/console and deploys it on their OWN domain; a list of domains we own
// can never name that domain, so the shipped feature could not work. It does not
// widen trust: the name still has to be proved, and it is proved through the
// existing boundary rather than a second one.
//
// AN EMPTY DECLARED LIST IS NO LONGER A NO-OP. It used to be, on the reasoning that
// the shared Traefik ingress owned CORS and a second Access-Control-Allow-Origin
// would break every preflight. That reasoning still holds for the header, and this
// middleware still emits one only when it admits the origin — but PROVEN has to be
// answerable even in a deployment that declared nothing, or a customer's own domain
// would depend on an operator having typed something into a list.
//
// The preflight is short-circuited (204) and the actual response reflects the
// origin. Both hang off the SAME boolean, so there is no arrangement in which a
// browser is told yes at the preflight and no at the request.
func EdgeCORS(pol *edge.Store) zip.Handler {
return edgeCORS(pol, corsVerifiedHost)
}
// edgeCORS is EdgeCORS over an explicit PROVEN source — the seam the tests drive
// without standing up a projects store or a plane.
func edgeCORS(pol *edge.Store, proven verifiedHostFn) zip.Handler {
var (
mu sync.Mutex
lastKey string
matcher *originMatcher
origins = &corsOrigins{proven: proven}
)
// currentMatcher recompiles only when the live allowlist string changes; the
// list is small and Platform() is itself TTL-cached, so this stays cheap.
currentMatcher := func() *originMatcher {
origins := pol.Platform().CORSOrigins
key := strings.Join(origins, "\n")
// Publish the instance, not a copy: apps/ai asks THIS predicate, sharing its
// compiled allowlist and its cache, so the two layers cannot drift.
corsPredicate.Store(origins)
// current recompiles the DECLARED matcher only when the live allowlist string
// changes; the list is small and Platform() is itself TTL-cached, so this stays
// cheap. The PROVEN cache is deliberately NOT rebuilt with it — it is keyed on
// hostnames, not on the policy, and it ages out on its own TTL.
current := func() *corsOrigins {
list := pol.Platform().CORSOrigins
key := strings.Join(list, "\n")
mu.Lock()
defer mu.Unlock()
if key != lastKey {
lastKey = key
matcher = newOriginMatcher(origins)
origins.setDeclared(newOriginMatcher(list))
}
return matcher
return origins
}
return func(c *zip.Ctx) error {
m := currentMatcher()
if m == nil {
// No allowlist configured ⇒ CORS is owned elsewhere (ingress). No-op.
return c.Continue()
}
origin := c.Header("Origin")
if origin == "" || !m.allowed(origin) {
// Not a cross-origin browser request we vouch for: add nothing (a
// non-allowlisted origin never receives credentialed CORS headers).
// A preflight from an unknown origin falls through and is refused by
// the normal pipeline; the browser blocks it either way.
if origin == "" {
// Not a cross-origin browser request. Nothing here depends on Origin, so
// nothing is added — not even Vary.
return c.Continue()
}
// Allowlisted origin: reflect it (credentialed CORS is never wildcard).
// EVERY answer below depends on Origin, INCLUDING the one that carries no
// CORS header, so the cache key must say so or a shared cache will hand one
// origin the response computed for another. Set before the branch, and
// APPENDED rather than assigned: SetHeader("Vary", …) overwrites, which is
// how this used to fight middleware_markdown's Vary: Accept — last writer
// won and one of the two protections silently vanished. Fiber's Vary appends
// and is idempotent.
c.Fiber().Vary("Origin")
if !current().allowed(c.Context(), origin) {
// An origin we do not vouch for gets NO credentialed CORS headers, and
// the browser blocks the read. It is not refused here: this middleware
// is in front of every route, and a 403 would break the many non-browser
// callers that send a stray Origin, plus every same-origin POST whose
// host nobody thought to declare. Withholding the header is the whole
// enforcement — it is what the browser acts on.
return c.Continue()
}
// Admitted: reflect the exact origin. Credentialed CORS is NEVER wildcard —
// `*` with Access-Control-Allow-Credentials is invalid per the Fetch
// standard, and the value echoed here has already been matched against a
// declaration or resolved to a verified record.
c.SetHeader("Access-Control-Allow-Origin", origin)
c.SetHeader("Access-Control-Allow-Credentials", "true")
c.SetHeader("Vary", "Origin")
if c.Method() == "OPTIONS" {
c.SetHeader("Access-Control-Allow-Methods", corsAllowMethods)
c.SetHeader("Access-Control-Allow-Headers", corsAllowHeaders)
@@ -125,62 +162,35 @@ func EdgeCORS(pol *edge.Store) zip.Handler {
}
}
// originMatcher decides whether a request Origin is on the CORS allowlist. Each
// config entry is either an EXACT origin ("https://hanzo.ai") or a host wildcard
// ("*.hanzo.ai", which matches the apex `hanzo.ai` AND any subdomain
// `<sub>.hanzo.ai`) — the two forms the gateway/ingress allowlists use, expressed
// once. Bare-host entries ("hanzo.ai") are treated as a host match on any scheme.
type originMatcher struct {
exact map[string]struct{} // full origin strings, e.g. "https://hanzo.ai"
hosts map[string]struct{} // bare hosts matched regardless of scheme
suffix []string // wildcard hosts: apex value, e.g. "hanzo.ai"
}
// corsPredicate is the process's ONE compiled CORS origin predicate — the instance
// EdgeCORS built, published so the other layer that would otherwise take its own
// CORS decision can ask THIS one instead.
//
// That layer is hanzoai/ai. It is mounted in-process and registers a CORS filter
// ahead of every /v1 route (routers/filters.go), which REFUSES with 403 any origin
// outside a 21-domain suffix list compiled into that module — a list no customer
// domain can ever be in, and no deployment can change. Two CORS authorities on one
// request is the defect: without this, a customer's console passes the preflight
// here and gets 403 on the actual call there, which is precisely the failure mode
// CORS review exists to catch.
//
// Published as a FUNCTION over the shared instance rather than a per-request mark:
// a mark travels as a request local or a header, and a header is forgeable by the
// caller — it would let a client assert its own CORS verdict. Both layers calling
// one function cannot disagree, and there is nothing on the wire to forge.
var corsPredicate atomic.Pointer[corsOrigins]
// newOriginMatcher compiles the allowlist. Returns nil when empty, so the caller
// can make CORS a pure no-op (the "owned elsewhere" default).
func newOriginMatcher(origins []string) *originMatcher {
if len(origins) == 0 {
return nil
}
m := &originMatcher{exact: map[string]struct{}{}, hosts: map[string]struct{}{}}
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
switch {
case strings.HasPrefix(o, "*."):
m.suffix = append(m.suffix, strings.ToLower(o[2:]))
case strings.Contains(o, "://"):
m.exact[o] = struct{}{}
default:
m.hosts[strings.ToLower(o)] = struct{}{}
}
}
if len(m.exact) == 0 && len(m.hosts) == 0 && len(m.suffix) == 0 {
return nil
}
return m
}
func (m *originMatcher) allowed(origin string) bool {
if _, ok := m.exact[origin]; ok {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
// CORSAllows reports whether this deployment's CORS authority admits origin.
//
// Exported for apps/ai, which is the only caller and uses it to keep the mounted ai
// module's own filter inert. Answers false before the edge is built (no app, no
// policy, nothing admitted), which is the fail-secure direction.
func CORSAllows(ctx context.Context, origin string) bool {
o := corsPredicate.Load()
if o == nil {
return false
}
host := strings.ToLower(u.Hostname())
if _, ok := m.hosts[host]; ok {
return true
}
for _, sfx := range m.suffix {
if host == sfx || strings.HasSuffix(host, "."+sfx) {
return true
}
}
return false
return o.allowed(ctx, origin)
}
// ─────────────────────────────────────────────────────────────────────────────
+11 -2
View File
@@ -66,8 +66,17 @@ func corsApp(t *testing.T, origins []string) *zip.App {
return app
}
func TestEdgeCORS_DisabledByDefault(t *testing.T) {
app := corsApp(t, nil) // empty allowlist ⇒ no-op (ingress owns CORS)
// TestEdgeCORS_DeclaresNothingAdmitsNothing: with an empty allowlist and no proven
// site host, no origin is admitted.
//
// This used to be named "disabled by default" and asserted that an empty allowlist
// made the middleware a pure no-op, on the reasoning that the ingress owned CORS.
// It is no longer a no-op — an empty DECLARED list still admits a host whose
// site_hosts row is verified, because a customer's own domain must not depend on an
// operator having typed it into a list. What survives is the property that actually
// mattered: nothing is admitted that nothing vouches for.
func TestEdgeCORS_DeclaresNothingAdmitsNothing(t *testing.T) {
app := corsApp(t, nil)
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set("Origin", "https://hanzo.ai")
res, err := app.Test(req)