dc28b63ad8
executus CI / test (push) Successful in 36s
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>
222 lines
7.9 KiB
Go
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,
|
|
}
|
|
}
|