Ctrl K
ComunidadGuías…

internal/mcpgateway/originguard.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
}