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:
parent
f3c5327e67
commit
34de127200
2 changed files with 57 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue