Files
executus/tool/ssrf_protect.go
T
steve dc28b63ad8
executus CI / test (push) Successful in 36s
P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
The tool registry core (registry, permission model, Invocation, gated-tool
wrapper, ssrf guard, hmac, encryption, argcoerce, helpers, rootrun,
session_tools, webhook_rate_limit) had zero mort coupling — it imports only
majordomo/llm + x/crypto/hkdf — so it moves verbatim with a package rename
(skilltools -> tool). All same-package tests came along and pass; the SSRF,
gated-wrapper, encryption and output-pattern invariants are re-anchored here.

majordomo re-enters the module graph (now pinned to the latest, incl. the
front-loaded-output fix). model/ + llmmeta + structured follow next.

Docs: CLAUDE.md now requires README/examples to stay in sync with changes in
the same commit; CI skips docs/example-only pushes via paths-ignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:31:47 -04:00

222 lines
7.9 KiB
Go

// 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,
}
}