# 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
476 lines
15 KiB
Python
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
|