Files
hermes-agent/tests/hermes_cli/test_urllib_security.py
ethernet 612d542281 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
2026-09-14 15:38:34 -04:00

476 lines
15 KiB
Python

"""Wire-level tests for credential-safe stdlib urllib redirects."""
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ssl
from threading import Thread
import urllib.error
import urllib.request
import pytest
from hermes_cli.urllib_security import (
SafeCredentialRedirectHandler,
open_credentialed_url,
url_origin,
)
class _Response:
def __init__(self, payload: bytes = b"{}") -> None:
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self) -> bytes:
return self._payload
class _RecordingHandler(BaseHTTPRequestHandler):
redirect_to = ""
redirect_status = 302
requests: list[tuple[str, dict[str, str]]] = []
def _record(self) -> None:
type(self).requests.append((
self.command,
{name.lower(): value for name, value in self.headers.items()},
))
def do_GET(self):
if self.path.startswith("/redirect"):
self.send_response(type(self).redirect_status)
self.send_header("Location", type(self).redirect_to)
self.end_headers()
return
self._record()
body = json.dumps({"data": []}).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
if self.path == "/redirect":
self.send_response(type(self).redirect_status)
self.send_header("Location", type(self).redirect_to)
self.end_headers()
return
self._record()
self.send_response(200)
self.end_headers()
def log_message(self, _format, *_args):
pass
def _server():
server = ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler)
Thread(target=server.serve_forever, daemon=True).start()
return server
def _credential_headers() -> dict[str, str]:
return {
"Authorization": "Bearer secret",
"Cookie": "session=secret",
"CF-Access-Client-Secret": "cloudflare-secret",
"X-Custom-Auth": "tenant-secret",
"Accept": "application/json",
"User-Agent": "hermes-test",
}
def test_cross_host_redirect_drops_arbitrary_credentials_on_wire():
source = _server()
sink = _server()
_RecordingHandler.requests = []
_RecordingHandler.redirect_status = 302
_RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink"
try:
request = urllib.request.Request(
f"http://127.0.0.1:{source.server_port}/redirect",
headers=_credential_headers(),
)
with open_credentialed_url(request, timeout=3) as response:
response.read()
finally:
source.shutdown()
sink.shutdown()
method, headers = _RecordingHandler.requests[-1]
assert method == "GET"
assert headers["accept"] == "application/json"
assert headers["user-agent"] == "hermes-test"
for name in (
"authorization",
"cookie",
"cf-access-client-secret",
"x-custom-auth",
):
assert name not in headers
def test_same_host_different_port_drops_credentials_on_wire():
source = _server()
sink = _server()
_RecordingHandler.requests = []
_RecordingHandler.redirect_status = 302
_RecordingHandler.redirect_to = f"http://127.0.0.1:{sink.server_port}/sink"
try:
request = urllib.request.Request(
f"http://127.0.0.1:{source.server_port}/redirect",
headers=_credential_headers(),
)
with open_credentialed_url(request, timeout=3) as response:
response.read()
finally:
source.shutdown()
sink.shutdown()
_, headers = _RecordingHandler.requests[-1]
assert "authorization" not in headers
assert "cf-access-client-secret" not in headers
def test_post_307_remains_rejected_by_urllib():
request = urllib.request.Request(
"https://models.example.test/load",
data=b"{}",
headers=_credential_headers(),
method="POST",
)
handler = SafeCredentialRedirectHandler(request.full_url)
with pytest.raises(urllib.error.HTTPError):
handler.redirect_request(
request,
None,
307,
"Temporary Redirect",
{},
"https://other.example.test/load",
)
def test_explicit_opener_factory_is_instrumentable_without_security_bypass():
calls = []
class _Opener:
def open(self, request, *, timeout):
calls.append((request.full_url, timeout))
return _Response()
def factory(*handlers):
assert any(isinstance(h, SafeCredentialRedirectHandler) for h in handlers)
return _Opener()
request = urllib.request.Request(
"https://models.example.test/models", headers={"Authorization": "secret"}
)
with open_credentialed_url(request, timeout=7, opener_factory=factory):
pass
assert calls == [("https://models.example.test/models", 7)]
def test_installed_request_processor_cannot_resurrect_cross_origin_secret(
monkeypatch,
):
source = _server()
sink = _server()
_RecordingHandler.requests = []
_RecordingHandler.redirect_status = 302
_RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink"
class SecretProcessor(urllib.request.BaseHandler):
handler_order = float("inf") # type: ignore[assignment]
def http_request(self, request):
request.add_header("X-Installed-Secret", "must-not-cross")
return request
installed = urllib.request.build_opener(SecretProcessor())
installed.addheaders = [("X-Opener-Secret", "also-must-not-cross")]
monkeypatch.setattr(urllib.request, "_opener", installed)
try:
request = urllib.request.Request(
f"http://127.0.0.1:{source.server_port}/redirect",
headers={"Authorization": "Bearer secret"},
)
with open_credentialed_url(request, timeout=3) as response:
response.read()
finally:
source.shutdown()
sink.shutdown()
_, headers = _RecordingHandler.requests[-1]
assert "authorization" not in headers
assert "x-installed-secret" not in headers
assert "x-opener-secret" not in headers
def test_multihop_redirects_never_resurrect_credentials():
request = urllib.request.Request(
"https://a.example.test/models", headers=_credential_headers()
)
handler = SafeCredentialRedirectHandler(request.full_url)
same_origin = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://a.example.test/step-two",
)
assert same_origin is not None
same_headers = {name.lower(): value for name, value in same_origin.header_items()}
assert "authorization" in same_headers
cross_origin = handler.redirect_request(
same_origin,
None,
302,
"Found",
{},
"https://b.example.test/step-three",
)
assert cross_origin is not None
cross_headers = {name.lower(): value for name, value in cross_origin.header_items()}
assert "authorization" not in cross_headers
assert "cf-access-client-secret" not in cross_headers
returned = handler.redirect_request(
cross_origin,
None,
302,
"Found",
{},
"https://a.example.test/final",
)
assert returned is not None
returned_headers = {name.lower(): value for name, value in returned.header_items()}
assert "authorization" not in returned_headers
assert "cf-access-client-secret" not in returned_headers
def test_probe_api_models_drops_custom_credentials_on_wire():
from hermes_cli.models import probe_api_models
source = _server()
sink = _server()
_RecordingHandler.requests = []
_RecordingHandler.redirect_status = 302
_RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink"
try:
result = probe_api_models(
"provider-key",
f"http://127.0.0.1:{source.server_port}/redirect/..",
timeout=3,
request_headers={
"CF-Access-Client-Secret": "cloudflare-secret",
"X-Custom-Auth": "tenant-secret",
},
)
finally:
source.shutdown()
sink.shutdown()
assert result["models"] == []
_, headers = _RecordingHandler.requests[-1]
assert "authorization" not in headers
assert "cf-access-client-secret" not in headers
assert "x-custom-auth" not in headers
class _LmStudioSourceHandler(BaseHTTPRequestHandler):
redirect_to = ""
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
self.send_response(302)
self.send_header("Location", type(self).redirect_to)
self.end_headers()
def log_message(self, format, *_args):
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.startswith("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_azure_catalog_probe_drops_api_key_and_bearer_on_redirect():
from hermes_cli import azure_detect
source = _server()
sink = _server()
_RecordingHandler.requests = []
_RecordingHandler.redirect_status = 302
_RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink"
try:
status, body = azure_detect._http_get_json(
f"http://127.0.0.1:{source.server_port}/redirect", "azure-secret", timeout=3
)
finally:
source.shutdown()
sink.shutdown()
assert status == 200
assert body == {"data": []}
_, headers = _RecordingHandler.requests[-1]
assert "authorization" not in headers
assert "api-key" not in headers
def test_azure_anthropic_probe_drops_api_key_and_bearer_on_redirect():
from hermes_cli import azure_detect
sink = _server()
source = ThreadingHTTPServer(("127.0.0.1", 0), _LmStudioSourceHandler)
Thread(target=source.serve_forever, daemon=True).start()
_RecordingHandler.requests = []
_LmStudioSourceHandler.redirect_to = f"http://localhost:{sink.server_port}/sink"
try:
azure_detect._probe_anthropic_messages(
f"http://127.0.0.1:{source.server_port}", "azure-secret"
)
finally:
source.shutdown()
sink.shutdown()
_, headers = _RecordingHandler.requests[-1]
assert "authorization" not in headers
assert "api-key" not in headers
def _clear_ca_bundle_env(monkeypatch) -> None:
for name in (
"HERMES_CA_BUNDLE",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
):
monkeypatch.delenv(name, raising=False)
def test_hermes_owned_opener_uses_resolved_https_context(monkeypatch):
import hermes_cli.urllib_security as urllib_security
context = ssl.create_default_context()
monkeypatch.setattr(urllib.request, "_opener", None)
monkeypatch.setattr(urllib_security, "_resolved_https_context", lambda: context)
opener = urllib_security._secure_opener_from_installed_policy(
"https://models.example.test/catalog"
)
https_handlers = [
handler
for handler in opener.handlers
if isinstance(handler, urllib.request.HTTPSHandler)
]
assert len(https_handlers) == 1
assert getattr(https_handlers[0], "_context", None) is context
def test_resolved_https_context_defers_to_the_platform_store(monkeypatch, tmp_path):
"""Hermes-owned urllib openers verify against the OS certificate store.
None means "urllib's default context", which — with truststore installed
process-wide — IS the platform verifier. There is no CA-bundle ladder
here any more: a stale or bogus env var must not steer or break trust,
which is precisely what the removed env/certifi ladder used to do.
"""
import hermes_cli.urllib_security as urllib_security
assert urllib_security._resolved_https_context() is None
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
monkeypatch.setenv(var, str(tmp_path / "nope.pem"))
assert urllib_security._resolved_https_context() is None
def test_resolved_https_context_installs_the_platform_verifier():
"""Resolving trust for a Hermes opener must put truststore in force.
A stdlib urllib request is the call path certifi never covered (the
llama.cpp engine download among them), so the install has to happen here
and not only on the httpx side.
"""
import ssl
import hermes_cli.urllib_security as urllib_security
urllib_security._resolved_https_context()
assert ssl.SSLContext.__module__.startswith("truststore")
def test_installed_https_context_is_preserved(monkeypatch):
import hermes_cli.urllib_security as urllib_security
context = ssl.create_default_context()
installed = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context)
)
monkeypatch.setattr(urllib.request, "_opener", installed)
def unexpected_context_resolution():
raise AssertionError("installed TLS policy must remain authoritative")
monkeypatch.setattr(
urllib_security,
"_resolved_https_context",
unexpected_context_resolution,
)
opener = urllib_security._secure_opener_from_installed_policy(
"https://models.example.test/catalog"
)
https_handlers = [
handler
for handler in opener.handlers
if isinstance(handler, urllib.request.HTTPSHandler)
]
assert len(https_handlers) == 1
assert getattr(https_handlers[0], "_context", None) is context