mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
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.SplitSeq(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.SplitSeq(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)
|
|
}
|
|
}
|