Files
goclaw/internal/http/providers_url_validate_test.go
fcy 6063d975e8 fix(http): restore ACP executable-path validation, bypass SSRF URL check (#1481)
ACP providers use api_base to store an executable command/path, not a URL.
A previous SSRF-hardening refactor incorrectly classified ACP as a local-URL
provider type, causing create/update to reject valid ACP configs with
"provider URL must use http or https scheme".

Changes:
- Remove ProviderACP from localURLProviderTypes.
- Add validateACPExecutablePath mirroring the runtime registration logic in
cmd/gateway_providers.go (allows empty, claude/codex/gemini, or absolute path;
rejects URLs and relative paths).
- Route ACP through the new validator inside validateProviderURL.
- Add unit tests for validateACPExecutablePath.
- Update existing local-type tests that assumed ACP was URL-based.
- Add create/update HTTP handler regression tests for ACP binary, empty binary,
and URL rejection.

Bug-first verification: without the fix, ACP api_base values like "gemini" or
absolute paths fail URL validation, while URLs are incorrectly accepted; with
the fix the behavior is reversed to match the intended executable-path model.

Fixes nextlevelbuilder/goclaw#1481.
2026-09-27 12:42:14 +08:00

403 lines
14 KiB
Go

package http
import (
"net"
"path/filepath"
"strings"
"testing"
)
// stubResolver returns a net.LookupHost-compatible function that maps known
// hostnames to controlled IPs. Unknown hostnames return a DNS error.
func stubResolver(m map[string][]string) func(host string) ([]string, error) {
return func(host string) ([]string, error) {
if addrs, ok := m[host]; ok {
return addrs, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
}
// saveAndRestoreGlobals saves mutable package-level vars and restores them after
// the test. Call at the start of any test that touches dnsResolverFn,
// allowPrivateProviderURLsFn, or ollamaAllowedHostsFn.
func saveAndRestoreGlobals(t *testing.T) {
t.Helper()
origResolver := dnsResolverFn
origAllow := allowPrivateProviderURLsFn
origOllamaAllowedHosts := ollamaAllowedHostsFn
t.Cleanup(func() {
dnsResolverFn = origResolver
allowPrivateProviderURLsFn = origAllow
ollamaAllowedHostsFn = origOllamaAllowedHosts
})
}
// --- Core merged test: all major cases in one table ---
func TestValidateProviderURL(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return false }
ollamaAllowedHostsFn = func() []string { return nil }
absClaudePath := filepath.Join(t.TempDir(), "claude")
dnsResolverFn = stubResolver(map[string][]string{
"api.openai.com": {"104.18.6.192"},
"legit-provider.com": {"203.0.113.50"},
"10.10.27.30.nip.io": {"10.10.27.30"},
"192.168.1.1.sslip.io": {"192.168.1.1"},
"172.16.0.5.nip.io": {"172.16.0.5"},
"169.254.169.254.nip.io": {"169.254.169.254"},
"127.0.0.1.nip.io": {"127.0.0.1"},
"rebind.attacker.com": {"10.0.0.1"},
"dual-stack.example.com": {"203.0.113.50", "10.0.0.1"},
})
tests := []struct {
name string
rawURL string
providerType string
wantErr bool
}{
// Empty URL always allowed
{"empty URL", "", "openai_compat", false},
{"empty URL ollama", "", "ollama", false},
// Public remote URLs OK
{"public HTTPS", "https://api.openai.com/v1", "openai_compat", false},
{"public HTTP", "http://legit-provider.com/v1", "openai_compat", false},
// --- Scheme check: unconditional for URL-based types ---
{"file scheme remote", "file:///etc/passwd", "openai_compat", true},
{"gopher scheme remote", "gopher://internal:25", "openai_compat", true},
{"file scheme ollama", "file:///etc/passwd", "ollama", true}, // H-1: scheme enforced even for local types
{"file scheme claude_cli", "file:///bin/bash", "claude_cli", true}, // H-1: scheme enforced for URL-like Claude CLI values
// --- Local type: allowlist-only ---
{"ollama localhost", "http://localhost:11434/v1", "ollama", false},
{"ollama 127.0.0.1", "http://127.0.0.1:11434/v1", "ollama", false},
{"ollama ::1", "http://[::1]:11434/v1", "ollama", false},
{"ollama host.docker.internal", "http://host.docker.internal:11434/v1", "ollama", false},
{"claude_cli command name", "claude", "claude_cli", false},
{"claude_cli absolute path", absClaudePath, "claude_cli", false},
// --- ACP: executable path / command, not a URL (issue #1481) ---
{"acp empty", "", "acp", false},
{"acp built-in gemini", "gemini", "acp", false},
{"acp built-in claude", "claude", "acp", false},
{"acp built-in codex", "codex", "acp", false},
{"acp URL rejected", "http://127.0.0.1:9090", "acp", true},
{"acp private URL rejected", "http://10.0.0.1:8080/v1", "acp", true},
{"acp relative rejected", "relative/acp", "acp", true},
// Local type with non-localhost hosts → blocked
{"ollama 169.254.169.254", "http://169.254.169.254/latest/meta-data/", "ollama", true},
{"ollama private IP", "http://10.0.0.5:11434/v1", "ollama", true},
{"ollama evil.com", "http://evil.attacker.tld:9999/v1", "ollama", true},
{"ollama postgres sidecar", "http://postgres:5432/v1", "ollama", true},
{"ollama link-local", "http://169.254.1.1:8080/v1", "ollama", true},
{"ollama .internal", "http://redis.internal:6379/v1", "ollama", true},
{"ollama gcp metadata", "http://metadata.google.internal/computeMetadata/v1/", "ollama", true},
// --- Remote type literal blocked IPs ---
{"remote localhost", "http://localhost:8080", "openai_compat", true},
{"remote 127.0.0.1", "http://127.0.0.1:8080", "openai_compat", true},
{"remote ::1", "http://[::1]:8080", "openai_compat", true},
{"remote 10.x", "http://10.0.0.1:8080/v1", "openai_compat", true},
{"remote 192.168.x", "http://192.168.1.100:8080/v1", "openai_compat", true},
{"remote 172.16.x", "http://172.16.0.5:8080/v1", "openai_compat", true},
{"remote 169.254.169.254", "http://169.254.169.254/latest/meta-data/", "openai_compat", true},
// --- Regression: unspecified addresses (bug missed by #974 hand-rolled checks) ---
{"0.0.0.0 unspecified", "http://0.0.0.0:8080/v1", "openai_compat", true},
{":: IPv6 unspecified", "http://[::]:8080/v1", "openai_compat", true},
// --- DNS bypass via wildcard services (nip.io, sslip.io) ---
{"nip.io 10.x", "http://10.10.27.30.nip.io:9999/v1", "openai_compat", true},
{"sslip.io 192.168.x", "http://192.168.1.1.sslip.io:8080/v1", "openai_compat", true},
{"nip.io 172.16.x", "http://172.16.0.5.nip.io:8080/v1", "openai_compat", true},
{"nip.io link-local", "http://169.254.169.254.nip.io/latest/", "openai_compat", true},
{"nip.io loopback", "http://127.0.0.1.nip.io:8080/v1", "openai_compat", true},
{"attacker rebind", "http://rebind.attacker.com:8080/v1", "openai_compat", true},
{"dual-stack any private", "http://dual-stack.example.com:8080/v1", "openai_compat", true},
// --- Internal hostname suffix ---
{".internal suffix", "http://metadata.google.internal/computeMetadata/v1/", "openai_compat", true},
{".local suffix", "http://myservice.local:8080", "openai_compat", true},
// --- Unresolvable hostname ---
{"unresolvable hostname", "http://nonexistent.invalid:8080/v1", "openai_compat", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateProviderURL(tt.rawURL, tt.providerType)
if (err != nil) != tt.wantErr {
t.Errorf("validateProviderURL(%q, %q) error = %v, wantErr %v",
tt.rawURL, tt.providerType, err, tt.wantErr)
}
})
}
}
// --- Env gate: GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS ---
func TestValidateProviderURL_AllowPrivateFlag(t *testing.T) {
saveAndRestoreGlobals(t)
dnsResolverFn = stubResolver(map[string][]string{
"my-vllm.lan": {"10.0.0.1"},
})
t.Run("private URL allowed when flag is true", func(t *testing.T) {
allowPrivateProviderURLsFn = func() bool { return true }
if err := validateProviderURL("http://my-vllm.lan:8080/v1", "openai_compat"); err != nil {
t.Errorf("expected nil with allow-private flag, got: %v", err)
}
if err := validateProviderURL("http://192.168.1.50:8080/v1", "openai_compat"); err != nil {
t.Errorf("expected nil for private IP with allow-private flag, got: %v", err)
}
if err := validateProviderURL("http://localhost:8080/v1", "openai_compat"); err != nil {
t.Errorf("expected nil for localhost with allow-private flag, got: %v", err)
}
if err := validateProviderURL("http://llm.internal/v1", "openai_compat"); err != nil {
t.Errorf("expected nil for .internal with allow-private flag, got: %v", err)
}
})
t.Run("private URL blocked when flag is false", func(t *testing.T) {
allowPrivateProviderURLsFn = func() bool { return false }
if err := validateProviderURL("http://my-vllm.lan:8080/v1", "openai_compat"); err == nil {
t.Error("expected error for private URL without allow-private flag")
}
})
t.Run("scheme still enforced even with allow-private flag", func(t *testing.T) {
allowPrivateProviderURLsFn = func() bool { return true }
err := validateProviderURL("file:///etc/passwd", "openai_compat")
if err == nil {
t.Error("expected error for file:// scheme even with allow-private flag")
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("expected scheme error, got: %v", err)
}
})
}
func TestValidateProviderURL_LocalTypesIgnoreAllowPrivateFlag(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return true }
cases := []struct {
url string
providerType string
}{
{"http://ollama:11434/v1", "ollama"},
{"http://host.lan:11434/v1", "ollama"},
{"http://10.0.0.5:11434/v1", "ollama"},
}
for _, c := range cases {
if err := validateProviderURL(c.url, c.providerType); err == nil {
t.Errorf("expected local provider URL %s / %s to remain blocked despite allow-private flag", c.url, c.providerType)
}
}
}
// --- H-1: scheme enforced for local provider types (from PR #972) ---
func TestValidateProviderURL_LocalTypeSchemeEnforced(t *testing.T) {
saveAndRestoreGlobals(t)
cases := []struct {
url string
providerType string
}{
{"file:///etc/passwd", "ollama"},
{"gopher://localhost:25", "ollama"},
}
for _, c := range cases {
err := validateProviderURL(c.url, c.providerType)
if err == nil {
t.Errorf("expected scheme error for %s / %s, got nil", c.url, c.providerType)
continue
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("expected scheme error for %s / %s, got: %v", c.url, c.providerType, err)
}
}
}
// --- Regression: 0.0.0.0 and :: unspecified (bug PR #974 missed) ---
func TestValidateProviderURL_UnspecifiedBlocked(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return false }
cases := []string{
"http://0.0.0.0:8080/v1",
"http://[::]:8080/v1",
}
for _, u := range cases {
err := validateProviderURL(u, "openai_compat")
if err == nil {
t.Errorf("expected error for unspecified address %q, got nil", u)
}
}
}
// --- DNS-rebinding: remote type resolving to private/loopback ---
func TestValidateProviderURL_DNSRebindBlocked(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return false }
privateHosts := map[string][]string{
"rebind-private.example.com": {"10.0.0.1"},
"rebind-loopback.example.com": {"127.0.0.1"},
"rebind-linklocal.example.com": {"169.254.169.254"},
}
dnsResolverFn = stubResolver(privateHosts)
for host := range privateHosts {
err := validateProviderURL("http://"+host+":8080/v1", "openai_compat")
if err == nil {
t.Errorf("expected DNS rebind to be blocked for %s, got nil", host)
}
}
}
// --- Local type: all allowed variants ---
func TestValidateProviderURL_LocalTypeAllowedHosts(t *testing.T) {
saveAndRestoreGlobals(t)
allowed := []struct {
url string
providerType string
}{
{"http://localhost:11434/v1", "ollama"},
{"http://127.0.0.1:11434/v1", "ollama"},
{"http://[::1]:11434/v1", "ollama"},
{"http://host.docker.internal:11434/v1", "ollama"},
}
for _, a := range allowed {
if err := validateProviderURL(a.url, a.providerType); err != nil {
t.Errorf("expected %s / %s to be allowed, got: %v", a.url, a.providerType, err)
}
}
}
func TestValidateProviderURL_ClaudeCLIExecutablePath(t *testing.T) {
saveAndRestoreGlobals(t)
absClaudePath := filepath.Join(t.TempDir(), "claude")
allowed := []string{
"",
"claude",
absClaudePath,
filepath.Join(t.TempDir(), "Claude Code.app", "Contents", "MacOS", "claude"),
}
for _, raw := range allowed {
if err := validateProviderURL(raw, "claude_cli"); err != nil {
t.Errorf("expected Claude CLI executable %q to be allowed, got: %v", raw, err)
}
}
blocked := []string{
"file:///usr/local/bin/claude",
"https://api.anthropic.com/v1",
"relative/claude",
"claude --dangerously-skip-permissions",
}
for _, raw := range blocked {
if err := validateProviderURL(raw, "claude_cli"); err == nil {
t.Errorf("expected Claude CLI executable %q to be rejected", raw)
}
}
}
func TestValidateProviderURL_ACPExecutablePath(t *testing.T) {
saveAndRestoreGlobals(t)
absBinary := filepath.Join(t.TempDir(), "gemini")
allowed := []string{
"",
"claude",
"codex",
"gemini",
absBinary,
filepath.Join(t.TempDir(), "Gemini.app", "Contents", "MacOS", "gemini"),
}
for _, raw := range allowed {
if err := validateProviderURL(raw, "acp"); err != nil {
t.Errorf("expected ACP executable %q to be allowed, got: %v", raw, err)
}
}
blocked := []string{
"file:///usr/local/bin/gemini",
"https://api.anthropic.com/v1",
"relative/gemini",
"gemini --dangerous-flag",
"http://localhost:9090",
}
for _, raw := range blocked {
if err := validateProviderURL(raw, "acp"); err == nil {
t.Errorf("expected ACP executable %q to be rejected", raw)
}
}
}
// --- Public remote URL always OK ---
func TestValidateProviderURL_PublicHostOK(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return false }
dnsResolverFn = stubResolver(map[string][]string{
"api.openai.com": {"104.18.6.192"},
})
if err := validateProviderURL("https://api.openai.com/v1", "openai_compat"); err != nil {
t.Errorf("expected public host to pass, got: %v", err)
}
}
// --- GOCLAW_OLLAMA_ALLOWED_HOSTS opt-in for local provider types ---
func TestValidateProviderURL_OllamaAllowedHosts(t *testing.T) {
saveAndRestoreGlobals(t)
allowPrivateProviderURLsFn = func() bool { return false }
t.Run("rejected when not configured", func(t *testing.T) {
ollamaAllowedHostsFn = func() []string { return nil }
if err := validateProviderURL("http://192.168.3.31:11434/v1", "ollama"); err == nil {
t.Error("expected LAN host to be rejected when GOCLAW_OLLAMA_ALLOWED_HOSTS is not configured")
}
})
t.Run("accepted when present in configured list", func(t *testing.T) {
ollamaAllowedHostsFn = func() []string { return []string{"192.168.3.31", "ollama.lan"} }
if err := validateProviderURL("http://192.168.3.31:11434/v1", "ollama"); err != nil {
t.Errorf("expected configured LAN IP to be allowed, got: %v", err)
}
// Case-insensitive host match, matching the existing style in this file.
if err := validateProviderURL("http://OLLAMA.LAN:11434/v1", "ollama"); err != nil {
t.Errorf("expected case-insensitive host match to be allowed, got: %v", err)
}
})
t.Run("rejected when host not in configured list", func(t *testing.T) {
ollamaAllowedHostsFn = func() []string { return []string{"192.168.3.31"} }
if err := validateProviderURL("http://10.0.0.9:11434/v1", "ollama"); err == nil {
t.Error("expected host outside the configured list to be rejected")
}
})
t.Run("default localhost behavior unchanged when list configured", func(t *testing.T) {
ollamaAllowedHostsFn = func() []string { return []string{"192.168.3.31"} }
if err := validateProviderURL("http://localhost:11434/v1", "ollama"); err != nil {
t.Errorf("expected localhost to still be allowed, got: %v", err)
}
})
}