Skip to content

fix(security): validate Nous Portal inference_base_url against host allowlist (#27612)#30611

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-37ccebe4
May 22, 2026
Merged

fix(security): validate Nous Portal inference_base_url against host allowlist (#27612)#30611
teknium1 merged 3 commits into
mainfrom
hermes/hermes-37ccebe4

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvages @memosr's PR #27612. A poisoned inference_base_url returned 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 in auth.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_refresh and the terminal-runtime variants) read inference_base_url from each Portal refresh / agent-key-mint payload and persist it to ~/.hermes/auth.json with only a trailing-slash strip — no scheme or host check. A network adversary in position to MITM portal.nousresearch.com (or any local process with write access to auth.json) could return:

{ "agent_key": "<real key>", "inference_base_url": "https://attacker.com" }

…and every subsequent /v1/chat/completions request through the local Nous proxy would be forwarded to https://attacker.com/... with Authorization: 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_HOSTS frozenset and _validate_nous_inference_url_from_network() — an https + exact-hostname allowlist gate that returns None for anything outside inference-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 — validates base_url returned by resolve_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:
    • L4840 — refresh-only access-token path
    • L4876 — mint payload path
    • L5154 — terminal-runtime access-token refresh
    • L5262 — cross-process serialized refresh
    • L5317 — terminal-runtime mint payload
  • 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") — but resolve_nous_runtime_credentials() returns the URL under the key "base_url", not "inference_base_url". The contributor's check would have silently always returned None, 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 into auth.py where 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:

  • Portal refresh / mint payload reads → validated (5 sites in 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 nous against a staging endpoint via the documented env override). Direct write_file / patch tampering 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_URL away from inference-api.nousresearch.com in the future, the allowlist must be updated in the same change — there's a test guarding this invariant.

Validation

Scenario Behavior
Portal returns inference-api.nousresearch.com accepted
Portal returns attacker.com rejected, WARN log, fallback to default
Portal returns evil.inference-api.nousresearch.com (subdomain) rejected (exact hostname only)
Portal returns http://... rejected (https only)
Portal returns file:// / javascript: rejected
User sets NOUS_INFERENCE_BASE_URL=https://staging.foo accepted (env path bypasses validator)
Pre-existing state in auth.json not re-validated (independently protected by PR #14157 write deny)

tests/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 main with original date. The follow-up wires it into the broader call-site surface they didn't cover; commit attributed to Teknium.

Infographic

PR 27612 Nous URL allowlist

memosr and others added 3 commits May 22, 2026 14:11
…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.
@teknium1 teknium1 merged commit 4f98863 into main May 22, 2026
17 checks passed
@teknium1 teknium1 deleted the hermes/hermes-37ccebe4 branch May 22, 2026 21:17
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-37ccebe4 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9064 on HEAD, 9061 on base (🆕 +3)

🆕 New issues (3):

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.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P0 Critical — data loss, security, crash loop area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard provider/nous Nous Research API (OAuth) labels May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P0 Critical — data loss, security, crash loop provider/nous Nous Research API (OAuth) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants