security(cron): block base_url overrides that exfiltrate provider credentials (salvage #52351) - #56196
Merged
Merged
Conversation
…dentials The model-facing cronjob tool accepts free-form provider + base_url. On fire, the scheduler pairs the named provider's stored credential with the job's base_url, so a prompt-injected job (e.g. provider=anthropic, base_url=https://attacker/v1) sends the real API key to an attacker endpoint. A base_url with no provider inherits the default provider's key for the same effect. Add a fail-closed guard at the tool boundary: a base_url override is allowed only for the custom/BYOK sentinel, a configured custom_providers entry, or when the override host matches the named provider's own endpoint; an override without an explicit provider is rejected. The trust boundary is the caller, so operator-configured base_urls for named providers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses egilewski (Codex) CR on PR NousResearch#52351: the run_job() credential-exfil backstop caught every exception around _validate_cron_base_url() and set err = None, so an unexpected validator/import error let an unvetted stored provider/base_url pair reach resolve_runtime_provider() — the very sink this checkpoint exists to guard. A synthetic validator-exception probe with a legacy custom:legit + off-host base_url job slipped through (validator_exception ALLOW). Now fail closed: if the validator raises and the job carries a base_url override (the exfil precondition), refuse the run. A job with no base_url override can't exfiltrate via this path — the validator would return None — so it still runs, keeping the common no-override jobs from wedging on an unrelated error. Operator fallback providers come from config, not the job, so they are unaffected. Adds two regressions: validator-exception + base_url -> blocked; validator-exception without base_url -> still allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collaborator
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Salvage of #52351 by @claudlos (rebased onto current
main+ AUTHOR_MAP entry). Blocks cron jobs from pairing a named provider's stored API credential with an attacker-controlledbase_url— a credential-exfiltration primitive reachable via the model-callable, prompt-injectablecronjobtool (CWE-200 / CWE-522).The original PR was 174 commits behind
main. Both of @claudlos's commits are cherry-picked here verbatim (authorship preserved); I added oneAUTHOR_MAPchore commit under my identity so thecheck-attributiongate resolves their plain email.The vulnerability
cronjob(action="create"|"update")accepts free-formprovider+base_url. On fire, the scheduler resolves the named provider's stored key and pairs it with the job'sbase_url. A prompt-injected job (provider=anthropic, base_url=https://attacker/v1) sends the real API key to the attacker's endpoint. Abase_urlwith no provider inherits the default provider's key for the same effect.Confirmed present on current
main: nobase_urlguard exists at the cron tool boundary.The fix (fail-closed, two layers)
tools/cronjob_tools.py::_validate_cron_base_url(provider, base_url)— runs on create AND update. Abase_urloverride is allowed only when it cannot leak a stored secret:custom(BYOK — key derived from the base_url/host-gated env, not a stored named secret);base_urlwith no explicit provider, and any name we can't host-match — is refused. Fail-closed on import/resolution error.provider/base_urlpair on every update (not only when the update touches those fields), so a job persisted before this guard can't be left exfil-capable by editing an unrelated field. An operator can remediate in one update by clearingbase_urlor repointing at a safe pair.cron/scheduler.py::_guard_job_credential_exfil(job)— re-validates the stored pair immediately beforeresolve_runtime_provider(), catching jobs persisted before the guard or written directly to the store. Fails closed: if the validator import/call raises, abase_url-bearing job is refused (a no-override job still runs). RaisesRuntimeError, caught byrun_job's failure handler → reported as a failed run, before any network call.Review (this salvage)
Ran our full review workflow — scope-integrity check, backstop error-handling trace, an independent bypass probe, and two adversarial review passes (security-bypass hunt + correctness/regression). All converged clean, zero findings. Key checks:
claude,google, …) aren't registry keys → fail-closed blocked at the guard even though the sink alias-expands them.base_url_hostname(stdliburlparse().hostname) defeats userinfo (x@evil.com), suffix (api.host.com.evil), path, case, trailing-dot, port tricks;base_url_host_matchesanchors subdomain checks on a"." + domainboundary so lookalikes (legit.example.attacker.test) are blocked. Subdomains of the configured host are intentionally allowed (still the provider's own domain) — tested.accept_suggestion→blueprint create path has nobase_urlfield so no override primitive; direct store writes are caught by the runtime backstop.Tests
Coverage includes: named-custom off-host blocked / matching-host allowed / lookalike blocked, bare-custom allowed, base_url-without-provider rejected, legacy-unsafe-job blocked on unrelated update + remediation paths, and the runtime backstop fail-closed-on-validator-error case.
Supersedes #52351. Full credit to @claudlos for the fix.