Skip to content

security(cron): block base_url overrides that exfiltrate provider credentials (salvage #52351) - #56196

Merged
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage-52351
Jul 1, 2026
Merged

security(cron): block base_url overrides that exfiltrate provider credentials (salvage #52351)#56196
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage-52351

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

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-controlled base_url — a credential-exfiltration primitive reachable via the model-callable, prompt-injectable cronjob tool (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 one AUTHOR_MAP chore commit under my identity so the check-attribution gate resolves their plain email.

The vulnerability

cronjob(action="create"|"update") accepts free-form provider + base_url. On fire, the scheduler resolves the named provider's stored key and pairs it with the job's base_url. A prompt-injected job (provider=anthropic, base_url=https://attacker/v1) sends the real API key to the attacker's endpoint. A base_url with no provider inherits the default provider's key for the same effect.

Confirmed present on current main: no base_url guard exists at the cron tool boundary.

The fix (fail-closed, two layers)

  • Boundary guard tools/cronjob_tools.py::_validate_cron_base_url(provider, base_url) — runs on create AND update. A base_url override is allowed only when it cannot leak a stored secret:
    • no override at all;
    • bare custom (BYOK — key derived from the base_url/host-gated env, not a stored named secret);
    • a named custom provider whose configured endpoint host matches the override host;
    • a named registry provider whose known endpoint host matches the override host.
    • Everything else — including a base_url with no explicit provider, and any name we can't host-match — is refused. Fail-closed on import/resolution error.
  • Update path re-validates the effective merged provider/base_url pair 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 clearing base_url or repointing at a safe pair.
  • Runtime backstop cron/scheduler.py::_guard_job_credential_exfil(job) — re-validates the stored pair immediately before resolve_runtime_provider(), catching jobs persisted before the guard or written directly to the store. Fails closed: if the validator import/call raises, a base_url-bearing job is refused (a no-override job still runs). Raises RuntimeError, caught by run_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:

  • All 3 reviewer-flagged bypasses from the original PR are closed on this head (they were raised on earlier heads; @claudlos pushed fixes): named-custom off-host refused; update-path re-validates effective pair; scheduler backstop fails closed on validator error. Verified each with a focused repro + positive controls.
  • Provider normalization / aliases: casing/whitespace normalized; aliases (claude, google, …) aren't registry keys → fail-closed blocked at the guard even though the sink alias-expands them.
  • Host-matching: base_url_hostname (stdlib urlparse().hostname) defeats userinfo (x@evil.com), suffix (api.host.com.evil), path, case, trailing-dot, port tricks; base_url_host_matches anchors subdomain checks on a "." + domain boundary so lookalikes (legit.example.attacker.test) are blocked. Subdomains of the configured host are intentionally allowed (still the provider's own domain) — tested.
  • Alternate sinks: the fallback chain reads only operator config (trusted, never job fields); the accept_suggestion→blueprint create path has no base_url field so no override primitive; direct store writes are caught by the runtime backstop.

Tests

pytest tests/tools/test_cronjob_tools.py tests/cron/test_scheduler_provider.py tests/cron/test_scheduler.py -q
309 passed

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.

claudlos and others added 3 commits July 1, 2026 14:06
…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>
@kshitijk4poor
kshitijk4poor merged commit 58ea7f9 into NousResearch:main Jul 1, 2026
29 checks passed
@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/cron Cron scheduler and job management area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P1 High — major feature broken, no workaround labels Jul 1, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.
Salvage of #52351 (@claudlos), rebased onto current main with authorship preserved — related, not a duplicate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cron Cron scheduler and job management P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants