diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 59daa2c5d40..c9752cd97a4 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -41,7 +41,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple from urllib.parse import parse_qs, urlencode, urlparse import httpx @@ -1559,6 +1559,67 @@ def _optional_base_url(value: Any) -> Optional[str]: return cleaned if cleaned else None +# Allowlist of hosts the Nous Portal proxy is willing to forward minted +# bearer tokens to. The bearer is a long-lived agent_key minted by +# portal.nousresearch.com — sending it anywhere else would leak it. +# +# This is consulted only for URLs coming from the NETWORK side (Portal +# refresh / agent-key-mint responses). User-controlled env-var overrides +# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented +# dev/staging escape hatch and the env source is already trusted (the +# user set it themselves). +_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ + "inference-api.nousresearch.com", +}) + + +def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: + """Validate a Portal-returned inference URL against the host allowlist. + + Returns ``url`` (normalised by stripping trailing slashes) if it's a + well-formed ``https:///...`` URL. Returns ``None`` + if the URL is missing, malformed, non-https, or points at an + unexpected host — letting the caller fall back to the configured + default rather than persist or forward a poisoned value. + + Defense-in-depth: a compromised refresh / mint response from the + Portal API (MITM, malicious response injection) could otherwise + redirect every subsequent proxy request — bearing the user's + legitimately-minted agent_key — to an attacker-controlled endpoint. + Validating scheme + host at the source closes that loop before the + poisoned URL ever lands in ``auth.json``. + + The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses + this — env values come from the trusted OS user, not from the + network, and the override is documented for staging/dev use. + + Co-authored-by: memosr + """ + if not isinstance(url, str): + return None + cleaned = url.strip() + if not cleaned: + return None + try: + parsed = urlparse(cleaned) + except Exception: + return None + if parsed.scheme != "https": + logger.warning( + "nous: refusing non-https inference URL scheme %r from Portal response", + parsed.scheme, + ) + return None + if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS: + logger.warning( + "nous: refusing inference URL host %r from Portal response " + "(not in allowlist); falling back to default", + parsed.hostname, + ) + return None + return cleaned.rstrip("/") + + def _decode_jwt_claims(token: Any) -> Dict[str, Any]: if not isinstance(token, str) or token.count(".") != 2: return {} diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 9fb07a9c053..e85d2100404 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -27,6 +27,7 @@ from hermes_cli.auth import ( _quarantine_nous_oauth_state, _quarantine_nous_pool_entries, _save_auth_store, + _validate_nous_inference_url_from_network, _write_shared_nous_state, resolve_nous_runtime_credentials, ) @@ -137,7 +138,10 @@ class NousPortalAdapter(UpstreamAdapter): "Try `hermes login nous` to re-authenticate." ) - base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL + base_url = ( + _validate_nous_inference_url_from_network(refreshed.get("base_url")) + or DEFAULT_NOUS_INFERENCE_URL + ) base_url = base_url.rstrip("/") return UpstreamCredential(