internal/mcpgateway/originguard.go
Rama main · réplica de lectura del repositorio original.
package mcpgateway
import (
"net/http"
"strings"
"github.com/enterpilot/gomodel/config"
"github.com/enterpilot/gomodel/internal/core"
)
// originGuard decides whether one downstream request may reach the MCP
// handler, and is the gateway's DNS-rebinding defense.
//
// The threat is a browser: an attacker's page points its own hostname at the
// gateway's address, and the browser then issues what it believes are
// same-origin requests to it. Rebinding changes only which address a hostname
// resolves to, never the hostname itself, so a rebound request's Origin and
// Host still agree with each other. Comparing the two — the fallback in
// http.CrossOriginProtection, which this guard replaces — therefore cannot
// detect a rebind, and neither can Sec-Fetch-Site, which reads "same-origin"
// for exactly the same reason. Only an explicit allowlist distinguishes the
// gateway's real origin from an attacker's rebound one.
//
// So the rule is: a request a browser initiated is served only when its
// Origin is explicitly trusted. Requests carrying no browser fetch metadata
// are ordinary MCP client traffic and pass through — still subject to the
// gateway's bearer authentication, which is the control that keeps unknown
// non-browser callers out.
//
// Unlike http.CrossOriginProtection this applies to every method. GET opens
// the MCP notification stream and DELETE tears a session down; neither is a
// "safe method" in the sense that would justify exempting it.
type originGuard struct {
trusted map[string]struct{}
hosts map[string]struct{}
trustsAll bool
}
// newOriginGuard compiles the configured allowlist. Entries are written the
// way an Origin header is ("scheme://host[:port]"), or config.TrustAnyOrigin
// to disable the check. An empty allowlist trusts no browser origin, which is
// the default and is correct for the MCP clients this endpoint exists to
// serve — none of them are web pages.
func newOriginGuard(origins []string) (*originGuard, error) {
guard := &originGuard{
trusted: make(map[string]struct{}, len(origins)),
hosts: make(map[string]struct{}, len(origins)),
}
for _, raw := range origins {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if entry == config.TrustAnyOrigin {
guard.trustsAll = true
continue
}
normalized, err := config.NormalizeAllowedOrigin(entry)
if err != nil {
return nil, err
}
guard.trusted[normalized] = struct{}{}
guard.hosts[config.OriginHost(normalized)] = struct{}{}
}
return guard, nil
}
// sameOriginStreamFromTrustedHost recognizes the one browser request that
// legitimately carries no Origin: the GET that opens the MCP notification
// stream. Per the Fetch standard a browser appends Origin only to CORS-tainted
// requests and to unsafe methods, so a same-origin GET or HEAD arrives with
// Sec-Fetch-Site but no Origin. Without this the allowlist would be useless —
// a browser client could initialize over POST and then never open its stream.
//
// Matching Host against the allowlist is sound here, and is not the broken
// self-consistency check this guard replaced. That one compared two
// attacker-supplied values (Origin and Host) to each other, which a rebound
// request satisfies by construction. This compares Host to an operator-
// configured list the attacker cannot write to: "same-origin" is the browser
// attesting that the initiating page's origin is the request's origin, whose
// host is the Host header, so a rebound page at attacker.example presents
// Host: attacker.example and matches nothing. An empty allowlist — the
// default — matches nothing either, so this widens nothing out of the box.
func (g *originGuard) sameOriginStreamFromTrustedHost(r *http.Request, fetchSite string) bool {
// "same-site" and "none" are excluded deliberately: neither tells us the
// initiating page's origin equals the request's, so Host proves nothing.
if !strings.EqualFold(fetchSite, "same-origin") {
return false
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
_, ok := g.hosts[strings.ToLower(strings.TrimSpace(r.Host))]
return ok
}
// check returns the error to fail a request with, or nil to serve it.
func (g *originGuard) check(r *http.Request) error {
if g == nil {
// An unconfigured guard trusts nothing rather than everything: this is
// the gateway's rebinding defense, so it fails closed.
g = &originGuard{}
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
fetchSite := strings.TrimSpace(r.Header.Get("Sec-Fetch-Site"))
// Neither header means no browser was involved. Both are forbidden header
// names, so page JavaScript can neither strip nor forge them; their joint
// absence is a reliable signal, and every browser has sent Sec-Fetch-Site
// since 2023.
if origin == "" && fetchSite == "" {
return nil
}
if g.trustsAll {
return nil
}
if origin != "" {
// A malformed Origin, or the literal "null" that opaque origins such
// as sandboxed frames send, simply fails to match any entry.
if normalized, err := config.NormalizeAllowedOrigin(origin); err == nil {
if _, ok := g.trusted[normalized]; ok {
return nil
}
}
} else if g.sameOriginStreamFromTrustedHost(r, fetchSite) {
return nil
}
// The rejected Origin is deliberately not echoed back: it is
// attacker-controlled text, and the caller already knows what it sent.
return core.NewInvalidRequestErrorWithStatus(http.StatusForbidden,
"browser requests to the MCP endpoint are not permitted from this origin; "+
"list it in mcp.allowed_origins (MCP_ALLOWED_ORIGINS) to allow it", nil)
}