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.
This commit is contained in:
teknium1 2026-07-01 04:41:55 -07:00 committed by Teknium
parent f3c5327e67
commit 34de127200
2 changed files with 57 additions and 1 deletions

View file

@ -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

View file

@ -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