mirror of
https://github.com/hanzoai/gateway.git
synced 2026-11-02 23:52:23 +00:00
Two literal lists of the allowed request headers — one here, one in cloud's middleware_edge.go — and this one had drifted four names behind: the console's X-Actor-Id, X-Act-As-Project, X-Act-As-Org and X-CSRF-Token were added there and never here. That is what a second literal list of the same thing always does. A name absent from the list fails PREFLIGHT, and the browser reports that as an opaque "TypeError: Failed to fetch" with nothing on the server side to see. The request never arrives, so there is no log, no metric and no 4xx to notice. The same gap cost the billing checkout its X-Idempotency-Key on POST /v1/billing/subscribe/card, which meant no paid subscription could leave the browser at all. So stop maintaining the list. Echo the Access-Control-Request-Headers the browser is asking about — the Fetch-standard way to say "all of them", and the only answer that cannot go stale or drift from cloud's. Only well-formed tokens are echoed, so a hand-written ask cannot fold a CRLF into a response header. negotiation now runs only on the preflight, which is what its own comment says it is for. An ordinary response was carrying allow-headers and max-age that a browser ignores, and it is also the only way Vary can name the request-headers on the preflight without claiming an ordinary response depends on them.
157 lines
6.8 KiB
Go
157 lines
6.8 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
// cors.go is the gateway's credentialed-CORS policy, stated ONCE.
|
|
//
|
|
// Which Origin may be reflected into Access-Control-Allow-Origin next to
|
|
// Access-Control-Allow-Credentials is a property of the REQUEST, not of the
|
|
// framework carrying it — the same decomplection [authGate] already made for
|
|
// authn. It lived inside a gin closure, and the cost of that braiding was the
|
|
// familiar one: the zip edge could not run the rule, so the HIP-0106 mount
|
|
// answered no preflight at all. A browser SPA talking to the unified binary got
|
|
// whatever the router does with an unrouted OPTIONS where the legacy edge
|
|
// answers 204 with the allowlist applied.
|
|
//
|
|
// Now the rule is a value — [corsPolicy] — and each transport is a few lines of
|
|
// adapter over it:
|
|
//
|
|
// legacy_transports.go gin, for the Lura edge (the shipping image)
|
|
// mount.go native zip, for the HIP-0106 unified cloud binary
|
|
//
|
|
// The header NAMES and VALUES live here too, in [corsAnswer], so the two edges
|
|
// cannot answer one preflight two ways.
|
|
package gateway
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// corsPolicy is the credentialed-CORS decision, compiled once from the
|
|
// environment. Build it with [newCORSPolicy] and ask it per request.
|
|
type corsPolicy struct{ allowed func(origin string) bool }
|
|
|
|
// newCORSPolicy compiles the allowlist. It reads the environment once, at
|
|
// construction, exactly as the middleware it replaced did.
|
|
func newCORSPolicy() corsPolicy { return corsPolicy{allowed: newCORSOriginAllower()} }
|
|
|
|
// admit reports the CORS an Origin has earned. nil means NONE, and none is the
|
|
// safe answer: a request whose Origin is absent or not allowlisted gets no
|
|
// Access-Control-* header whatsoever, so no other site can make a credentialed
|
|
// cross-origin read against a session (Great-Audit F3).
|
|
func (p corsPolicy) admit(origin string) *corsAnswer {
|
|
if origin == "" || !p.allowed(origin) {
|
|
return nil
|
|
}
|
|
return &corsAnswer{origin: origin}
|
|
}
|
|
|
|
// corsAnswer is what an allowlisted Origin earns. It writes through a
|
|
// transport's own two-string header setter — gin's Header, zip's SetHeader — so
|
|
// the header names and values are stated once and neither edge can drift.
|
|
type corsAnswer struct{ origin string }
|
|
|
|
// credentials is what EVERY response to an allowlisted Origin carries: the
|
|
// reflection, the credentials flag, and Vary so a shared cache never serves one
|
|
// origin's Access-Control-Allow-Origin to another.
|
|
//
|
|
// It is the WHOLE of the panic-recovery path's CORS: a 500 is still an
|
|
// origin-dependent response and must not widen the policy into a
|
|
// wildcard-reflect-with-credentials primitive on the error path.
|
|
func (a *corsAnswer) credentials(set func(name, value string)) {
|
|
set("Access-Control-Allow-Origin", a.origin)
|
|
set("Access-Control-Allow-Credentials", "true")
|
|
set("Vary", "Origin")
|
|
}
|
|
|
|
// negotiation is the rest of what a browser needs to complete a preflight. It
|
|
// is separate from [corsAnswer.credentials] because the error path deliberately
|
|
// sends only the credentials half — describing what a 500 may be read by, not
|
|
// what the API accepts.
|
|
//
|
|
// ask is the preflight's Access-Control-Request-Headers, and Vary names it for
|
|
// the same reason credentials names Origin. Both are written here rather than
|
|
// appended because set is a two-string Set: the last writer wins, so the whole
|
|
// value has to be stated at once.
|
|
func (a *corsAnswer) negotiation(ask string, set func(name, value string)) {
|
|
set("Access-Control-Allow-Headers", corsAllowHeaders(ask))
|
|
set("Access-Control-Allow-Methods", corsAllowMethods)
|
|
set("Access-Control-Max-Age", corsMaxAge)
|
|
set("Vary", "Origin, Access-Control-Request-Headers")
|
|
}
|
|
|
|
const (
|
|
corsAllowMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
corsMaxAge = "86400"
|
|
)
|
|
|
|
// corsAllowHeaders answers a preflight with the header names the BROWSER said
|
|
// the script attached — the Access-Control-Request-Headers it is asking about.
|
|
//
|
|
// This used to be a literal list, and a literal list is a standing outage
|
|
// waiting for the next client header: a name absent from it fails PREFLIGHT, and
|
|
// the browser reports that as an opaque "TypeError: Failed to fetch" with nothing
|
|
// on the server side to see. The request never arrives, so there is no log, no
|
|
// metric and no 4xx to notice. This copy had drifted four names behind the one
|
|
// cloud serves (middleware_edge.go), which is what a second literal list of the
|
|
// same thing always does.
|
|
//
|
|
// Answering the ask widens nothing. Naming a header only lets the browser SEND
|
|
// it, and the auth gate still validates and re-mints every client-supplied
|
|
// identity header — an intent is validated, never believed. The forbidden names a
|
|
// script must never set the browser refuses on its own. [corsPolicy.admit] is the
|
|
// enforcement, and it did not move.
|
|
//
|
|
// Only well-formed tokens are echoed, so a hand-written request cannot fold a
|
|
// CRLF into a response header.
|
|
func corsAllowHeaders(ask string) string {
|
|
names := make([]string, 0, 8)
|
|
for _, name := range strings.Split(ask, ",") {
|
|
if name = strings.TrimSpace(name); headerToken(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// headerToken reports whether s is a single RFC 9110 field-name token — the only
|
|
// shape a header name can have.
|
|
func headerToken(s string) bool {
|
|
const tchar = "!#$%&'*+-.^_`|~0123456789" +
|
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
return s != "" && strings.IndexFunc(s, func(r rune) bool {
|
|
return !strings.ContainsRune(tchar, r)
|
|
}) < 0
|
|
}
|
|
|
|
// newCORSOriginAllower builds the gateway's single credentialed-CORS origin
|
|
// predicate. An origin is allowlisted iff it EITHER exact-matches a dev/env
|
|
// origin (the baked-in localhost defaults plus any fully-qualified
|
|
// scheme+host[:port] supplied via GATEWAY_CORS_ORIGINS, comma-separated) OR its
|
|
// hostname suffix-matches a baked-in brand domain (defaultAllowedOrigins — the
|
|
// SAME set the widget gate uses — including subdomains, so a browser SPA like
|
|
// https://cowork.hanzo.ai is allowed with no per-origin env).
|
|
//
|
|
// This is the ONE definition of the allowlist (DRY). Every site that reflects an
|
|
// Origin with credentials gates on it: the preflight transport on both edges AND
|
|
// the legacy engine's panic-recovery handler.
|
|
func newCORSOriginAllower() func(origin string) bool {
|
|
origins := map[string]bool{
|
|
"http://localhost:3000": true, "http://localhost:3001": true, "http://localhost:3100": true,
|
|
"http://localhost:5173": true, "http://localhost:8080": true, "http://127.0.0.1:3000": true,
|
|
}
|
|
if extra := os.Getenv("GATEWAY_CORS_ORIGINS"); extra != "" {
|
|
for _, o := range strings.Split(extra, ",") {
|
|
if o = strings.TrimSpace(o); o != "" {
|
|
origins[o] = true
|
|
}
|
|
}
|
|
}
|
|
brandOrigins := widgetAllowedOrigins(defaultAllowedOrigins())
|
|
return func(origin string) bool {
|
|
if origins[origin] {
|
|
return true
|
|
}
|
|
return isAllowedOrigin(extractOriginHost(origin), brandOrigins)
|
|
}
|
|
}
|