diff --git a/hermes_cli/urllib_security.py b/hermes_cli/urllib_security.py index d60625229c3..451fcb7f473 100644 --- a/hermes_cli/urllib_security.py +++ b/hermes_cli/urllib_security.py @@ -18,14 +18,13 @@ def url_origin(url: str) -> tuple[str, str, int | None]: """Return a normalized (scheme, hostname, effective port) origin.""" parsed = urllib.parse.urlparse(url) scheme = (parsed.scheme or "").lower() - try: - port = parsed.port - except ValueError: - port = None + # Accessing ``parsed.port`` validates malformed/non-numeric ports. Let the + # ValueError fail the request closed instead of collapsing it to a default. + port = parsed.port return ( scheme, (parsed.hostname or "").lower().rstrip("."), - port or _DEFAULT_PORTS.get(scheme), + port if port is not None else _DEFAULT_PORTS.get(scheme), ) diff --git a/plugins/model-providers/anthropic/__init__.py b/plugins/model-providers/anthropic/__init__.py index 3acf6e02fa3..2773476c937 100644 --- a/plugins/model-providers/anthropic/__init__.py +++ b/plugins/model-providers/anthropic/__init__.py @@ -4,6 +4,7 @@ import json import logging import urllib.request +from hermes_cli.urllib_security import open_credentialed_url from providers import register_provider from providers.base import ProviderProfile @@ -28,7 +29,7 @@ class AnthropicProfile(ProviderProfile): req.add_header("x-api-key", api_key) req.add_header("anthropic-version", "2023-06-01") req.add_header("Accept", "application/json") - with urllib.request.urlopen(req, timeout=timeout) as resp: + with open_credentialed_url(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) return [ m["id"] diff --git a/tests/hermes_cli/test_urllib_security.py b/tests/hermes_cli/test_urllib_security.py index c1e9667c127..f20e99e2a34 100644 --- a/tests/hermes_cli/test_urllib_security.py +++ b/tests/hermes_cli/test_urllib_security.py @@ -98,6 +98,13 @@ def test_url_origin_normalizes_default_ports_and_trailing_dot(): 443, ) assert url_origin("http://example.test") != url_origin("https://example.test") + assert url_origin("https://example.test:0") == ( + "https", + "example.test", + 0, + ) + with pytest.raises(ValueError): + url_origin("https://example.test:not-a-port") def test_cross_host_redirect_drops_arbitrary_credentials_on_wire(): @@ -337,6 +344,41 @@ class _LmStudioSourceHandler(BaseHTTPRequestHandler): pass +def test_anthropic_profile_drops_x_api_key_on_redirect(monkeypatch): + import importlib + + AnthropicProfile = importlib.import_module( + "plugins.model-providers.anthropic" + ).AnthropicProfile + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + + original_request = urllib.request.Request + + def local_anthropic_request(url, *args, **kwargs): + if url == "https://api.anthropic.com/v1/models": + url = f"http://127.0.0.1:{source.server_port}/redirect" + return original_request(url, *args, **kwargs) + + monkeypatch.setattr(urllib.request, "Request", local_anthropic_request) + try: + result = AnthropicProfile(name="anthropic").fetch_models( + api_key="anthropic-secret", timeout=3 + ) + finally: + source.shutdown() + sink.shutdown() + + assert result == [] + _, headers = _RecordingHandler.requests[-1] + assert "x-api-key" not in headers + assert headers["accept"] == "application/json" + + def test_lmstudio_load_post_drops_bearer_on_redirect(monkeypatch): from hermes_cli import models