mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
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.
403 lines
14 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|