mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
security(runtime_provider): close OLLAMA_API_KEY substring-leak sweep miss (#13522)
Two call sites still used a raw substring check to identify ollama.com:
hermes_cli/runtime_provider.py:496:
_is_ollama_url = "ollama.com" in base_url.lower()
run_agent.py:6127:
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() ...
Same bug class as GHSA-xf8p-v2cg-h7h5 (OpenRouter substring leak), which
was fixed in commit dbb7e00e via base_url_host_matches() across the
codebase. The earlier sweep missed these two Ollama sites. Self-discovered
during April 2026 security-advisory triage; filed as GHSA-76xc-57q6-vm5m.
Impact is narrow — requires a user with OLLAMA_API_KEY configured AND a
custom base_url whose path or look-alike host contains 'ollama.com'.
Users on default provider flows are unaffected. Filed as a draft advisory
to use the private-fork flow; not CVE-worthy on its own.
Fix is mechanical: replace substring check with base_url_host_matches
at both sites. Same helper the rest of the codebase uses.
Tests: 67 -> 71 passing. 7 new host-matcher cases in
tests/test_base_url_hostname.py (path injection, lookalike host,
localtest.me subdomain, ollama.ai TLD confusion, localhost, genuine
ollama.com, api.ollama.com subdomain) + 4 call-site tests in
tests/hermes_cli/test_runtime_provider_resolution.py verifying
OLLAMA_API_KEY is selected only when base_url actually targets
ollama.com.
Fixes GHSA-76xc-57q6-vm5m
This commit is contained in:
parent
4cc5065f63
commit
7fc1e91811
4 changed files with 148 additions and 4 deletions
|
|
@ -492,8 +492,12 @@ def _resolve_openrouter_runtime(
|
||||||
else:
|
else:
|
||||||
# Custom endpoint: use api_key from config when using config base_url (#1760).
|
# 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
|
# When the endpoint is Ollama Cloud, check OLLAMA_API_KEY — it's
|
||||||
# the canonical env var for ollama.com authentication.
|
# the canonical env var for ollama.com authentication. Match on
|
||||||
_is_ollama_url = "ollama.com" in base_url.lower()
|
# 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 = [
|
api_key_candidates = [
|
||||||
explicit_api_key,
|
explicit_api_key,
|
||||||
(cfg_api_key if use_config_base_url else ""),
|
(cfg_api_key if use_config_base_url else ""),
|
||||||
|
|
|
||||||
|
|
@ -6123,8 +6123,9 @@ class AIAgent:
|
||||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||||
fb_api_key_hint = (fb.get("api_key") 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
|
# For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env
|
||||||
# when no explicit key is in the fallback config.
|
# when no explicit key is in the fallback config. Host match
|
||||||
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint:
|
# (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_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
|
||||||
fb_client, _resolved_fb_model = resolve_provider_client(
|
fb_client, _resolved_fb_model = resolve_provider_client(
|
||||||
fb_provider, model=fb_model, raw_codex=True,
|
fb_provider, model=fb_model, raw_codex=True,
|
||||||
|
|
|
||||||
|
|
@ -1412,3 +1412,90 @@ def test_named_custom_runtime_no_model_when_absent(monkeypatch):
|
||||||
|
|
||||||
resolved = rp.resolve_runtime_provider(requested="my-server")
|
resolved = rp.resolve_runtime_provider(requested="my-server")
|
||||||
assert "model" not in resolved
|
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"
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,55 @@ class TestBaseUrlHostMatchesEdgeCases:
|
||||||
|
|
||||||
def test_trailing_dot_on_domain_stripped(self):
|
def test_trailing_dot_on_domain_stripped(self):
|
||||||
assert base_url_host_matches("https://openrouter.ai/v1", "openrouter.ai.") is True
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue