Files
executus/tool/ssrf_protect_test.go
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

169 lines
7.0 KiB
Go

package tool
import (
"context"
"net"
"strings"
"testing"
)
// TestResolveAndCheck_AllowlistedPublic anchors the happy path: a
// public domain in the allowlist resolves and returns its IP. Skips
// when DNS isn't available so the suite still passes in offline CI.
func TestResolveAndCheck_AllowlistedPublic(t *testing.T) {
// Pre-flight: skip the test if the test environment has no DNS.
if _, err := net.LookupHost("example.com"); err != nil {
t.Skipf("no DNS in test environment: %v", err)
}
allow := AllowlistConfig{Domains: []string{"example.com"}}
ip, err := ResolveAndCheck(context.Background(), "https://example.com/", allow)
if err != nil {
t.Fatalf("ResolveAndCheck failed: %v", err)
}
if ip == nil {
t.Fatal("expected non-nil IP")
}
}
// TestResolveAndCheck_NotAllowlisted ensures a domain outside the
// allowlist is rejected before any DNS resolution.
func TestResolveAndCheck_NotAllowlisted(t *testing.T) {
allow := AllowlistConfig{Domains: []string{"example.com"}}
_, err := ResolveAndCheck(context.Background(), "https://evil.test/", allow)
if err == nil {
t.Fatal("expected rejection for non-allowlisted host")
}
if !strings.Contains(err.Error(), "not in allowlist") {
t.Errorf("expected allowlist error, got: %v", err)
}
}
// TestResolveAndCheck_WildcardMatch confirms "*.example.com" matches
// foo.example.com.
func TestResolveAndCheck_WildcardMatch(t *testing.T) {
if !matchesAllowlist("foo.example.com", []string{"*.example.com"}) {
t.Error("expected *.example.com to match foo.example.com")
}
if !matchesAllowlist("a.b.example.com", []string{"*.example.com"}) {
t.Error("expected *.example.com to match a.b.example.com")
}
}
// TestResolveAndCheck_WildcardDoesNotMatchBareDomain documents the
// design choice: "*.example.com" does NOT match "example.com" itself.
// Admins who want both must list both entries.
func TestResolveAndCheck_WildcardDoesNotMatchBareDomain(t *testing.T) {
if matchesAllowlist("example.com", []string{"*.example.com"}) {
t.Error("expected *.example.com NOT to match bare example.com")
}
}
// TestResolveAndCheck_LocalhostRejected verifies that a hostname
// resolving to 127.0.0.1 is rejected unless the admin explicitly
// includes it in the allowlist.
func TestResolveAndCheck_LocalhostRejected(t *testing.T) {
if _, err := net.LookupHost("localhost"); err != nil {
t.Skipf("no DNS in test environment: %v", err)
}
// "localhost" matches the allowlist by exact name match, but the
// hostExplicitlyAllowed bypass kicks in only when the host is in
// the allowlist as an exact entry. Here we use a DIFFERENT bare
// allowlist entry so the host fails the allowlist match outright.
allow := AllowlistConfig{Domains: []string{"example.com"}}
_, err := ResolveAndCheck(context.Background(), "http://localhost/", allow)
if err == nil {
t.Fatal("expected rejection for localhost (not in allowlist)")
}
}
// TestResolveAndCheck_LocalhostAllowedExplicit confirms the
// admin-opt-in escape hatch: when the hostname is itself in the
// allowlist as an exact entry, the private-IP check is bypassed.
// This is what test code uses to drive httptest.NewServer URLs.
func TestResolveAndCheck_LocalhostAllowedExplicit(t *testing.T) {
// Use 127.0.0.1 directly so this test doesn't depend on DNS for
// "localhost".
allow := AllowlistConfig{Domains: []string{"127.0.0.1"}}
ip, err := ResolveAndCheck(context.Background(), "http://127.0.0.1/", allow)
if err != nil {
t.Fatalf("expected 127.0.0.1 with explicit allowlist to succeed; got: %v", err)
}
if !ip.Equal(net.ParseIP("127.0.0.1")) {
t.Errorf("expected ip=127.0.0.1, got %v", ip)
}
}
// TestResolveAndCheck_FileSchemeRejected blocks file:// URLs.
func TestResolveAndCheck_FileSchemeRejected(t *testing.T) {
allow := AllowlistConfig{Domains: []string{"anything"}}
_, err := ResolveAndCheck(context.Background(), "file:///etc/passwd", allow)
if err == nil {
t.Fatal("expected rejection for file:// scheme")
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("expected scheme error, got: %v", err)
}
}
// TestResolveAndCheck_EmptyHostRejected blocks malformed URLs with
// no host component.
func TestResolveAndCheck_EmptyHostRejected(t *testing.T) {
allow := AllowlistConfig{Domains: []string{"anything"}}
_, err := ResolveAndCheck(context.Background(), "http:///nohost", allow)
if err == nil {
t.Fatal("expected rejection for empty host")
}
}
// TestResolveAndCheck_PrivateIPLiteralRejected confirms that an IP
// literal resolving to the private range is rejected even if the
// allowlist matches by wildcard or other means. The private-IP gate
// is the last line of defence.
func TestResolveAndCheck_PrivateIPLiteralRejected(t *testing.T) {
// Add a wildcard that would match anything (silly but plausible
// admin error) and confirm a private IP literal is still blocked
// because the literal isn't itself in the allowlist as exact.
allow := AllowlistConfig{Domains: []string{"192.168.1.1"}}
// The exact-IP-in-allowlist case bypasses the private check; flip
// to a NEAR-but-different IP literal that's NOT in the allowlist.
allow2 := AllowlistConfig{Domains: []string{"192.168.0.0"}}
_, err := ResolveAndCheck(context.Background(), "http://192.168.1.1/", allow2)
if err == nil {
t.Fatal("expected rejection for private IP literal not in allowlist")
}
// Sanity: explicit allowlist entry bypasses.
_, err = ResolveAndCheck(context.Background(), "http://192.168.1.1/", allow)
if err != nil {
t.Errorf("expected explicit allowlist entry to bypass; got: %v", err)
}
}
// TestResolveAndCheck_CloudMetadataRejected blocks the well-known
// cloud metadata IP via the link-local check. We use a wildcard that
// matches the IP-as-hostname so the rejection comes from the
// private/link-local layer (not the allowlist).
func TestResolveAndCheck_CloudMetadataRejected(t *testing.T) {
// "*.169.254.169.254" wildcard wouldn't match either; instead use
// a wildcard that matches any IP literal under .254 — but
// matchesAllowlist treats '.' as a literal so we just allowlist
// the IP itself with a one-bit-different sibling that fails the
// exact-allow check (so private check still runs).
//
// Easier: include a different exact IP entry so the IP literal
// fails hostExplicitlyAllowed but passes the wildcard.
allow := AllowlistConfig{Domains: []string{"*.169.254.169.254"}} // matches "x.169.254.169.254", not the bare IP
// 169.254.169.254 won't match the wildcard pattern either —
// switch to a strategy that lets the host pass allowlist but
// fails the private check.
_ = allow
// Use an explicit non-IP-literal hostname (we'd need DNS to point
// to 169.254.169.254 which is not feasible). Instead, exercise
// the rejectPrivateIP helper directly for the metadata IP since
// the public surface only enters that path through resolution.
if err := rejectPrivateIP("metadata.test", net.ParseIP("169.254.169.254")); err == nil {
t.Fatal("expected rejection for cloud metadata IP")
} else if !strings.Contains(err.Error(), "metadata") {
t.Errorf("expected metadata error, got: %v", err)
}
}