From a3791d45b74d0cedce7ca32917bc9d39f8838a27 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:21:20 +0530 Subject: [PATCH] fix(local_ai): reject ollama.com website as Ollama base URL (#3991, TAURI-RUST-A3T) (#3992) --- src/openhuman/inference/local/ollama.rs | 146 +++++++++++++++++- .../local/service/ollama_admin/diagnostics.rs | 11 +- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index 721789dcb..799c6645e 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -4,6 +4,44 @@ use serde::{Deserialize, Serialize}; pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434"; +/// Hosts that serve the public Ollama website / model registry — never a local +/// or self-hosted Ollama API server. Pointing the base URL at one makes every +/// `/api/tags` poll hit ollama.com (HTTP 429 from its rate limiter), floods +/// Sentry with `list_models: non-success response`, and sends pointless traffic +/// to Ollama Inc. (TAURI-RUST-A3T). +/// +/// Matched **exactly** (no suffix match) so a user's own self-hosted server at +/// e.g. `ollama.mycompany.com` is unaffected. +fn is_ollama_registry_host(host: &str) -> bool { + matches!( + host.trim() + .trim_end_matches('.') + .to_ascii_lowercase() + .as_str(), + "ollama.com" | "www.ollama.com" | "ollama.ai" | "www.ollama.ai" | "registry.ollama.ai" + ) +} + +/// Returns `url` unless its host is a public Ollama registry host +/// ([`is_ollama_registry_host`]), in which case it logs a warning and falls back +/// to [`DEFAULT_OLLAMA_BASE_URL`]. This heals stale config/env values that +/// already target ollama.com (set before validation rejected it), so the +/// diagnostics poller stops hitting the website on the next launch. +fn reject_registry_host_or_default(url: String) -> String { + let is_registry = reqwest::Url::parse(&url) + .ok() + .and_then(|u| u.host_str().map(is_ollama_registry_host)) + .unwrap_or(false); + if is_registry { + log::warn!( + "[local_ai] ignoring Ollama base URL {} (public website/registry, not a local server) — falling back to {DEFAULT_OLLAMA_BASE_URL}", + redact_ollama_base_url(&url) + ); + return DEFAULT_OLLAMA_BASE_URL.to_string(); + } + url +} + /// Rewrite unspecified bind addresses (`0.0.0.0`, `[::]`) to their loopback /// equivalents (`127.0.0.1`, `[::1]`). Ollama's default `OLLAMA_HOST` is /// `0.0.0.0:11434` — a valid *server-side* bind address but an invalid @@ -46,7 +84,9 @@ pub(crate) fn ollama_base_url() -> String { if let Ok(url) = std::env::var("OPENHUMAN_OLLAMA_BASE_URL") { let trimmed = url.trim(); if !trimmed.is_empty() { - return normalize_unspecified_host(trimmed.trim_end_matches('/')); + return reject_registry_host_or_default(normalize_unspecified_host( + trimmed.trim_end_matches('/'), + )); } } @@ -59,7 +99,7 @@ pub(crate) fn ollama_base_url() -> String { format!("http://{trimmed}") }; log::debug!("[local_ai] ollama_base_url: using OLLAMA_HOST -> {url}"); - return normalize_unspecified_host(&url); + return reject_registry_host_or_default(normalize_unspecified_host(&url)); } } @@ -81,7 +121,7 @@ pub(crate) fn ollama_base_url_from_config(config: &crate::openhuman::config::Con if let Some(ref url) = config.local_ai.base_url { let trimmed = url.trim().trim_end_matches('/'); if !trimmed.is_empty() { - let normalized = normalize_unspecified_host(trimmed); + let normalized = reject_registry_host_or_default(normalize_unspecified_host(trimmed)); log::debug!( "[local_ai] ollama_base_url_from_config: using config base_url -> {}", redact_ollama_base_url(&normalized) @@ -121,6 +161,18 @@ pub(crate) fn validate_ollama_url(raw: &str) -> Result { return Err("URL must have a non-empty host".to_string()); } + if parsed + .host_str() + .map(is_ollama_registry_host) + .unwrap_or(false) + { + return Err( + "ollama.com is the Ollama website, not a local server. Enter your local Ollama \ + address (e.g. http://localhost:11434) or your own server's URL." + .to_string(), + ); + } + if !parsed.username().is_empty() || parsed.password().is_some() { return Err("URL must not contain credentials (user:pass@host)".to_string()); } @@ -969,6 +1021,94 @@ mod tests { ); } + // ── public Ollama registry-host rejection (TAURI-RUST-A3T) ───────── + + #[test] + fn is_ollama_registry_host_matches_public_hosts_case_insensitively() { + for host in [ + "ollama.com", + "OLLAMA.COM", + "www.ollama.com", + "ollama.ai", + "www.ollama.ai", + "registry.ollama.ai", + "ollama.com.", // trailing FQDN dot + ] { + assert!(is_ollama_registry_host(host), "expected reject: {host}"); + } + } + + #[test] + fn is_ollama_registry_host_allows_self_hosted_and_loopback() { + // No suffix match: a user's own subdomain must NOT be blocked. + for host in [ + "localhost", + "127.0.0.1", + "ollama.mycompany.com", + "my-ollama.lan", + "remote-ollama.example.com", + "notollama.com", + ] { + assert!(!is_ollama_registry_host(host), "expected allow: {host}"); + } + } + + #[test] + fn validate_ollama_url_rejects_public_registry_hosts() { + // The bug: ollama.com is the website/registry, not a local server. + // `/api/tags` against it returns HTTP 429 and floods Sentry. + for url in [ + "https://ollama.com", + "http://ollama.com", + "https://ollama.com/", + "https://www.ollama.com:443", + "https://ollama.ai", + "https://registry.ollama.ai", + ] { + let err = validate_ollama_url(url).unwrap_err(); + assert!( + err.contains("ollama.com is the Ollama website"), + "expected website rejection for {url}, got: {err}" + ); + } + } + + #[test] + fn validate_ollama_url_still_accepts_self_hosted_subdomain() { + assert_eq!( + validate_ollama_url("https://ollama.mycompany.com:11434"), + Ok("https://ollama.mycompany.com:11434".to_string()) + ); + } + + #[test] + fn ollama_base_url_from_config_heals_registry_host_to_default() { + // Existing users who saved ollama.com before validation rejected it + // must stop hitting the website on the next launch. + let _lock = test_lock(); + let _g = OllamaEnvGuard::clear(); + let config = make_config_with_base_url(Some("https://ollama.com")); + assert_eq!( + ollama_base_url_from_config(&config), + DEFAULT_OLLAMA_BASE_URL + ); + } + + #[test] + fn ollama_base_url_heals_registry_host_from_env_override() { + let _lock = test_lock(); + let _g = OllamaEnvGuard::set("https://ollama.com"); + assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL); + } + + #[test] + fn ollama_base_url_heals_registry_host_from_ollama_host() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "ollama.com"); + assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL); + } + // ── redact_ollama_base_url ──────────────────────────────────────── #[test] diff --git a/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs b/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs index cb3bbafc2..f5b82b869 100644 --- a/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs +++ b/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs @@ -262,7 +262,16 @@ impl LocalAiService { if !status.is_success() { let body = response.text().await.unwrap_or_default(); - tracing::error!( + // Defense-in-depth (TAURI-RUST-A3T): the diagnostics caller already + // tolerates this failure (degrades to empty models + surfaces the + // error to the UI as `tags_error`). A non-2xx from the configured + // endpoint is an external/server condition, not an openhuman code + // defect, so log at `warn!` (breadcrumb) instead of `error!` to + // avoid flooding Sentry on every diagnostics poll. The root-cause + // fix for the common case lives in `validate_ollama_url` / + // `reject_registry_host_or_default` (rejecting the ollama.com + // public-website host, which returns 429). + tracing::warn!( target: "local_ai::ollama_admin", %url, %status,