Ctrl K
ComunidadGuías…

internal/mcpgateway/oauth_dialguard.go

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

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/enterpilot/gomodel/internal/httpclient"
)

// errOAuthNonPublicAddress means an OAuth endpoint resolved to an address
// the gateway refuses to reach for a server's sign-in.
var errOAuthNonPublicAddress = errors.New("refusing to connect to a non-public address")

// errOAuthTransport means the upstream HTTP client sends through a transport
// the guard cannot wrap, so OAuth requests are refused rather than sent
// unchecked.
var errOAuthTransport = errors.New("mcp oauth requests need an *http.Transport to check the addresses they dial")

// nonPublicHint explains a refused OAuth endpoint.
const nonPublicHint = "OAuth endpoints must be public unless they share the MCP server's host or the MCP server is on a private network too"

// oauthDialDefaults are the dialer settings of the shared upstream client,
// whose dialer the guarded copy replaces with its own.
var oauthDialDefaults = httpclient.DefaultConfig()

// nonPublicPrefixes are ranges netip's predicates do not cover that must
// not be reachable through server-published URLs.
var nonPublicPrefixes = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),
	netip.MustParsePrefix("192.0.0.0/24"),
	netip.MustParsePrefix("198.18.0.0/15"),
	netip.MustParsePrefix("240.0.0.0/4"),
}

// carrierGradeNAT is shared address space, private like RFC 1918 ranges.
var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10")

// serverResolveTimeout bounds resolving the MCP server's own host to learn
// which zone it is in.
const serverResolveTimeout = 5 * time.Second

// serverZone is where an MCP server's host sits, which decides where its
// OAuth endpoints may sit too.
type serverZone int

const (
	zonePublic serverZone = iota
	zonePrivate
	zoneLoopback
)

// oauthHTTPClient returns base for the OAuth requests of the server at
// serverURL: discovery, client registration, and token requests. The server
// publishes the URLs those go to, so every connection is checked when it is
// dialed, redirects included: a host other than the server's own has to
// resolve to a public address, to a private one when the server itself is
// on a private network (an internal MCP server signing in through an internal
// identity provider), or to loopback when the server itself is on loopback
// for local development. Link-local addresses, cloud metadata endpoints
// among them, are refused for every host, the server's own included, so a
// DNS answer that changes after sign-in cannot reach them. Without the check
// a public, malicious server could point the gateway at internal services or
// a cloud metadata endpoint. Through a configured proxy, the target is
// resolved and checked before the proxy is used.
//
// The go-sdk's own discovery guard turns itself off for a wrapped transport
// such as the gateway's and never covers token requests, so the check lives
// here.
func oauthHTTPClient(base *http.Client, serverURL string) *http.Client {
	if base == nil {
		base = http.DefaultClient
	}
	var host string
	if parsed, err := url.Parse(serverURL); err == nil {
		host = parsed.Hostname()
	}
	clone := *base
	clone.Transport = &oauthGuardTransport{
		base:  base,
		guard: &oauthDialGuard{serverHost: host, zone: serverZoneOnce(host)},
	}
	return &clone
}

// serverZoneOnce reports host's zone, resolving it on first use: loopback by
// name or address, private when it is a private address or resolves only to
// private addresses, else public. A failed lookup counts as public, the
// stricter answer.
func serverZoneOnce(host string) func() serverZone {
	return sync.OnceValue(func() serverZone {
		if isLoopbackHost(host) {
			return zoneLoopback
		}
		if ip, err := netip.ParseAddr(host); err == nil {
			if privateAddr(ip) {
				return zonePrivate
			}
			return zonePublic
		}
		if host == "" {
			return zonePublic
		}
		ctx, cancel := context.WithTimeout(context.Background(), serverResolveTimeout)
		defer cancel()
		ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
		if err != nil || len(ips) == 0 {
			return zonePublic
		}
		for _, ip := range ips {
			if !privateAddr(ip) {
				return zonePublic
			}
		}
		return zonePrivate
	})
}

func privateAddr(ip netip.Addr) bool {
	ip = ip.Unmap()
	return ip.IsPrivate() || carrierGradeNAT.Contains(ip)
}

// oauthGuardTransport sends through a copy of the base client's current
// transport that dials through the guard. The copy is rebuilt when the base
// transport changes, such as after a TLS trust reload. A base transport the
// guard cannot copy fails every request instead of sending it unchecked.
type oauthGuardTransport struct {
	base  *http.Client
	guard *oauthDialGuard

	mu      sync.Mutex
	source  *http.Transport
	guarded *http.Transport
}

func (t *oauthGuardTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	transport, err := t.transport()
	if err != nil {
		return nil, err
	}
	return transport.RoundTrip(req)
}

func (t *oauthGuardTransport) CloseIdleConnections() {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.guarded != nil {
		t.guarded.CloseIdleConnections()
	}
}

func (t *oauthGuardTransport) transport() (*http.Transport, error) {
	source, err := baseTransport(t.base)
	if err != nil {
		return nil, err
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.guarded != nil && t.source == source {
		return t.guarded, nil
	}
	if t.guarded != nil {
		t.guarded.CloseIdleConnections()
	}
	guarded := source.Clone()
	guarded.DialContext = t.guard.dial
	guarded.DialTLS = nil //nolint:staticcheck // SA1019: a legacy TLS dialer would bypass the guard.
	guarded.DialTLSContext = nil
	guarded.Proxy = t.guard.proxy(source.Proxy)
	t.source, t.guarded = source, guarded
	return guarded, nil
}

// baseTransport returns the *http.Transport client sends through: the one a
// gateway client currently uses, a plain one, or the default for a client
// without one.
func baseTransport(client *http.Client) (*http.Transport, error) {
	if transport, ok := httpclient.CurrentTransport(client); ok {
		return transport, nil
	}
	switch transport := client.Transport.(type) {
	case nil:
		if def, ok := http.DefaultTransport.(*http.Transport); ok {
			return def, nil
		}
	case *http.Transport:
		return transport, nil
	}
	return nil, fmt.Errorf("%w: %T", errOAuthTransport, client.Transport)
}

// oauthDialGuard checks the addresses OAuth connections dial.
type oauthDialGuard struct {
	// serverHost is the MCP server's own host, which the operator chose and
	// is reachable wherever it resolves, link-local addresses excepted.
	serverHost string
	// zone is the server host's zone, resolved once on first use.
	zone func() serverZone
	// proxies holds the host:port of each proxy the transport picked for a
	// checked target, so the dial to the proxy itself passes like the
	// server's own host. A target never passes for sharing a proxy's
	// hostname: only the dial address is compared.
	proxies sync.Map
}

// allowed reports whether ip may be dialed. trusted marks the server's own
// host and a configured proxy, which sit wherever the operator put them;
// link-local addresses, cloud metadata endpoints among them, are refused for
// every host.
func (g *oauthDialGuard) allowed(ip netip.Addr, trusted bool) bool {
	ip = ip.Unmap()
	if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
		return false
	}
	if trusted {
		return true
	}
	if ip.IsLoopback() {
		return g.zone() == zoneLoopback
	}
	if privateAddr(ip) {
		return g.zone() == zonePrivate
	}
	if !ip.IsGlobalUnicast() {
		return false
	}
	for _, prefix := range nonPublicPrefixes {
		if prefix.Contains(ip) {
			return false
		}
	}
	return true
}

// ownHost reports whether host is the MCP server's own.
func (g *oauthDialGuard) ownHost(host string) bool {
	return strings.EqualFold(host, g.serverHost)
}

func refusedAddress(host string, ip netip.Addr) error {
	return fmt.Errorf("%w: %s resolves to %s; %s", errOAuthNonPublicAddress, host, ip, nonPublicHint)
}

// dial checks the address each connection actually goes to, after name
// resolution, so a host cannot resolve to a public address for a check and
// a private one for the connection.
func (g *oauthDialGuard) dial(ctx context.Context, network, address string) (net.Conn, error) {
	host, _, err := net.SplitHostPort(address)
	if err != nil {
		return nil, err
	}
	_, viaProxy := g.proxies.Load(strings.ToLower(address))
	trusted := viaProxy || g.ownHost(host)
	dialer := &net.Dialer{Timeout: oauthDialDefaults.DialTimeout, KeepAlive: oauthDialDefaults.KeepAlive}
	dialer.Control = func(_, resolved string, _ syscall.RawConn) error {
		addr, err := netip.ParseAddrPort(resolved)
		if err != nil {
			return fmt.Errorf("%w: %s", errOAuthNonPublicAddress, host)
		}
		if !g.allowed(addr.Addr(), trusted) {
			return refusedAddress(host, addr.Addr())
		}
		return nil
	}
	return dialer.DialContext(ctx, network, address)
}

// proxy wraps the transport's proxy selection: a request that goes through
// a proxy has its target checked here, since the dial only sees the proxy.
// The proxy resolves the target on its own, so the check reflects the
// gateway's view of DNS; a proxy that reaches internal networks should not
// front a gateway that signs in to servers it does not trust.
func (g *oauthDialGuard) proxy(next func(*http.Request) (*url.URL, error)) func(*http.Request) (*url.URL, error) {
	if next == nil {
		return nil
	}
	return func(req *http.Request) (*url.URL, error) {
		proxyURL, err := next(req)
		if err != nil || proxyURL == nil {
			return proxyURL, err
		}
		if err := g.checkTarget(req.Context(), req.URL.Hostname()); err != nil {
			return nil, err
		}
		g.proxies.Store(proxyDialAddress(proxyURL), struct{}{})
		return proxyURL, nil
	}
}

// proxyDialAddress is the host:port the transport dials for proxyURL.
func proxyDialAddress(proxyURL *url.URL) string {
	port := proxyURL.Port()
	if port == "" {
		switch proxyURL.Scheme {
		case "https":
			port = "443"
		case "socks5", "socks5h":
			port = "1080"
		default:
			port = "80"
		}
	}
	return strings.ToLower(net.JoinHostPort(proxyURL.Hostname(), port))
}

// checkTarget resolves host and checks every address, as dial would.
func (g *oauthDialGuard) checkTarget(ctx context.Context, host string) error {
	ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
	if err != nil {
		return fmt.Errorf("resolve %s: %w", host, err)
	}
	trusted := g.ownHost(host)
	for _, ip := range ips {
		if !g.allowed(ip, trusted) {
			return refusedAddress(host, ip)
		}
	}
	return nil
}