Files
zeekayandHanzo Dev a5c5cc019e Go 1.27.1, and the code it can say more simply
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]>
2026-09-16 22:42:15 -07:00

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)
}
}