Skip to content

fix(providers): route Actual's fetch_models through the credential-redirect guard - #80247

Closed
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/actual-provider-credential-redirect-leak
Closed

fix(providers): route Actual's fetch_models through the credential-redirect guard#80247
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/actual-provider-credential-redirect-leak

Conversation

@pierrenode

Copy link
Copy Markdown
Contributor

Summary

  • ActualProfile.fetch_models() (plugins/model-providers/actual/__init__.py) overrides ProviderProfile's default implementation with its own Actual-specific base_url resolution, but called raw urllib.request.urlopen(req, timeout=timeout) directly instead of the base class's open_credentialed_url(). Every other provider either uses the base class default or forwards to it via super() and gets SafeCredentialRedirectHandler for free — Actual is the only provider that attaches a Bearer token to its own Request object and opens it with the stdlib's default redirect handling, which forwards every header, including Authorization, across a cross-origin redirect.
  • Actual's own feature surface makes the trigger realistic: ACTUAL_BASE_URL is a first-class, documented way to point this provider at a self-hosted or local-offline endpoint (see the local-loopback no-auth path already handled elsewhere in this provider), so a misconfigured or compromised endpoint 302-ing to another host leaks ACTUAL_API_KEY to it.
  • Fix: import and call the same open_credentialed_url() the base class uses, keeping Actual's own URL-resolution logic unchanged.

Test plan

  • New end-to-end regression test using two real local HTTP servers (no mocking of the security module itself) — one redirects, the other records the Authorization header it receives — mirroring test_urllib_security.py's own redirect tests.
  • Repointed the existing fetch_models test's mock from urllib.request.urlopen to hermes_cli.urllib_security.open_credentialed_url, since fetch_models no longer calls the former.
  • Mutation-verified: the new redirect test fails on pre-fix code with the leaked Authorization header observed at the redirect target (assert ['Bearer actual-secret-token'] == [None]).
  • Full neighboring suite green: tests/hermes_cli/test_actual_provider.py, tests/hermes_cli/test_models_dev_preferred_merge.py, tests/hermes_cli/test_urllib_security.py — 28 passed.
  • ruff check clean on changed files.

…direct guard

ActualProfile.fetch_models() overrides ProviderProfile's default
implementation with its own Actual-specific base_url resolution
(ACTUAL_BASE_URL env var, hosted-vs-local normalization), but called raw
urllib.request.urlopen(req, timeout=timeout) directly instead of the base
class's open_credentialed_url(). Every other provider either uses the
base class default or forwards to it via super() and gets
SafeCredentialRedirectHandler for free — Actual is the only provider that
attaches a Bearer token to its own Request object and opens it with the
stdlib's default redirect handling, which forwards every header,
including Authorization, across a cross-origin redirect.

Actual's own feature surface makes the trigger realistic: ACTUAL_BASE_URL
is a first-class, documented way to point this provider at a self-hosted
or local-offline endpoint (see the local-loopback no-auth path already
handled elsewhere in this provider), so a misconfigured or compromised
endpoint 302-ing to another host leaks ACTUAL_API_KEY to it.

Fix: import and call the same open_credentialed_url() the base class
uses, keeping Actual's own URL-resolution logic unchanged.

Adds an end-to-end regression test using two real local HTTP servers (no
mocking of the security module itself) — one redirects, the other
records the Authorization header it receives — mirroring
test_urllib_security.py's own redirect tests. Also repoints the existing
fetch_models test's mock from urllib.request.urlopen to
hermes_cli.urllib_security.open_credentialed_url, since fetch_models no
longer calls the former. Mutation-verified: the new redirect test fails
on pre-fix code with the Authorization header observed at the redirect
target.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/plugins Plugin system and bundled plugins area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists needs-repro Bug needs reproduction steps sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 6, 2026
@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

ActualProfile.fetch_models now uses the shared credential-safe opener for its model catalog request, preventing credential headers from crossing origins while preserving same-origin redirects.

Security evidence:

  • trust boundary: ACTUAL_API_KEY or an explicit api_key and the configured Actual base URL enter ActualProfile.fetch_models, which sends an Authorization-bearing request to a provider-controlled endpoint. Redirects are an untrusted boundary; the shared opener mediates that boundary.
  • source/sink/invariant: The request adds Authorization only when api_key is present. open_credentialed_url replaces the direct opener: same-origin redirects retain request headers, while cross-origin redirects retain only Accept and User-Agent and cannot forward arbitrary credential headers.
  • current-main reproduction: On current main, the direct opener preserves Authorization across a cross-origin redirect; an in-memory redirect check reproduced that behavior.
  • PR-head or patch-replay validation: The PR replaces the Actual catalog opener with open_credentialed_url, and source inspection confirms the updated path reaches the shared redirect guard.
  • positive/negative cases: Positive: a same-origin redirect retained Authorization. Negative: a cross-origin redirect removed Authorization and an arbitrary custom credential while retaining Accept.
  • residual bypass search: The Actual override, its provider registration, the inherited catalog implementation, and the Actual catalog call path use the guarded opener; no direct opener remains in this provider path.
  • reviewer validation: Focused provider checks passed, and redirect probes covered URL normalization, response parsing, same-origin credential retention, and cross-origin removal of Authorization and other credential headers.

Not checked:

  • CodeRabbit review
  • Actual end-to-end redirect test

Signed: GPT-5.6-luna-max in Codex

@teknium1

Copy link
Copy Markdown
Contributor

The fetch_models redirect-guard commit from this PR landed on main via #86492 with your authorship preserved. Thanks!

@teknium1

Copy link
Copy Markdown
Contributor

Closing: this work is now fully on main via #86492 (your commit cherry-picked with authorship preserved). Thanks again!

@teknium1 teknium1 closed this Aug 14, 2026
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/plugins Plugin system and bundled plugins needs-repro Bug needs reproduction steps P2 Medium — degraded but workaround exists 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.

4 participants