diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index fd28f5136..62f1407cc 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -492,8 +492,12 @@ def _resolve_openrouter_runtime( else: # Custom endpoint: use api_key from config when using config base_url (#1760). # When the endpoint is Ollama Cloud, check OLLAMA_API_KEY — it's - # the canonical env var for ollama.com authentication. - _is_ollama_url = "ollama.com" in base_url.lower() + # the canonical env var for ollama.com authentication. Match on + # HOST, not substring — a custom base_url whose path contains + # "ollama.com" (e.g. http://127.0.0.1/ollama.com/v1) or whose + # hostname is a look-alike (ollama.com.attacker.test) must not + # receive the Ollama credential. See GHSA-76xc-57q6-vm5m. + _is_ollama_url = base_url_host_matches(base_url, "ollama.com") api_key_candidates = [ explicit_api_key, (cfg_api_key if use_config_base_url else ""), diff --git a/run_agent.py b/run_agent.py index 26b334a5b..ba8a2bf4e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6123,8 +6123,9 @@ class AIAgent: fb_base_url_hint = (fb.get("base_url") or "").strip() or None fb_api_key_hint = (fb.get("api_key") or "").strip() or None # For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env - # when no explicit key is in the fallback config. - if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + # when no explicit key is in the fallback config. Host match + # (not substring) — see GHSA-76xc-57q6-vm5m. + if fb_base_url_hint and base_url_host_matches(fb_base_url_hint, "ollama.com") and not fb_api_key_hint: fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None fb_client, _resolved_fb_model = resolve_provider_client( fb_provider, model=fb_model, raw_codex=True, diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index c7510a55b..9d2232f39 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1412,3 +1412,90 @@ def test_named_custom_runtime_no_model_when_absent(monkeypatch): resolved = rp.resolve_runtime_provider(requested="my-server") assert "model" not in resolved + + +# --------------------------------------------------------------------------- +# GHSA-76xc-57q6-vm5m — Ollama URL substring leak +# +# Same bug class as the previously-fixed GHSA-xf8p-v2cg-h7h5 (OpenRouter). +# _resolve_openrouter_runtime's custom-endpoint branch selects OLLAMA_API_KEY +# when the base_url "looks like" ollama.com. Previous implementation used +# raw substring match; a custom base_url whose PATH or look-alike host +# merely contained "ollama.com" leaked OLLAMA_API_KEY to that endpoint. +# Fix: use base_url_host_matches (same helper as the OpenRouter sweep). +# --------------------------------------------------------------------------- + +class TestOllamaUrlSubstringLeak: + """Call-site regression tests for the fix in _resolve_openrouter_runtime.""" + + def _make_cfg(self, base_url): + return {"base_url": base_url, "api_key": "", "provider": "custom"} + + def test_ollama_key_not_leaked_to_path_injection(self, monkeypatch): + """http://127.0.0.1:9000/ollama.com/v1 — attacker endpoint with + ollama.com in PATH. Must resolve to OPENAI_API_KEY, not OLLAMA_API_KEY.""" + monkeypatch.setenv("OPENAI_API_KEY", "oa-secret") + monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret") + monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg( + "http://127.0.0.1:9000/ollama.com/v1" + )) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert "ol-SECRET" not in resolved["api_key"], ( + "OLLAMA_API_KEY must not be sent to an endpoint whose " + "hostname is not ollama.com (GHSA-76xc-57q6-vm5m)" + ) + assert resolved["api_key"] == "oa-secret" + + def test_ollama_key_not_leaked_to_lookalike_host(self, monkeypatch): + """ollama.com.attacker.test — look-alike host. OLLAMA_API_KEY + must not be sent.""" + monkeypatch.setenv("OPENAI_API_KEY", "oa-secret") + monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg( + "http://ollama.com.attacker.test:9000/v1" + )) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert "ol-SECRET" not in resolved["api_key"] + assert resolved["api_key"] == "oa-secret" + + def test_ollama_key_sent_to_genuine_ollama_com(self, monkeypatch): + """https://ollama.com/v1 — legit Ollama Cloud. OLLAMA_API_KEY + should be used.""" + monkeypatch.setenv("OPENAI_API_KEY", "oa-secret") + monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg( + "https://ollama.com/v1" + )) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["api_key"] == "ol-legit-key" + + def test_ollama_key_sent_to_ollama_subdomain(self, monkeypatch): + """https://api.ollama.com/v1 — legit subdomain.""" + monkeypatch.setenv("OPENAI_API_KEY", "oa-secret") + monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg( + "https://api.ollama.com/v1" + )) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["api_key"] == "ol-legit-key" diff --git a/tests/test_base_url_hostname.py b/tests/test_base_url_hostname.py index 54aca08c0..cdf8450a2 100644 --- a/tests/test_base_url_hostname.py +++ b/tests/test_base_url_hostname.py @@ -106,3 +106,55 @@ class TestBaseUrlHostMatchesEdgeCases: def test_trailing_dot_on_domain_stripped(self): assert base_url_host_matches("https://openrouter.ai/v1", "openrouter.ai.") is True + + +class TestOllamaUrlHostCheck: + """GHSA-76xc-57q6-vm5m — ollama.com was using a raw substring match for + credential selection (same bug class as GHSA-xf8p-v2cg-h7h5 for OpenRouter). + These tests lock in that the base_url_host_matches fix correctly rejects + the same attack vectors for Ollama. + """ + + def test_ollama_com_path_injection_rejected(self): + """http://evil.test/ollama.com/v1 — ollama.com appears in the path, + not the host. Must not be treated as Ollama Cloud.""" + assert base_url_host_matches( + "http://127.0.0.1:9000/ollama.com/v1", "ollama.com" + ) is False + + def test_ollama_com_subdomain_lookalike_rejected(self): + """ollama.com.attacker.test is a separate host, not ollama.com.""" + assert base_url_host_matches( + "http://ollama.com.attacker.test:9000/v1", "ollama.com" + ) is False + + def test_ollama_com_localtest_me_rejected(self): + """ollama.com.localtest.me resolves to 127.0.0.1 via localtest.me + but its true hostname is localtest.me, not ollama.com.""" + assert base_url_host_matches( + "http://ollama.com.localtest.me:9000/v1", "ollama.com" + ) is False + + def test_ollama_ai_is_not_ollama_com(self): + """Different TLD. ollama.ai is not ollama.com.""" + assert base_url_host_matches( + "https://ollama.ai/v1", "ollama.com" + ) is False + + def test_localhost_ollama_port_is_not_ollama_com(self): + """http://localhost:11434/v1 is a local Ollama install, but its + hostname is localhost, so OLLAMA_API_KEY (an ollama.com-only secret) + must not be sent.""" + assert base_url_host_matches( + "http://localhost:11434/v1", "ollama.com" + ) is False + + def test_genuine_ollama_com_matches(self): + assert base_url_host_matches( + "https://ollama.com/api/generate", "ollama.com" + ) is True + + def test_ollama_com_subdomain_matches(self): + assert base_url_host_matches( + "https://api.ollama.com/v1", "ollama.com" + ) is True