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>
169 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|