fix(cron): a bare custom base_url takes a stored key only at a configured origin

The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.

(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
This commit is contained in:
John Paul Soliva
2026-09-28 23:25:55 +09:00
committed by kshitij
parent 1903b2b6e3
commit 001a3d0203
4 changed files with 57 additions and 7 deletions

View File

@@ -689,6 +689,29 @@ class TestValidateCronBaseUrl:
self._patch_named_legit(monkeypatch) # named custom provider arm
refused_off_origin("custom:legit", "legit.example")
def test_bare_custom_sends_a_stored_key_only_to_a_configured_origin(self, monkeypatch):
# Whatever key the real resolver would attach to a bare-custom base_url, the guard lets
# the job through only at the origin the operator configured. No key: still pure BYOK.
from hermes_cli.config import get_config_path
from hermes_cli.runtime_provider import resolve_runtime_provider
configured, stored = "https://api.acmellm.example/v1", "sk-stored-acmellm-key"
monkeypatch.setenv("ACMELLM_API_KEY", stored)
get_config_path().write_text(
f"custom_providers:\n - name: acme\n base_url: {configured}\n", encoding="utf-8")
def attached(bu):
return resolve_runtime_provider(requested="custom", explicit_base_url=bu)["api_key"]
for bu in ("http://api.acmellm.example:8080/v1", "http://api.acmellm.example/v1",
"https://api.acmellm.example:8443/v1", "https://eu.acmellm.example/v1"):
assert attached(bu) == stored, bu
err = self._v("custom", bu)
assert err and "not allowed" in err, bu
assert self._v("custom", configured) is None
keyless = "http://192.0.2.10:8080/v1"
assert attached(keyless) != stored and self._v("custom", keyless) is None
def test_named_custom_lookalike_host_blocked(self, monkeypatch):
self._patch_named_legit(monkeypatch)
assert self._v("custom:legit", "https://legit.example.attacker.test/v1") is not None

View File

@@ -320,11 +320,38 @@ def _same_origin(base_url: str, configured: str) -> bool:
return bool(want[1]) and got == want
def _custom_stored_key_error(bu: str) -> Optional[str]:
"""Bare 'custom' is BYOK only while the runtime attaches no stored key. The resolver picks
the host-gated env keys by HOSTNAME, so a base_url that would receive one must be an origin
the operator or the provider registry names; pool and ``model.key_env`` keys already match
their configured URL exactly."""
from agent.credential_pool import _iter_custom_providers
from hermes_cli import runtime_provider as rp
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.runtime_provider_custom import _DIRECT_API_BASE_URLS
from hermes_constants import OPENROUTER_BASE_URL
if not any(rp.has_usable_secret(key) for key in rp._host_gated_env_key_candidates(bu, ollama=True)):
return None
configured = [
rp._get_model_config().get("base_url"), rp.get_secret_str("OPENAI_BASE_URL", ""),
OPENROUTER_BASE_URL, *_DIRECT_API_BASE_URLS.values(),
*(getattr(p, "inference_base_url", "") for p in PROVIDER_REGISTRY.values()),
*(entry.get("base_url") for _, entry in _iter_custom_providers()),
]
if any(_same_origin(bu, str(url)) for url in configured if url):
return None
return (
f"base_url {bu!r} is not allowed for provider 'custom'. A stored API key matches its "
f"hostname, and a stored credential may only be sent to a configured endpoint (same "
f"scheme, host and port); configure the endpoint as a custom provider to use it.")
def _validate_cron_base_url(
provider: Optional[Any], base_url: Optional[Any]) -> Optional[str]:
"""Reject pairing a named provider's stored credential with an off-origin base_url (a
prompt-injected job could exfil the key). Allowed: no override; bare 'custom' (pure BYOK,
key derived from the base_url); an override with the same origin as the named provider's
"""Reject pairing a stored credential with an off-origin base_url (a prompt-injected job
could exfil the key). Allowed: no override; bare 'custom' while no stored key would go with
it, or at a configured origin; an override with the same origin as the named provider's
own endpoint. Everything else fails closed. Returns an error string if blocked, else None."""
bu = _normalize_optional_job_value(base_url, strip_trailing_slash=True)
if not bu:
@@ -343,8 +370,8 @@ def _validate_cron_base_url(
except Exception:
return f"Unable to validate base_url override for provider {prov!r}; refused."
if prov.lower() == "custom": # pure BYOK: key keyed by THIS base_url, never a stored secret
return None
if prov.lower() == "custom":
return _custom_stored_key_error(bu)
if has_named_custom_provider(prov):
# A NAMED custom provider's STORED key is still sent to an override base_url.
try:

View File

@@ -1354,7 +1354,7 @@ If a hand edit leaves `jobs.json` malformed, the scheduler repairs it on the nex
Jobs may store `model` and `provider` as `null`. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set.
A per-job `base_url` override needs an explicit `provider`. For a provider with a stored key (a named custom provider or a built-in one), the override must have the same origin as that provider's configured endpoint: the same scheme, host and port. Another scheme, port or subdomain is refused, so the stored key is only ever sent where you configured it. A bare `provider: custom` takes any `base_url`, because no stored key goes with it.
A per-job `base_url` override needs an explicit `provider`. For a provider with a stored key (a named custom provider or a built-in one), the override must have the same origin as that provider's configured endpoint: the same scheme, host and port. Another scheme, port or subdomain is refused, so the stored key is only ever sent where you configured it. A bare `provider: custom` takes any `base_url` that no stored key goes with. When a stored key matches the URL's hostname (for example `DEEPSEEK_API_KEY` for `api.deepseek.com`), the same rule applies: the `base_url` must have the origin of an endpoint you configured or of a built-in provider.
The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.

View File

@@ -746,7 +746,7 @@ cronjob(action="create", name="daily-digest",
任务可能将 `model` 和 `provider` 存储为 `null`。省略这些字段时,Hermes 在执行时从全局配置中解析它们。只有设置了单任务覆盖时,这些字段才会出现在任务记录中。
单任务的 `base_url` 覆盖必须同时指定 `provider`。对于保存了密钥的 provider(具名自定义 provider 或内置 provider),覆盖地址必须与该 provider 配置的端点同源:协议、主机和端口都相同。其他协议、端口或子域名都会被拒绝,因此保存的密钥只会发往你配置的地址。单独的 `provider: custom` 可以使用任意 `base_url`,因为它不携带保存的密钥。
单任务的 `base_url` 覆盖必须同时指定 `provider`。对于保存了密钥的 provider(具名自定义 provider 或内置 provider),覆盖地址必须与该 provider 配置的端点同源:协议、主机和端口都相同。其他协议、端口或子域名都会被拒绝,因此保存的密钥只会发往你配置的地址。单独的 `provider: custom` 可以使用任何不会携带保存密钥的 `base_url`。如果某个保存的密钥与该 URL 的主机名匹配(例如 `api.deepseek.com` 对应 `DEEPSEEK_API_KEY`),则适用同样的规则:`base_url` 必须与你配置的端点或内置 provider 的端点同源。
存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。