fix(auth): accept a non-production Portal's own inference host when the operator selected it
A token minted by a non-production Portal is meant to be spent at that environment's own inference gateway, and the Portal's refresh response names that host. The allowlist applied to Portal-returned inference URLs was production-only, so the value was refused as "not in allowlist" and healed to the production host — a token the production Portal never issued, sent to the production gateway, which 401s it. Every hosted non-production instance hit this on every gateway turn once #108319 made the deploy-wide NOUS_INFERENCE_BASE_URL invisible inside a routed profile scope (by design, #65941). The widening is keyed on the operator's trusted HERMES_PORTAL_BASE_URL override, never on the stored portal_base_url: when that override names a Portal outside the production allowlist, any https host under the Nous domain is accepted; otherwise the strict production set stands. So a poisoned auth.json cannot widen the set, a production-Portal session that finds a foreign inference URL in its state is still refused and healed, and the bearer can only ever go to a Nous-owned host. No environment is named in code. Because the override is read through the profile scope (previous commit), each multiplexed profile decides for itself. Validation: 4 invariant tests (accepted only under a non-production override; look-alike domains, dotless suffix and http still refused; stored portal alone does not widen; the decision follows the profile scope under multiplex) — the new-behaviour ones red on the previous commit. Main's existing validation tests are unchanged and green. Live receipt for the symptom and the fixed chain on a hosted instance: #111589. Based on #102863 and its rebase onto the decomposed auth_nous.py in #111589, whose portal-keyed pairing this replaces with the same behaviour and no environment literals. Co-authored-by: Ben Barclay <ben@nousresearch.com>
This commit is contained in:
+32
-1
@@ -108,6 +108,37 @@ _ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
|
||||
# Free-tier (anonymous) host: serves the single ``nous/welcome`` model.
|
||||
"welcome-api.nousresearch.com"})
|
||||
|
||||
# Every Nous inference gateway, production or not, lives under this domain. Consulted only when
|
||||
# the operator has pointed the process at a non-production Portal (see below).
|
||||
_NOUS_INFERENCE_HOST_SUFFIX = ".nousresearch.com"
|
||||
|
||||
|
||||
def _operator_selected_non_production_portal() -> bool:
|
||||
"""True when the trusted ``HERMES_PORTAL_BASE_URL`` override names a Portal outside the
|
||||
production allowlist — the operator has deliberately put this profile on another environment.
|
||||
|
||||
A token minted by that Portal is meant to be spent at that environment's own inference
|
||||
gateway, and the Portal's refresh response names it. Keyed on the operator override, never on
|
||||
the stored ``portal_base_url``, so a poisoned auth.json cannot widen the allowlist and a
|
||||
production-Portal session that finds a foreign inference URL in its state is still refused.
|
||||
"""
|
||||
override = _nous_portal_env_override()
|
||||
if not override:
|
||||
return False
|
||||
from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS
|
||||
host = urlparse(override).hostname
|
||||
return bool(host) and host not in _NOUS_PORTAL_ALLOWED_HOSTS # unparseable override: fail closed
|
||||
|
||||
|
||||
def _nous_inference_host_allowed(hostname: Optional[str]) -> bool:
|
||||
"""Production hosts always; any Nous-domain host when the operator selected another Portal."""
|
||||
if hostname in _ALLOWED_NOUS_INFERENCE_HOSTS:
|
||||
return True
|
||||
if not hostname or not hostname.endswith(_NOUS_INFERENCE_HOST_SUFFIX):
|
||||
return False
|
||||
labels = hostname.removesuffix(_NOUS_INFERENCE_HOST_SUFFIX).split(".")
|
||||
return all(labels) and _operator_selected_non_production_portal()
|
||||
|
||||
|
||||
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
|
||||
"""Validate a Portal-returned inference URL against the host allowlist.
|
||||
@@ -126,7 +157,7 @@ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[st
|
||||
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:
|
||||
if not _nous_inference_host_allowed(parsed.hostname):
|
||||
logger.warning(
|
||||
"nous: refusing inference URL host %r from Portal response "
|
||||
"(not in allowlist); falling back to default",
|
||||
|
||||
@@ -45,3 +45,77 @@ def test_portal_env_override_is_read_through_the_profile_scope(monkeypatch):
|
||||
ss.reset_secret_scope(token)
|
||||
finally:
|
||||
ss.set_multiplex_active(False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"portal_override, url, expected",
|
||||
[
|
||||
# The bug: the operator's non-production Portal returns its own inference host; it survives.
|
||||
(NONPROD_PORTAL, NONPROD_INFERENCE, NONPROD_INFERENCE),
|
||||
# The protection: with no override, or a production override, only production hosts pass.
|
||||
(None, NONPROD_INFERENCE, None),
|
||||
(PROD_PORTAL, NONPROD_INFERENCE, None),
|
||||
# Production stays valid under any override; the rule widens, never narrows.
|
||||
(None, PROD_INFERENCE, PROD_INFERENCE),
|
||||
(NONPROD_PORTAL, PROD_INFERENCE, PROD_INFERENCE),
|
||||
],
|
||||
)
|
||||
def test_inference_host_follows_the_operator_selected_portal(monkeypatch, portal_override, url, expected):
|
||||
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
||||
if portal_override is None:
|
||||
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", portal_override)
|
||||
assert auth_nous._validate_nous_inference_url_from_network(url) == expected
|
||||
|
||||
|
||||
def test_non_production_portal_never_admits_a_foreign_or_non_https_host(monkeypatch):
|
||||
"""The widening is bounded to Nous-domain https hosts: a look-alike domain, a bare
|
||||
``nousresearch.com`` suffix without the dot, and plain http are all still refused."""
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL)
|
||||
for url in ("https://attacker.example/v1", "https://evilnousresearch.com/v1",
|
||||
"https://nousresearch.com.attacker.example/v1", "http://inference.example-env.nousresearch.com/v1",
|
||||
"https://.nousresearch.com/v1", "https://a..nousresearch.com/v1"):
|
||||
assert auth_nous._validate_nous_inference_url_from_network(url) is None, url
|
||||
# A malformed override (no parseable host) must not select the wider set either.
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "localhost:8000")
|
||||
assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) is None
|
||||
|
||||
|
||||
def test_stored_portal_alone_does_not_widen(monkeypatch):
|
||||
"""A poisoned auth.json cannot select the wider set: the stored portal_base_url is a
|
||||
network-provenance value, only the operator override counts. ``_healed_nous_inference_url``
|
||||
therefore heals a foreign host to production unless the operator chose that environment."""
|
||||
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
||||
state = {"portal_base_url": NONPROD_PORTAL, "inference_base_url": NONPROD_INFERENCE, "client_id": "cid"}
|
||||
_portal, stored_inference, _effective, _ = auth_nous._nous_effective_routing(state)
|
||||
assert stored_inference == auth_nous.DEFAULT_NOUS_INFERENCE_URL
|
||||
refreshed = {"inference_base_url": NONPROD_INFERENCE}
|
||||
assert auth_nous._healed_nous_inference_url(refreshed) == auth_nous.DEFAULT_NOUS_INFERENCE_URL
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL)
|
||||
assert auth_nous._healed_nous_inference_url(refreshed) == NONPROD_INFERENCE
|
||||
|
||||
|
||||
def test_widening_follows_the_profile_scope_under_multiplex(monkeypatch):
|
||||
"""Under multiplexing the decision uses the routed profile's own override: a secondary whose
|
||||
scope lacks it stays on the strict set even when the process env carries a non-production
|
||||
Portal (the default profile's), and a scope that carries one gets its environment's host."""
|
||||
from agent import secret_scope as ss
|
||||
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL)
|
||||
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
||||
ss.set_multiplex_active(True)
|
||||
try:
|
||||
token = ss.set_secret_scope({})
|
||||
try:
|
||||
assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) is None
|
||||
finally:
|
||||
ss.reset_secret_scope(token)
|
||||
token = ss.set_secret_scope({"HERMES_PORTAL_BASE_URL": NONPROD_PORTAL})
|
||||
try:
|
||||
assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) == NONPROD_INFERENCE
|
||||
finally:
|
||||
ss.reset_secret_scope(token)
|
||||
finally:
|
||||
ss.set_multiplex_active(False)
|
||||
|
||||
@@ -138,7 +138,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
|
||||
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing). When it points at a non-production Portal, that Portal's own `*.nousresearch.com` inference host is accepted, so `NOUS_INFERENCE_BASE_URL` is not also required. Per-profile under multiplexing: set it in the served profile's `.env`. |
|
||||
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
|
||||
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
|
||||
| `HERMES_NOUS_TIMEOUT_SECONDS` | HTTP timeout for Nous credential / token flows |
|
||||
|
||||
Reference in New Issue
Block a user