fix(security): validate inference_base_url against host allowlist to prevent bearer token exfiltration#27612
Conversation
…prevent bearer token exfiltration
|
CI triage note from rock-turning: the failing |
…e sites @memosr's PR #27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR #14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
|
Thanks @memosr — sharp threat model analysis and a clean validator pattern. Merged via salvage PR #30611 (rebase-merge, your authorship preserved on A few adjustments during salvage worth flagging:
All 119 Nous-auth tests green, no regressions. Closing this PR — fix is on main: #30611 |
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green. #AI commit#
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
…e sites @memosr's PR NousResearch#27612 put the inference_base_url allowlist check only at the Nous proxy adapter forward boundary. The poisoned URL, however, lands in ``auth.json`` upstream of that — at five refresh / agent-key-mint payload read sites inside ``resolve_nous_runtime_credentials`` and ``_extend_state_from_refresh``. Without gating those sites, a single MITM on a refresh response persists the attacker's URL across restarts, even if the proxy adapter's defense-in-depth check would later catch it on the way out. Replace ``_optional_base_url`` with ``_validate_nous_inference_url_from_network`` at all five Portal-network reads: - hermes_cli/auth.py L4840 (refresh-only access-token path) - hermes_cli/auth.py L4876 (mint payload path) - hermes_cli/auth.py L5154 (terminal-runtime access-token refresh) - hermes_cli/auth.py L5262 (cross-process serialized refresh) - hermes_cli/auth.py L5317 (terminal-runtime mint payload) The state-read path at L5025 (``state.get("inference_base_url")``) is deliberately NOT gated — pre-existing state in ``auth.json`` is either already validated (it came from one of the five network sites above) or set by a trusted local actor (manual edit, ``_setup_nous_auth`` test fixture, ``hermes login nous`` against a staging endpoint via the documented ``NOUS_INFERENCE_BASE_URL`` env override). Direct write_file / patch tampering with auth.json is independently blocked by PR NousResearch#14157. Adds tests/hermes_cli/test_nous_inference_url_validation.py covering: - validator https + host + edge-case rules (12 cases) - all 5 network call sites grep contracts (no _optional_base_url regression possible without test failure) - proxy adapter defense-in-depth check still present - env override path NOT gated (documented dev/staging behaviour) 18 new tests, all 119 Nous-auth tests green.
What does this PR do?
The Nous Portal proxy adapter (
hermes_cli/proxy/adapters/nous_portal.py)forwards minted
agent_keybearer tokens to whateverinference_base_urlthe Portal returns in its refresh / agent-key-mint response, with no
validation beyond a trailing-slash strip:
The same field is also persisted to
~/.hermes/auth.jsonvia_save_state()and re-read byget_credential()on every proxyrequest, so a one-time poisoning sticks across restarts.
Two attack paths
1. Network — MITM on portal.nousresearch.com during refresh
refresh_nous_oauth_from_state()runs whenever the cachedagent_keyis expired (every ~24h on an active proxy). A network adversary in
position to MITM the Portal response can return:
{ "agent_key": "<the real minted key, untouched>", "inference_base_url": "https://attacker.com" }From that point on, every
/v1/chat/completionsrequest the localproxy receives is forwarded to
https://attacker.com/...withAuthorization: Bearer <legitimate_agent_key>. The attacker harvestsworking bearer tokens and gets full access to the user's Nous Portal
inference budget — plus a side-channel to inject responses back into
the user's IDE / chat client.
2. Local — write access to
~/.hermes/auth.jsonAny local process that can write the user's auth.json (a malicious VS
Code extension, a postinstall script on a stray npm package, a
compromised cron job) can flip
inference_base_urland achieve thesame outcome without ever touching the network.
CVSS 3.1 estimate
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N→ 8.7 (HIGH)Scope-changed (S:C) because the bearer token grants access to the
user's Portal account from the attacker's host.
Fix
Add a defense-in-depth allowlist check on the URL before it's used:
Then at the call site:
A poisoned
inference_base_urlnow falls back to the documenteddefault with a warning log, so the bearer never leaves an
allowlisted Nous host.
Why this allowlist shape
Mirrors the conservative-default pattern used elsewhere in the
codebase (e.g.
#19597Meet node localhost binding,#21277dashboard plugin SRI integrity,
#22432Google Chat sender_typecoercion). The allowlist is a
frozensetconstant — easy for Nousto add staging / mirror hosts in a single line when needed, and the
fallback is fail-safe rather than fail-open.
Type of Change
Checklist