fix(xai-oauth): pin inference base_url to x.ai origin (#28952)

XAI_BASE_URL / HERMES_XAI_BASE_URL let users repoint the OAuth-authenticated
inference endpoint, but the env override was an unguarded credential-leak
vector: a tampered .env or hostile shell init setting
XAI_BASE_URL=https://attacker.example/v1 would silently ship the SuperGrok
OAuth bearer to a third party on every request.

Add _xai_validate_inference_base_url() that pins the host to x.ai or a
*.x.ai subdomain and rejects non-HTTPS. On rejection, fall back to the
default with a warning rather than raise — a bad env var should not
deadlock auth, but should never leak the bearer either.

Apply at all three sites that read the env override for xai-oauth:
- hermes_cli/auth.py resolve_xai_oauth_runtime_credentials (main path)
- hermes_cli/auth.py _xai_oauth_loopback_login (initial login)
- agent/auxiliary_client.py _resolve_xai_oauth_for_aux (aux client)

E2E validated against four scenarios: attacker.example, lookalike
api.x.ai.evil.com, http:// downgrade on api.x.ai, and legit custom.x.ai
subdomain (which still resolves correctly).

Discovered while comparing against the opencode-grok-auth plugin
(github.com/ysnock404/opencode-grok-auth), which highlighted the same
guard on the OpenCode side.
This commit is contained in:
Teknium
2026-05-19 14:51:21 -07:00
committed by GitHub
parent c9d5ef28bf
commit 64a9a199bb
3 changed files with 200 additions and 12 deletions

View File

@@ -23,6 +23,7 @@ from hermes_cli.auth import (
_xai_callback_cors_origin,
_xai_oauth_build_authorize_url,
_xai_start_callback_server,
_xai_validate_inference_base_url,
_xai_validate_loopback_redirect_uri,
format_auth_error,
get_xai_oauth_auth_status,
@@ -554,6 +555,134 @@ def test_resolve_xai_runtime_credentials_honours_env_base_url(tmp_path, monkeypa
assert creds["base_url"] == "https://custom.x.ai/v1"
# ---------------------------------------------------------------------------
# Inference base-URL host guard (xai-oauth bearer leak protection)
#
# The xAI OAuth bearer is a high-value, long-lived SuperGrok credential.
# ``XAI_BASE_URL`` / ``HERMES_XAI_BASE_URL`` are a credential-leak vector
# unless the host is pinned to the xAI origin. These tests cover the
# accept/reject matrix for `_xai_validate_inference_base_url` and confirm
# the runtime resolver falls back to the default on rejection rather than
# leaking the bearer to an attacker-controlled endpoint.
# ---------------------------------------------------------------------------
def test_xai_inference_base_url_accepts_default():
assert (
_xai_validate_inference_base_url(
"https://api.x.ai/v1", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== "https://api.x.ai/v1"
)
def test_xai_inference_base_url_accepts_bare_apex():
assert (
_xai_validate_inference_base_url(
"https://x.ai/v1", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== "https://x.ai/v1"
)
def test_xai_inference_base_url_accepts_subdomain():
assert (
_xai_validate_inference_base_url(
"https://custom.x.ai/v1", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== "https://custom.x.ai/v1"
)
def test_xai_inference_base_url_strips_trailing_slash():
assert (
_xai_validate_inference_base_url(
"https://api.x.ai/v1/", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== "https://api.x.ai/v1"
)
def test_xai_inference_base_url_empty_returns_fallback():
assert (
_xai_validate_inference_base_url("", fallback=DEFAULT_XAI_OAUTH_BASE_URL)
== DEFAULT_XAI_OAUTH_BASE_URL
)
assert (
_xai_validate_inference_base_url(" ", fallback=DEFAULT_XAI_OAUTH_BASE_URL)
== DEFAULT_XAI_OAUTH_BASE_URL
)
def test_xai_inference_base_url_rejects_off_origin_host():
# The headline attack: env var pointing at an attacker-controlled host.
result = _xai_validate_inference_base_url(
"https://attacker.example/v1", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
assert result == DEFAULT_XAI_OAUTH_BASE_URL
def test_xai_inference_base_url_rejects_suffix_lookalike():
# ``api.x.ai.example`` ends in ``.example``, not ``.x.ai``. urlparse picks
# the full host as the hostname, and the suffix check uses ``.x.ai`` (with
# leading dot) so a lookalike like ``apix.ai`` or ``api.x.ai.evil.com``
# is rejected.
for hostile in (
"https://api.x.ai.evil.com/v1",
"https://apix.ai/v1",
"https://x.ai.evil.com/v1",
):
assert (
_xai_validate_inference_base_url(
hostile, fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== DEFAULT_XAI_OAUTH_BASE_URL
), hostile
def test_xai_inference_base_url_rejects_http():
# http:// would put the bearer on the wire in cleartext.
assert (
_xai_validate_inference_base_url(
"http://api.x.ai/v1", fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== DEFAULT_XAI_OAUTH_BASE_URL
)
def test_xai_inference_base_url_rejects_other_schemes():
for hostile in (
"ftp://api.x.ai/v1",
"file:///etc/passwd",
"javascript:alert(1)",
):
assert (
_xai_validate_inference_base_url(
hostile, fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
== DEFAULT_XAI_OAUTH_BASE_URL
), hostile
def test_resolve_xai_runtime_credentials_rejects_off_origin_env_base_url(tmp_path, monkeypatch, caplog):
# The end-to-end guarantee: if the env var points at an attacker host,
# the resolver MUST silently fall back to the default rather than ship
# the OAuth bearer to the attacker.
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 3600)
_setup_hermes_auth(hermes_home, access_token=fresh)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("XAI_BASE_URL", "https://attacker.example/v1")
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
with caplog.at_level("WARNING"):
creds = resolve_xai_oauth_runtime_credentials()
assert creds["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL
assert any(
"attacker.example" in record.getMessage() for record in caplog.records
), "Expected a warning identifying the rejected override host."
# ---------------------------------------------------------------------------
# Quarantine: terminal refresh failure clears dead tokens (#28155 sibling)
# ---------------------------------------------------------------------------