Skip to content

fix(security): validate inference_base_url against host allowlist to prevent bearer token exfiltration#27612

Closed
memosr wants to merge 1 commit into
NousResearch:mainfrom
memosr:fix/nous-portal-inference-url-validation
Closed

fix(security): validate inference_base_url against host allowlist to prevent bearer token exfiltration#27612
memosr wants to merge 1 commit into
NousResearch:mainfrom
memosr:fix/nous-portal-inference-url-validation

Conversation

@memosr

@memosr memosr commented May 17, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The Nous Portal proxy adapter (hermes_cli/proxy/adapters/nous_portal.py)
forwards minted agent_key bearer tokens to whatever inference_base_url
the Portal returns in its refresh / agent-key-mint response, with no
validation beyond a trailing-slash strip:

# Before — accepts any URL
base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL
base_url = base_url.rstrip("/")
return UpstreamCredential(bearer=agent_key, base_url=base_url, ...)

The same field is also persisted to ~/.hermes/auth.json via
_save_state() and re-read by get_credential() on every proxy
request, 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 cached agent_key
is 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/completions request the local
proxy receives is forwarded to https://attacker.com/... with
Authorization: Bearer <legitimate_agent_key>. The attacker harvests
working 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.json

Any 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_url and achieve the
same 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:N8.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:

_ALLOWED_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
    "inference-api.nousresearch.com",
})


def _validate_nous_inference_url(url: str) -> str:
    """Return *url* if it points at an allowlisted Nous inference host,
    otherwise fall back to the documented default."""
    try:
        parsed = urlparse(url)
    except Exception:
        return DEFAULT_NOUS_INFERENCE_URL
    if parsed.scheme != "https":
        logger.warning("proxy: refusing non-https inference_base_url ...")
        return DEFAULT_NOUS_INFERENCE_URL
    if parsed.hostname not in _ALLOWED_INFERENCE_HOSTS:
        logger.warning("proxy: refusing inference_base_url host %r ...",
                       parsed.hostname)
        return DEFAULT_NOUS_INFERENCE_URL
    return url.rstrip("/")

Then at the call site:

raw_base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL
base_url = _validate_nous_inference_url(raw_base_url)

A poisoned inference_base_url now falls back to the documented
default 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. #19597 Meet node localhost binding, #21277
dashboard plugin SRI integrity, #22432 Google Chat sender_type
coercion). The allowlist is a frozenset constant — easy for Nous
to add staging / mirror hosts in a single line when needed, and the
fallback is fail-safe rather than fail-open.

Type of Change

  • 🔒 Security fix (HIGH — bearer token exfiltration via upstream URL manipulation)

Checklist

  • Read the Contributing Guide
  • Commit messages follow Conventional Commits
  • Defense-in-depth — doesn't replace the existing TLS / Portal-trust assumption, hardens it
  • Fail-safe fallback to documented default rather than hard error
  • No behavior change for users on the standard Nous inference host
  • Warning logs surface the rejection so operators can investigate if Nous adds a new host

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P0 Critical — data loss, security, crash loop comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools labels May 17, 2026
@BoardJames-Bot

Copy link
Copy Markdown

CI triage note from rock-turning: the failing test job is not exercising this PR's touched file (hermes_cli/proxy/adapters/nous_portal.py). The only failure is tests/tools/test_mcp_stability.py::TestStdioPidTracking::test_kill_orphaned_uses_sigkill_when_available, where the mocked global time.sleep saw many unrelated background-thread calls (Expected 'sleep' to be called once. Called 207243 times.). I verified that exact test passes locally via scripts/run_tests.sh tests/tools/test_mcp_stability.py::TestStdioPidTracking::test_kill_orphaned_uses_sigkill_when_available. I tried to rerun the failed job, but gh run rerun 26003254002 --failed is blocked by repo admin permission (Must have admin rights to Repository). Next owner: repo maintainer with Actions admin should rerun the test job; if it flakes again, fix the MCP stability test to avoid asserting a global time.sleep mock was called exactly once while other threads are alive.

teknium1 added a commit that referenced this pull request May 22, 2026
…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 added a commit that referenced this pull request May 22, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks @memosr — sharp threat model analysis and a clean validator pattern. Merged via salvage PR #30611 (rebase-merge, your authorship preserved on main in commit d33c99bb).

A few adjustments during salvage worth flagging:

  1. Key name correction. Your validator was called with refreshed.get("inference_base_url"), but resolve_nous_runtime_credentials() returns the URL under the key "base_url" (it's renamed during the dict construction at L5294). Without the rename catch, the validator would have silently always received None, fallen back to the default, and never actually validated a real Portal-returned value. Fixed the proxy adapter call site to use "base_url".

  2. Moved the helper into hermes_cli/auth.py. Original placement was inside the proxy adapter, but the URL gets persisted to auth.json upstream at 5 sites in auth.py (refresh + mint payload reads in resolve_nous_runtime_credentials and _extend_state_from_refresh). A poisoned URL would have landed in auth.json before the proxy adapter ever saw it, and stayed there across restarts — so the gate needs to be at the source, with the adapter as defense-in-depth.

  3. Wired into all 5 network call sites (follow-up commit). Replaced _optional_base_url(refreshed.get(...)) with _validate_nous_inference_url_from_network(refreshed.get(...)) at all 5 sites where a Portal payload influences persisted state.

  4. Env override left intact. NOUS_INFERENCE_BASE_URL doesn't pass through the validator — that's the documented dev/staging escape hatch and the env source is trusted (user set it themselves).

  5. 18 new regression tests including grep contracts that catch future regressions if anyone replaces the validator with the unsafe helper at any of the 5 sites.

All 119 Nous-auth tests green, no regressions.

Closing this PR — fix is on main: #30611

@teknium1 teknium1 closed this May 22, 2026
Gpapas pushed a commit to Gpapas/hermes-agent that referenced this pull request May 23, 2026
…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.
Gpapas pushed a commit to Gpapas/hermes-agent that referenced this pull request May 23, 2026
Mucky010 pushed a commit to Mucky010/hermes-agent that referenced this pull request May 24, 2026
…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.
Mucky010 pushed a commit to Mucky010/hermes-agent that referenced this pull request May 24, 2026
exosyphon pushed a commit to exosyphon/hermes-agent that referenced this pull request May 24, 2026
…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.
exosyphon pushed a commit to exosyphon/hermes-agent that referenced this pull request May 24, 2026
mathias3 pushed a commit to mathias3/hermes-agent that referenced this pull request May 28, 2026
…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.
mathias3 pushed a commit to mathias3/hermes-agent that referenced this pull request May 28, 2026
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
…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#
Bryce-huang pushed a commit to wbkunlun/hermes-agent that referenced this pull request May 29, 2026
mosaiq-systems pushed a commit to mosaiq-systems/hermes-agent that referenced this pull request May 29, 2026
…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.
mosaiq-systems pushed a commit to mosaiq-systems/hermes-agent that referenced this pull request May 29, 2026
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…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.
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 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 type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants