fix(security): validate Nous Portal inference_base_url against host allowlist (#27612)#30611
Merged
Conversation
…llowlist The Nous Portal proxy adapter forwards minted ``agent_key`` bearer tokens to whatever ``base_url`` ``resolve_nous_runtime_credentials()`` returns, which is read directly from the refresh / agent-key-mint response and persisted to ``~/.hermes/auth.json``. With no validation beyond a trailing-slash strip, a poisoned URL (Portal-side MITM, or local write to auth.json) gets forwarded the legitimate bearer on every subsequent proxy request — exfiltrating the user's inference budget and opening a response-injection channel back into the IDE / chat client. Add ``_validate_nous_inference_url_from_network()`` in ``hermes_cli.auth``: an https + host-allowlist check that returns None for anything outside ``inference-api.nousresearch.com``, so callers fall back to the documented default rather than ship the bearer to an attacker. This commit wires the validator into the proxy adapter at ``nous_portal.py``. A follow-up commit wires it into the four refresh / mint sites in ``auth.py`` so the poisoned URL never lands in auth.json in the first place. The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses validation by design — that's the documented staging/dev escape hatch and the env source is already trusted (the user set it themselves). Co-authored-by: memosr <mehmet.sr35@gmail.com>
…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.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
2 |
unresolved-import |
1 |
First entries
tests/hermes_cli/test_nous_inference_url_validation.py:25: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_nous_inference_url_validation.py:97: [invalid-argument-type] invalid-argument-type: Argument to function `_validate_nous_inference_url_from_network` is incorrect: Expected `str | None`, found `Literal[12345]`
tests/hermes_cli/test_nous_inference_url_validation.py:98: [invalid-argument-type] invalid-argument-type: Argument to function `_validate_nous_inference_url_from_network` is incorrect: Expected `str | None`, found `dict[str, str]`
✅ Fixed issues: none
Unchanged: 4801 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Salvages @memosr's PR #27612. A poisoned
inference_base_urlreturned by the Nous Portal refresh / agent-key-mint response can no longer redirect the user's minted bearer token to an attacker-controlled host — validated against an https + host-allowlist gate at all five network read sites inauth.py, plus a defense-in-depth check at the proxy adapter's forward boundary.Root cause
resolve_nous_runtime_credentials()and its helpers (_extend_state_from_refreshand the terminal-runtime variants) readinference_base_urlfrom each Portal refresh / agent-key-mint payload and persist it to~/.hermes/auth.jsonwith only a trailing-slash strip — no scheme or host check. A network adversary in position to MITMportal.nousresearch.com(or any local process with write access toauth.json) could return:{ "agent_key": "<real key>", "inference_base_url": "https://attacker.com" }…and every subsequent
/v1/chat/completionsrequest through the local Nous proxy would be forwarded tohttps://attacker.com/...withAuthorization: Bearer <real-agent_key>. The attacker harvests working bearer tokens and gets a side-channel to inject responses back into the user's IDE / chat client.Changes
hermes_cli/auth.py(+62, contributor + Teknium): adds_ALLOWED_NOUS_INFERENCE_HOSTSfrozenset and_validate_nous_inference_url_from_network()— an https + exact-hostname allowlist gate that returns None for anything outsideinference-api.nousresearch.com. The fallback shape (validate() or DEFAULT_NOUS_INFERENCE_URL) is fail-safe, not fail-open.hermes_cli/proxy/adapters/nous_portal.py(+5 -2, contributor): forward-boundary defense-in-depth — validatesbase_urlreturned byresolve_nous_runtime_credentials()before forwarding bearer tokens.hermes_cli/auth.py(+5 -5, follow-up): wires the validator into all five Portal-network call sites that previously used the unvalidated_optional_base_url:tests/hermes_cli/test_nous_inference_url_validation.py(+212, follow-up): 18 new regression tests.Adjustments during salvage
The contributor's original PR placed the validator only at the proxy adapter and called it with
refreshed.get("inference_base_url")— butresolve_nous_runtime_credentials()returns the URL under the key"base_url", not"inference_base_url". The contributor's check would have silently always returnedNone, fallen back to the default, and never actually validated a real Portal-returned value. Salvaged the threat-model analysis and validator design; moved the gate intoauth.pywhere the URL is sourced AND fixed the proxy adapter call site to use the correct key.Threat model boundaries
The validator gates network-sourced URLs only:
auth.py)state.get("inference_base_url")from on-disk auth.json → NOT re-validated. Pre-existing state is either already validated on the way in (network path) or set by a trusted local actor (manual user edit,hermes login nousagainst a staging endpoint via the documented env override). Directwrite_file/patchtampering with auth.json is independently blocked by PR fix(security): protect Hermes control-plane files from prompt injection #14072 #14157.os.getenv("NOUS_INFERENCE_BASE_URL")env override → NOT gated. Documented dev/staging escape hatch; env source is trusted (user set it themselves).If anyone retargets
DEFAULT_NOUS_INFERENCE_URLaway frominference-api.nousresearch.comin the future, the allowlist must be updated in the same change — there's a test guarding this invariant.Validation
inference-api.nousresearch.comattacker.comevil.inference-api.nousresearch.com(subdomain)http://...file:///javascript:NOUS_INFERENCE_BASE_URL=https://staging.footests/hermes_cli/test_nous_inference_url_validation.py— 18/18 new tests pass.All Nous auth tests (5 files) — 119/119 green, no regressions.
Attribution
@memosr did the threat model analysis, designed the validator pattern, and built the helper. Their commit is preserved on
mainwith original date. The follow-up wires it into the broader call-site surface they didn't cover; commit attributed to Teknium.Infographic