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:
Teknium 2026-04-21 06:06:16 -07:00 committed by GitHub
parent 4cc5065f63
commit 7fc1e91811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 148 additions and 4 deletions

View file

@ -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 ""),

View file

@ -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,

View file

@ -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"

View file

@ -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