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:
committed by
kshitij
parent
1903b2b6e3
commit
001a3d0203
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的端点同源。
|
||||
|
||||
存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user