hermes-agent/tests/test_base_url_hostname.py
Teknium 7fc1e91811
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
2026-04-21 06:06:16 -07:00

160 lines
6.8 KiB
Python

"""Targeted tests for ``utils.base_url_hostname`` and ``base_url_host_matches``.
These helpers are used across provider routing, auxiliary client, setup
wizards, billing routes, and the trajectory compressor to avoid the
substring-match false-positive class documented in
tests/agent/test_direct_provider_url_detection.py.
"""
from __future__ import annotations
from utils import base_url_hostname, base_url_host_matches
# ─── base_url_hostname ────────────────────────────────────────────────────
def test_empty_returns_empty_string():
assert base_url_hostname("") == ""
assert base_url_hostname(None) == "" # type: ignore[arg-type]
def test_plain_host_without_scheme():
assert base_url_hostname("api.openai.com") == "api.openai.com"
assert base_url_hostname("api.openai.com/v1") == "api.openai.com"
def test_https_url_extracts_hostname_only():
assert base_url_hostname("https://api.openai.com/v1") == "api.openai.com"
assert base_url_hostname("https://api.x.ai/v1") == "api.x.ai"
assert base_url_hostname("https://api.anthropic.com") == "api.anthropic.com"
def test_hostname_case_insensitive():
assert base_url_hostname("https://API.OpenAI.com/v1") == "api.openai.com"
def test_trailing_dot_stripped():
assert base_url_hostname("https://api.openai.com./v1") == "api.openai.com"
def test_path_containing_provider_host_is_not_the_hostname():
assert base_url_hostname("https://proxy.example.test/api.openai.com/v1") == "proxy.example.test"
assert base_url_hostname("https://proxy.example.test/api.anthropic.com/v1") == "proxy.example.test"
def test_host_suffix_is_not_the_provider():
assert base_url_hostname("https://api.openai.com.example/v1") == "api.openai.com.example"
assert base_url_hostname("https://api.x.ai.example/v1") == "api.x.ai.example"
def test_port_is_ignored():
assert base_url_hostname("https://api.openai.com:443/v1") == "api.openai.com"
def test_whitespace_stripped():
assert base_url_hostname(" https://api.openai.com/v1 ") == "api.openai.com"
# ─── base_url_host_matches ────────────────────────────────────────────────
class TestBaseUrlHostMatchesExact:
def test_exact_domain_matches(self):
assert base_url_host_matches("https://openrouter.ai/api/v1", "openrouter.ai") is True
assert base_url_host_matches("https://moonshot.ai", "moonshot.ai") is True
def test_subdomain_matches(self):
# A subdomain of the registered domain should match — needed for
# api.moonshot.ai / api.kimi.com / portal.qwen.ai lookups that
# accept both the bare registrable domain and any subdomain under it.
assert base_url_host_matches("https://api.moonshot.ai/v1", "moonshot.ai") is True
assert base_url_host_matches("https://api.kimi.com/v1", "api.kimi.com") is True
assert base_url_host_matches("https://portal.qwen.ai/v1", "portal.qwen.ai") is True
class TestBaseUrlHostMatchesNegatives:
"""The reason this helper exists — defend against substring collisions."""
def test_path_segment_containing_domain_does_not_match(self):
assert base_url_host_matches("https://evil.test/moonshot.ai/v1", "moonshot.ai") is False
assert base_url_host_matches("https://proxy.example.test/openrouter.ai/v1", "openrouter.ai") is False
assert base_url_host_matches("https://proxy/api.kimi.com/v1", "api.kimi.com") is False
def test_host_suffix_does_not_match(self):
# Attacker-controlled hosts that end with the domain string are not
# the domain.
assert base_url_host_matches("https://moonshot.ai.evil/v1", "moonshot.ai") is False
assert base_url_host_matches("https://openrouter.ai.example/v1", "openrouter.ai") is False
def test_host_prefix_does_not_match(self):
# "fake-openrouter.ai" is not a subdomain of openrouter.ai.
assert base_url_host_matches("https://fake-openrouter.ai/v1", "openrouter.ai") is False
class TestBaseUrlHostMatchesEdgeCases:
def test_empty_base_url_returns_false(self):
assert base_url_host_matches("", "openrouter.ai") is False
assert base_url_host_matches(None, "openrouter.ai") is False # type: ignore[arg-type]
def test_empty_domain_returns_false(self):
assert base_url_host_matches("https://openrouter.ai/v1", "") is False
def test_case_insensitive(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
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