From db8ac12b16be3933cdf8c510458c0da9a243b3b4 Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Sun, 5 Jul 2026 13:38:43 -0400 Subject: [PATCH] feat(providers): allow LAN Ollama hosts via GOCLAW_OLLAMA_ALLOWED_HOSTS (#1367) Adds an operator opt-in env var (comma-separated hostnames/IPs) to whitelist additional local hosts for ollama/acp provider-type URL validation, on top of the existing hardcoded localhost allowlist. Co-authored-by: Bruno Clermont --- internal/http/providers.go | 35 ++++++++++++-- internal/http/providers_url_validate_test.go | 49 +++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/internal/http/providers.go b/internal/http/providers.go index b35848d1..69358ebe 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -446,6 +446,27 @@ var allowPrivateProviderURLsFn = sync.OnceValue(func() bool { return v == "1" || v == "true" || v == "yes" }) +// ollamaAllowedHostsFn returns the operator-configured extra hosts permitted +// for local provider types (ollama, acp) via GOCLAW_OLLAMA_ALLOWED_HOSTS +// (comma-separated hostnames/IPs, e.g. "192.168.3.31,ollama.lan"). These are +// added on top of allowedLocalHosts, allowing LAN-hosted Ollama servers. +// Evaluated once at first call so tests can override the variable before that happens. +var ollamaAllowedHostsFn = sync.OnceValue(func() []string { + raw := os.Getenv("GOCLAW_OLLAMA_ALLOWED_HOSTS") + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + hosts := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + hosts = append(hosts, p) + } + } + return hosts +}) + // validateProviderURL rejects provider base URLs pointing to internal/private networks. // Defense-in-depth: prevents SSRF when providers are later used for API calls. // @@ -454,9 +475,10 @@ var allowPrivateProviderURLsFn = sync.OnceValue(func() bool { // 2. Claude CLI → api_base is an executable path/command, not a URL. // 3. Scheme check (http/https only) → enforced for URL-based types, including // local URL types. Blocks file://, gopher://, dict://, etc. -// 4. Local URL types (ollama, acp) → host must be in allowedLocalHosts -// (explicit allowlist prevents reaching 169.254.169.254 or internal services -// via the local-type bypass). +// 4. Local URL types (ollama, acp) → host must be in allowedLocalHosts, or in +// the operator-configured GOCLAW_OLLAMA_ALLOWED_HOSTS list (explicit +// allowlist prevents reaching 169.254.169.254 or internal services via the +// local-type bypass, while still allowing LAN-hosted Ollama servers). // 5. Remote types → if GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS is set, allow and log. // Otherwise: resolve DNS hostname; reject if ANY resolved IP satisfies // security.IsBlocked (covers loopback, link-local, private, multicast, @@ -493,8 +515,13 @@ func validateProviderURL(rawURL string, providerType string) error { return nil } } + for _, a := range ollamaAllowedHostsFn() { + if strings.EqualFold(host, a) { + return nil + } + } slog.Warn("security.provider_url.local_type_denied", "host", host, "provider_type", providerType) - return fmt.Errorf("provider type %q only allows localhost URLs (localhost, 127.0.0.1, ::1, host.docker.internal), got host %q", providerType, host) + return fmt.Errorf("provider type %q only allows localhost URLs (localhost, 127.0.0.1, ::1, host.docker.internal, or GOCLAW_OLLAMA_ALLOWED_HOSTS), got host %q", providerType, host) } // Operator opt-in to allow private-network provider URLs (e.g. LAN-hosted vLLM). diff --git a/internal/http/providers_url_validate_test.go b/internal/http/providers_url_validate_test.go index 715db697..e5fbe54a 100644 --- a/internal/http/providers_url_validate_test.go +++ b/internal/http/providers_url_validate_test.go @@ -19,15 +19,17 @@ func stubResolver(m map[string][]string) func(host string) ([]string, error) { } // saveAndRestoreGlobals saves mutable package-level vars and restores them after -// the test. Call at the start of any test that touches dnsResolverFn or -// allowPrivateProviderURLsFn. +// 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 }) } @@ -36,6 +38,7 @@ func saveAndRestoreGlobals(t *testing.T) { 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{ @@ -324,3 +327,45 @@ func TestValidateProviderURL_PublicHostOK(t *testing.T) { 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) + } + if err := validateProviderURL("http://ollama.lan:11434/v1", "acp"); err != nil { + t.Errorf("expected configured LAN hostname to be allowed for acp, 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) + } + }) +}