mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 10:14:49 +00:00
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:
1 parent
75d89da5e7
commit
db8ac12b16
2 files changed
+78
-6
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user