// Package skilltools — SSRF protection layer for skill HTTP tools. // // Why a dedicated layer (vs reusing pkg/utils.ValidateExternalURL): // the platform's HTTP tools enforce a per-deployment ALLOWLIST (not // just a "no private IPs" denylist) — admins must explicitly opt-in // to each domain a skill may call. Additionally, defeating DNS // rebinding requires capturing the resolved IP at validation time // and pinning the dialler so a hostile DNS resolver can't return a // public IP during the check and a private one at dial time. package tool import ( "context" "fmt" "net" "net/http" "net/url" "strings" "time" ) // AllowlistConfig governs which hosts a skill HTTP tool may contact. // // Why a config struct (vs raw []string): forward-compatibility — we // expect to add per-tool overrides (e.g. "this skill may also reach // internal.example.com") and an explicit `AllowLoopback` opt-in for // development environments. Keeping the validation surface as a // struct lets new fields land without breaking call sites. type AllowlistConfig struct { // Domains is the list of allowed hostnames. Wildcards: "*.example.com" // matches "foo.example.com" and "bar.baz.example.com" but NOT // "example.com" itself (to allow both, list both entries). // // Comparison is case-insensitive; trailing dots are NOT trimmed // (DNS treats "example.com" and "example.com." as different). Domains []string } // ResolveAndCheck validates urlStr against the allowlist and returns // the resolved IP. The IP is meant to be passed to the transport's // dial step (via PinnedDialTransport) to defeat DNS rebinding. // // Loopback / private / link-local rejection is bypassed when the // HOSTNAME (not the resolved IP) is itself an entry in the allowlist // OR the resolved IP literal appears in the allowlist. This lets an // admin opt-in to "127.0.0.1" or "localhost" for tests / debug // without a global allow-private flag, while keeping the default // (random hostname → resolved private IP) safe. // // Returns: // - resolvedIP if the URL is acceptable // - error explaining the rejection (host not allowlisted, scheme // unsupported, resolves to private IP, etc.) func ResolveAndCheck(ctx context.Context, urlStr string, allow AllowlistConfig) (net.IP, error) { u, err := url.Parse(urlStr) if err != nil { return nil, fmt.Errorf("parse url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("scheme %q not supported (need http or https)", u.Scheme) } host := u.Hostname() if host == "" { return nil, fmt.Errorf("url has no host") } if !matchesAllowlist(host, allow.Domains) { return nil, fmt.Errorf("host %q not in allowlist", host) } // If the host is already a literal IP, skip the resolve step. if literal := net.ParseIP(host); literal != nil { // Even an explicitly allowlisted IP literal goes through the // privacy check UNLESS the literal is itself in the allowlist // (covers admin opt-in "127.0.0.1" for tests). if hostExplicitlyAllowed(host, allow.Domains) { return literal, nil } if err := rejectPrivateIP(host, literal); err != nil { return nil, err } return literal, nil } // Resolve. context controls timeout. resolver := &net.Resolver{} addrs, err := resolver.LookupIPAddr(ctx, host) if err != nil { return nil, fmt.Errorf("resolve %q: %w", host, err) } if len(addrs) == 0 { return nil, fmt.Errorf("resolve %q: no addresses", host) } ip := addrs[0].IP // Hostname explicitly in allowlist (e.g. "localhost" → opt-in by // admin) bypasses the private-IP check. The wildcard form does NOT // bypass — wildcards are for public domain families, not for // private space. if hostExplicitlyAllowed(host, allow.Domains) { return ip, nil } if err := rejectPrivateIP(host, ip); err != nil { return nil, err } return ip, nil } // rejectPrivateIP returns an error if the IP is loopback / private / // link-local / unspecified, formatted with the original hostname so // the rejection message is informative. // // Why a helper: ResolveAndCheck calls it twice (literal-IP path and // resolved-host path) and the same checks apply. func rejectPrivateIP(host string, ip net.IP) error { // Cloud metadata endpoint check FIRST — it's a link-local IP, so // the more-specific metadata error message would otherwise be // shadowed by the link-local rejection. if ip.Equal(net.ParseIP("169.254.169.254")) { return fmt.Errorf("host %q resolves to cloud metadata IP %v", host, ip) } if ip.IsLoopback() { return fmt.Errorf("host %q resolves to loopback %v", host, ip) } if ip.IsPrivate() { return fmt.Errorf("host %q resolves to private IP %v", host, ip) } if ip.IsLinkLocalUnicast() { return fmt.Errorf("host %q resolves to link-local %v", host, ip) } if ip.IsUnspecified() { return fmt.Errorf("host %q resolves to unspecified %v", host, ip) } return nil } // hostExplicitlyAllowed reports whether host is in the allowlist as // an exact entry (NOT via a wildcard). Used to bypass the private-IP // check when an admin has explicitly named a host (e.g. "127.0.0.1" // or "localhost") to opt-in. func hostExplicitlyAllowed(host string, allow []string) bool { host = strings.ToLower(host) for _, pattern := range allow { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == host { return true } } return false } // matchesAllowlist reports whether host matches any entry in allow, // either by exact match, by "*.example.com" wildcard, or by the // special bare "*" wildcard (allow every host). // // Wildcards match one-or-more subdomain levels: "*.example.com" // matches "foo.example.com" and "a.b.example.com" but NOT // "example.com" itself. // // Bare "*" matches any host. **Operators should use this only when // they understand the SSRF + iptables layers still defend against // private-IP traffic** (ResolveAndCheck blocks loopback / RFC1918 / // link-local UNLESS the IP literal is also in the allowlist; the v15 // codeexec firewall sidecar adds host-level iptables drops). The // bare-"*" form is the v15.1 operator UX answer to "I just want to // let the agent reach the public internet" — without it, operators // had to enumerate TLDs (*.com, *.org, *.io, etc.) which never // covered the long tail. func matchesAllowlist(host string, allow []string) bool { host = strings.ToLower(host) for _, pattern := range allow { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == "" { continue } // Bare "*" = allow-any. The SSRF + iptables layers still // enforce private-IP blocks; this only opens the hostname gate. if pattern == "*" { return true } if pattern == host { return true } if strings.HasPrefix(pattern, "*.") { suffix := pattern[1:] // ".example.com" if strings.HasSuffix(host, suffix) && len(host) > len(suffix) { return true } } } return false } // PinnedDialTransport returns an http.RoundTripper that uses the given // IP for all Dial operations regardless of host (defeats DNS rebinding). // The Host header is preserved from the request — TLS SNI and HTTP // Host routing continue to work, only the network connection is // pinned to the pre-validated IP. // // Why pre-validated dial vs trusting the request: between the // ResolveAndCheck call and the http.Client.Do call, a hostile DNS // server can return a different IP. Pinning the dialler ensures the // connection lands on the exact address that passed the privacy // check. func PinnedDialTransport(ip net.IP, timeout time.Duration) http.RoundTripper { dialer := &net.Dialer{Timeout: timeout} return &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { // addr is "host:port" — replace host with pinned IP. _, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) }, ResponseHeaderTimeout: timeout, TLSHandshakeTimeout: timeout, } }