From 34de127200331df19ee27224a3e4ee5270a42cb2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:41:55 -0700 Subject: [PATCH] fix(auth): widen portal_base_url allowlist guard to runtime credential path The salvaged PR guarded only resolve_nous_access_token; the primary resolve_nous_runtime_credentials path also POSTs the refresh token to portal_base_url on refresh with no allowlist check. Mirror the guard there so a poisoned host can't receive the bearer, and drop the stray duplicated allowlist comment. Adds a sibling-site regression test. --- hermes_cli/auth.py | 14 ++++++- tests/hermes_cli/test_auth_nous_provider.py | 44 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 93290fbf1..d467afd6c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1812,7 +1812,7 @@ def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None: ) nous["portal_base_url"] = DEFAULT_NOUS_PORTAL_URL -# Allowlist of hosts the Nous Portal proxy is willing to forward inference + # Allowlist of hosts the Nous Portal proxy is willing to forward inference # JWTs to. Sending a bearer anywhere else would leak it. # @@ -5677,6 +5677,18 @@ def resolve_nous_runtime_credentials( or os.getenv("NOUS_PORTAL_BASE_URL") or DEFAULT_NOUS_PORTAL_URL ).rstrip("/") + + # A persisted/stale portal_base_url is where the refresh token gets + # POSTed on refresh — reject any host outside the allowlist so a + # poisoned value can't exfiltrate the bearer, healing to the default. + parsed_portal_url = urlparse(portal_base_url) + if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: + logger.warning( + "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", + portal_base_url, parsed_portal_url.hostname, + ) + portal_base_url = DEFAULT_NOUS_PORTAL_URL + # Persisted value: validated network-provenance only. The stored # inference_base_url is re-validated on read so a poisoned/stale # staging host (persisted before the allowlist existed) heals to the diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index f5f4806e2..183cf82fa 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -2123,3 +2123,47 @@ class TestStalePortalBaseUrlMigration: assert token == "refreshed-access" assert len(refresh_calls) == 1 assert "localhost" in refresh_calls[0] + + def test_runtime_credentials_fallback_for_invalid_portal_url(self, tmp_path, monkeypatch): + """resolve_nous_runtime_credentials also rejects an off-allowlist portal host. + + The refresh token is POSTed to portal_base_url on refresh; a poisoned + value must never receive the bearer. This mirrors the guard on + resolve_nous_access_token so the whole class is covered, not just the + managed-gateway path. + """ + from hermes_cli import auth as auth_mod + + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _setup_nous_auth( + hermes_home, + access_token=_invoke_jwt(seconds=-60), + refresh_token="valid-refresh", + expires_at=_future_iso(-60), + expires_in=0, + ) + auth_file = hermes_home / "auth.json" + store = json.loads(auth_file.read_text()) + store["providers"]["nous"]["portal_base_url"] = "https://evil.example.com" + auth_file.write_text(json.dumps(store, indent=2)) + + refresh_calls = [] + + def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): + del client, client_id, refresh_token + refresh_calls.append(portal_base_url) + return { + "access_token": _invoke_jwt(seconds=3600), + "refresh_token": "new-refresh", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "inference:invoke", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + } + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) + + auth_mod.resolve_nous_runtime_credentials() + assert len(refresh_calls) == 1 + assert refresh_calls[0] == auth_mod.DEFAULT_NOUS_PORTAL_URL