mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// mcpDiscoveryExtraBlockedPrefixes lists special-use CIDR ranges that
|
|
// the stdlib classification methods (IsLoopback, IsPrivate, etc.) do
|
|
// not cover. Blocking these prevents SSRF against carrier-grade NAT,
|
|
// benchmarking, documentation, discard-only, and the all-zeros "this
|
|
// network" ranges.
|
|
//
|
|
// IPv6 ranges already handled by stdlib:
|
|
// - ::1/128 (IsLoopback)
|
|
// - fc00::/7 (IsPrivate, ULA)
|
|
// - fe80::/10 (IsLinkLocalUnicast)
|
|
// - ff00::/8 (IsMulticast)
|
|
// - ::/128 (IsUnspecified)
|
|
var mcpDiscoveryExtraBlockedPrefixes = []netip.Prefix{
|
|
// IPv4 special-use ranges.
|
|
netip.MustParsePrefix("0.0.0.0/8"), // RFC 1122 "this network".
|
|
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 carrier-grade NAT.
|
|
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking.
|
|
|
|
// IPv6 special-use ranges not covered by stdlib.
|
|
netip.MustParsePrefix("64:ff9b:1::/48"), // RFC 8215 IPv4/IPv6 translation.
|
|
netip.MustParsePrefix("100::/64"), // RFC 6666 discard-only.
|
|
netip.MustParsePrefix("2001:2::/48"), // RFC 5180 benchmarking.
|
|
netip.MustParsePrefix("2001:db8::/32"), // RFC 3849 documentation.
|
|
}
|
|
|
|
// isBlockedMCPDiscoveryAddr reports whether addr must not be reached
|
|
// during MCP OAuth2 discovery because it is in a private, loopback,
|
|
// link-local, multicast, unspecified, or other special-use range.
|
|
// IPv4-mapped IPv6 addresses are unmapped first so a literal like
|
|
// ::ffff:169.254.169.254 cannot bypass the IPv4 ranges. Prefixes in
|
|
// allowed exempt their range from blocking.
|
|
func isBlockedMCPDiscoveryAddr(addr netip.Addr, allowed []netip.Prefix) bool {
|
|
addr = addr.Unmap()
|
|
for _, prefix := range allowed {
|
|
if prefix.Contains(addr) {
|
|
return false
|
|
}
|
|
}
|
|
if addr.IsLoopback() ||
|
|
addr.IsPrivate() ||
|
|
addr.IsLinkLocalUnicast() ||
|
|
addr.IsLinkLocalMulticast() ||
|
|
addr.IsMulticast() ||
|
|
addr.IsUnspecified() ||
|
|
addr.IsInterfaceLocalMulticast() {
|
|
return true
|
|
}
|
|
for _, prefix := range mcpDiscoveryExtraBlockedPrefixes {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newMCPDiscoveryHTTPClient returns an HTTP client for MCP OAuth2
|
|
// metadata discovery and Dynamic Client Registration that refuses to
|
|
// connect to private/internal addresses. Every URL fetched during
|
|
// discovery is attacker-influenced (the MCP server URL and any
|
|
// endpoints or redirects it advertises), so without this guard a
|
|
// hostile server can pivot coderd into internal infrastructure such
|
|
// as cloud metadata services (CDM-02-002).
|
|
//
|
|
// The guard validates the resolved IPs at dial time and dials a
|
|
// validated IP directly, so DNS rebinding cannot swap in a private
|
|
// address between validation and connect, and 3xx redirects to
|
|
// internal targets are blocked when the redirected connection is
|
|
// dialed. Requests never use a proxy: through a proxy the destination
|
|
// IP is invisible to the dialer and the guard would be ineffective.
|
|
//
|
|
// base contributes its timeout and (when its transport is an
|
|
// *http.Transport) TLS configuration; its dialing behavior is always
|
|
// replaced with the guarded dialer.
|
|
func newMCPDiscoveryHTTPClient(base *http.Client, allowed []netip.Prefix) *http.Client {
|
|
timeout := 30 * time.Second
|
|
var transport *http.Transport
|
|
if base != nil {
|
|
if base.Timeout > 0 {
|
|
timeout = base.Timeout
|
|
}
|
|
if t, ok := base.Transport.(*http.Transport); ok && t != nil {
|
|
transport = t.Clone()
|
|
}
|
|
}
|
|
if transport == nil {
|
|
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
|
transport = t.Clone()
|
|
} else {
|
|
transport = &http.Transport{}
|
|
}
|
|
}
|
|
|
|
// Force every connection through the guarded dialer: no proxies
|
|
// and no alternate dial paths that would bypass it.
|
|
transport.Proxy = nil
|
|
//nolint:staticcheck // Deprecated fields are cleared so the guarded DialContext is authoritative.
|
|
transport.Dial = nil
|
|
//nolint:staticcheck // Deprecated fields are cleared so the guarded DialContext is authoritative.
|
|
transport.DialTLS = nil
|
|
transport.DialTLSContext = nil
|
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
lookupNetwork := "ip"
|
|
switch network {
|
|
case "tcp":
|
|
case "tcp4":
|
|
lookupNetwork = "ip4"
|
|
case "tcp6":
|
|
lookupNetwork = "ip6"
|
|
default:
|
|
return nil, xerrors.Errorf("network %q not permitted for MCP OAuth2 discovery", network)
|
|
}
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("split host/port %q: %w", addr, err)
|
|
}
|
|
ips, err := net.DefaultResolver.LookupNetIP(ctx, lookupNetwork, host)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("resolve %q: %w", host, err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, xerrors.Errorf("no addresses for %q", host)
|
|
}
|
|
// Reject when ANY resolved address is blocked so a single
|
|
// tainted DNS answer short-circuits the dial rather than
|
|
// racing it.
|
|
for _, ip := range ips {
|
|
if isBlockedMCPDiscoveryAddr(ip, allowed) {
|
|
return nil, xerrors.Errorf(
|
|
"connection to %q blocked: %s is in a private/reserved IP range not permitted for MCP OAuth2 discovery",
|
|
host, ip.Unmap(),
|
|
)
|
|
}
|
|
}
|
|
// Dial a validated IP directly. Dialing by hostname would
|
|
// re-resolve, letting a hostile resolver swap in a private
|
|
// IP after validation (DNS rebinding). TLS verification
|
|
// still uses the URL hostname via the transport's TLS
|
|
// config.
|
|
var dialer net.Dialer
|
|
var firstErr error
|
|
for _, ip := range ips {
|
|
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.Unmap().String(), port))
|
|
if dialErr == nil {
|
|
return conn, nil
|
|
}
|
|
if firstErr == nil {
|
|
firstErr = dialErr
|
|
}
|
|
}
|
|
return nil, firstErr
|
|
}
|
|
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: transport,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// Mirror the default client's redirect cap.
|
|
if len(via) >= 10 {
|
|
return xerrors.New("stopped after 10 redirects")
|
|
}
|
|
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
|
return xerrors.Errorf("redirect to non-HTTP scheme %q blocked", req.URL.Scheme)
|
|
}
|
|
// Defense in depth: reject redirects to blocked IP
|
|
// literals before the request is attempted. Hostnames
|
|
// are validated post-resolution by the guarded dialer.
|
|
if ip, err := netip.ParseAddr(req.URL.Hostname()); err == nil && isBlockedMCPDiscoveryAddr(ip, allowed) {
|
|
return xerrors.Errorf(
|
|
"redirect to %q blocked: destination is in a private/reserved IP range not permitted for MCP OAuth2 discovery",
|
|
req.URL.Host,
|
|
)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|