hermes - ✅(Solved) Fix OPENAI_API_KEY / OPENROUTER_API_KEY leak to non-OpenAI custom-provider base_urls (cross-provider credential leak) [3 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#28660Fetched 2026-05-20 04:02:46
View on GitHub
Comments
0
Participants
1
Timeline
9
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×4labeled ×4referenced ×1

Error Message

Error code: 401 - {'error': {'message': 'Authentication Fails, Your api key: ****ired is invalid', ...}}

Root Cause

hermes_cli/runtime_provider.py::_resolve_openrouter_runtime, the fallback chain for custom endpoints:

# When hitting a custom endpoint (e.g. Z.ai, local LLM), prefer
# OPENAI_API_KEY so the OpenRouter key doesn't leak to an unrelated
# provider (issues #420, #560).
_is_ollama_url = base_url_host_matches(base_url, "ollama.com")
api_key_candidates = [
    explicit_api_key,
    (cfg_api_key if use_config_base_url else ""),
    (os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
    os.getenv("OPENAI_API_KEY"),         # ← runs for *any* non-openrouter URL
    os.getenv("OPENROUTER_API_KEY"),     # ← same
]

The Ollama path correctly gates on host (_is_ollama_url), but OPENAI_API_KEY and OPENROUTER_API_KEY are added unconditionally. So provider="custom" + base_url="https://api.deepseek.com/v1" ends up sending an OpenAI key to DeepSeek.

Symmetrical to (and predates) the OpenRouter-leak fix in the comment block above — same class of bug, just a different scapegoat key.

Fix Action

Fixed

PR fix notes

PR #261: fix(config): auto-populate model.api_key for known-host custom providers (#260)

Description (problem / solution / changelog)

Closes #260 (workaround — see "Why this is a workaround" below).

Symptom

User configures DeepSeek (or Groq, Mistral, etc.) as a custom provider through the Providers UI, sends a chat, gets:

``` Error: Error code: 401 - {'error': {'message': 'Authentication Fails, Your api key: ****ired is invalid', ...}} ```

The masked tail (`****ired`, or whatever the last 4 chars of the user's `OPENAI_API_KEY` happen to be) gives it away: an OpenAI key is being sent to api.deepseek.com.

Root cause (upstream, not fixable here)

Lives in `hermes-agent/hermes_cli/runtime_provider.py::_resolve_openrouter_runtime`. When `model.provider == "custom"` and the credential pool for the resolved `custom:<name>` is empty, the api_key fallback chain reads:

```python api_key_candidates = [ explicit_api_key, (cfg_api_key if use_config_base_url else ""), # model.api_key (os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""), os.getenv("OPENAI_API_KEY"), # ← leaks here os.getenv("OPENROUTER_API_KEY"), # ← or here ] ```

There's no provision for "if base_url points at DeepSeek, prefer `DEEPSEEK_API_KEY`" — the matching env var is never consulted. So even though the user typed `DEEPSEEK_API_KEY=...` into the Providers UI's env section, the gateway routes around it.

The desktop UI's "Credential Pool" section doesn't help either: the dropdown only offers literal "custom", so keys land under `credential_pool["custom"]`, while the gateway looks up `credential_pool["custom:deepseek"]`.

The workaround in this PR

`cfg_api_key` (inline `model.api_key` in config.yaml) does win the fallback chain before the leak. So `setModelConfig` now auto-copies the matching env var to `model.api_key` when:

  • Provider is bare `"custom"`, and
  • The configured `base_url` matches a known commercial host (`expectedEnvKeyForModel` from #250 already has the mapping table), and
  • The matching `<NAME>_API_KEY` is set in the active profile's `.env`

If the env var is missing, or the provider switches, or the URL stops matching a known host, the stale `api_key` line is stripped on the next save — so it never lingers past relevance.

Scope is deliberately narrow:

  • Local LLMs (Ollama, vLLM, LM Studio, …) → untouched (no URL match → no auto-key)
  • Built-in providers (anthropic, openai, …) → untouched (only fires for bare "custom")
  • Custom hosts with no `<NAME>_API_KEY` env var → untouched

Why this is a workaround

Right fix is upstream: `_resolve_openrouter_runtime` should drop the `OPENAI_API_KEY`/`OPENROUTER_API_KEY` candidates when the resolved base_url isn't openai.com / openrouter.ai. I'll file that on `NousResearch/hermes-agent` separately. Once it lands, this PR can be reverted with no user-visible change.

Tests

`tests/custom-provider-auto-key.test.ts` — 8 cases:

  • DeepSeek + env set → writes api_key ✓
  • Quoted env value → quotes stripped ✓
  • Groq + env set → writes api_key ✓
  • Env var missing → no api_key written ✓
  • Localhost / unknown host → no api_key written ✓
  • Non-custom provider → no api_key written ✓
  • Stale api_key cleared on provider switch ✓
  • Existing api_key updated in place when env value changes ✓

411/411 total passing. Typecheck clean.

🤖 Generated with Claude Code

Changed files

  • src/main/config.ts (modified, +107/-1)
  • tests/custom-provider-auto-key.test.ts (added, +190/-0)

PR #6: fix(security): gate OPENAI/OPENROUTER API keys on resolved host (#28660)

Description (problem / solution / changelog)

🟡 Merge order: 7 / 12 — P0 security, straightforward host-gate pattern

Closes #28660 (P0 — security)

Problem

OPENAI_API_KEY and OPENROUTER_API_KEY were added unconditionally to api_key_candidates in three code paths. When provider="custom" with a non-OpenAI base URL (e.g. DeepSeek, Groq), the credential was sent to the unrelated endpoint.

Fix

Gate each provider-specific env var on the resolved host via base_url_host_matches(), matching the existing pattern for OLLAMA_API_KEY.

Risk assessment

FactorRating
Lines changed16/-6
New code3 host checks (existing function)
Side effects⚠️ Users with custom endpoints that relied on OPENAI_API_KEY fallback will need to set it explicitly via key_env or api_key in config
Revert complexityEasy

Testing notes

  • hermes chat with provider: custom, base_url: https://api.deepseek.com/v1 — confirm no sk-... is sent
  • hermes chat with provider: openrouter — confirm OPENROUTER_API_KEY still resolves
  • hermes chat with local LLM (no key) — confirm no-key-required fallback works

Files changed

  • hermes_cli/runtime_provider.py (+16/-6)

Changed files

  • hermes_cli/runtime_provider.py (modified, +16/-6)

PR #28884: fix(security): prevent API key leakage to non-authoritative custom endpoints

Description (problem / solution / changelog)

Problem

Custom endpoint provider was forwarding OPENAI_API_KEY and OLLAMA_API_KEY to arbitrary hosts. A user pointing Hermes at a custom base URL (e.g. a local proxy or a lookalike domain) would silently send their real API keys to that endpoint.

Closes #28660.

Fix

  • OPENAI_API_KEY is now only forwarded to hosts ending in openai.com
  • OLLAMA_API_KEY is now only forwarded to hosts ending in ollama.com
  • All other custom endpoints receive no-key-required unless a credential pool entry explicitly provides a key

Tests

113 tests passing. Added/updated tests covering path injection attacks, lookalike hosts, legitimate Ollama Cloud, and OpenRouter mirror URLs.

Security Impact

Prevents credential exfiltration when users configure custom or self-hosted endpoints.

Changed files

  • hermes_cli/runtime_provider.py (modified, +10/-4)
  • tests/hermes_cli/test_runtime_provider_resolution.py (modified, +79/-6)

Code Example

Error code: 401 - {'error': {'message': 'Authentication Fails, Your api key: ****ired is invalid', ...}}

---

# When hitting a custom endpoint (e.g. Z.ai, local LLM), prefer
# OPENAI_API_KEY so the OpenRouter key doesn't leak to an unrelated
# provider (issues #420, #560).
_is_ollama_url = base_url_host_matches(base_url, "ollama.com")
api_key_candidates = [
    explicit_api_key,
    (cfg_api_key if use_config_base_url else ""),
    (os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
    os.getenv("OPENAI_API_KEY"),         # ← runs for *any* non-openrouter URL
    os.getenv("OPENROUTER_API_KEY"),     # ← same
]

---

# ~/.hermes/config.yaml
model:
  provider: "custom"
  default: "deepseek-chat"
  base_url: "https://api.deepseek.com/v1"

providers: {}

---

# ~/.hermes/.env
OPENAI_API_KEY=sk-something-from-an-old-experiment
# DEEPSEEK_API_KEY intentionally unset

---

_is_openrouter_url = base_url_host_matches(base_url, "openrouter.ai")
_is_openai_url    = base_url_host_matches(base_url, "openai.com")
_is_ollama_url    = base_url_host_matches(base_url, "ollama.com")

api_key_candidates = [
    explicit_api_key,
    (cfg_api_key if use_config_base_url else ""),
    (os.getenv("OLLAMA_API_KEY")     if _is_ollama_url     else ""),
    (os.getenv("OPENAI_API_KEY")     if _is_openai_url     else ""),
    (os.getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
]
RAW_BUFFERClick to expand / collapse

What I'm seeing

When model.provider is custom in config.yaml and model.base_url points at a non-OpenRouter, non-OpenAI commercial endpoint (DeepSeek, Groq, Mistral, etc.), the gateway sends the user's OPENAI_API_KEY (or OPENROUTER_API_KEY) to that endpoint.

Manifests as a 401 with a masked key tail that doesn't match anything the user thinks they configured:

Error code: 401 - {'error': {'message': 'Authentication Fails, Your api key: ****ired is invalid', ...}}

The ****ired is the last 4 chars of OPENAI_API_KEY (or whatever else trailing-matched).

Root cause

hermes_cli/runtime_provider.py::_resolve_openrouter_runtime, the fallback chain for custom endpoints:

# When hitting a custom endpoint (e.g. Z.ai, local LLM), prefer
# OPENAI_API_KEY so the OpenRouter key doesn't leak to an unrelated
# provider (issues #420, #560).
_is_ollama_url = base_url_host_matches(base_url, "ollama.com")
api_key_candidates = [
    explicit_api_key,
    (cfg_api_key if use_config_base_url else ""),
    (os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
    os.getenv("OPENAI_API_KEY"),         # ← runs for *any* non-openrouter URL
    os.getenv("OPENROUTER_API_KEY"),     # ← same
]

The Ollama path correctly gates on host (_is_ollama_url), but OPENAI_API_KEY and OPENROUTER_API_KEY are added unconditionally. So provider="custom" + base_url="https://api.deepseek.com/v1" ends up sending an OpenAI key to DeepSeek.

Symmetrical to (and predates) the OpenRouter-leak fix in the comment block above — same class of bug, just a different scapegoat key.

Reproduction

# ~/.hermes/config.yaml
model:
  provider: "custom"
  default: "deepseek-chat"
  base_url: "https://api.deepseek.com/v1"

providers: {}
# ~/.hermes/.env
OPENAI_API_KEY=sk-something-from-an-old-experiment
# DEEPSEEK_API_KEY intentionally unset

Send any chat → 401 from DeepSeek complaining about an sk-… key it doesn't recognize.

Suggested fix

Gate OPENAI_API_KEY / OPENROUTER_API_KEY on the resolved host, the same way OLLAMA_API_KEY is already gated. The principle: an env var that's named for a specific provider should only feed an endpoint pointed at that provider:

_is_openrouter_url = base_url_host_matches(base_url, "openrouter.ai")
_is_openai_url    = base_url_host_matches(base_url, "openai.com")
_is_ollama_url    = base_url_host_matches(base_url, "ollama.com")

api_key_candidates = [
    explicit_api_key,
    (cfg_api_key if use_config_base_url else ""),
    (os.getenv("OLLAMA_API_KEY")     if _is_ollama_url     else ""),
    (os.getenv("OPENAI_API_KEY")     if _is_openai_url     else ""),
    (os.getenv("OPENROUTER_API_KEY") if _is_openrouter_url else ""),
]

For non-OpenAI / non-OpenRouter custom hosts, the chain then ends with cfg_api_key (matching the existing Ollama precedent) — effective_provider == "custom" and not api_key already falls back to "no-key-required" (line 718–719), so local LLMs without a key continue to work.

Bonus: it would be nice to also read a <NAME>_API_KEY derived from the host (e.g. api.deepseek.comDEEPSEEK_API_KEY) as an extra candidate. Not strictly required to close this bug, but it's what users intuitively expect.

Environment

Reproduced on hermes-agent 0.14.x.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING