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 <bruno.clermont@gmail.com>
This commit is contained in:
Bruno ClermontandBruno Clermont authored and GitHub committed 2026-07-06 00:38:43 +07:00
1 parent 75d89da5e7
commit db8ac12b16
2 files changed
+78 -6

No files matched your search

+31 -4
View File
@@ -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).
+47 -2
View File
@@ -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)
}
})
}