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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user