fix(models): route the AI Gateway catalog fetchers through the credential-redirect guard - #87235
Conversation
`_fetch_ai_gateway_models` attaches `Authorization: Bearer <AI_GATEWAY_API_KEY>` to its `/models` request and then opens it with raw `urllib.request.urlopen`. The stdlib's default `HTTPRedirectHandler` re-sends every header it was given when it follows a redirect, including across origins, so a 302 from the configured gateway endpoint forwards the API key to whatever host the `Location` header names. The endpoint is user-settable: the function itself reads `AI_GATEWAY_BASE_URL` from the environment and only falls back to the `hermes_constants` default when that is empty. So pointing the provider at a self-hosted or proxied gateway — a documented, first-class thing to do — is enough to make the redirect reachable, and the picker path (`_fetch_ai_gateway_models` is called while building the AI Gateway model list) swallows every exception, so nothing surfaces to the user. `hermes_cli/urllib_security.py` exists for exactly this and states the policy in its module docstring: "Security policy for credential-bearing stdlib urllib requests." This file already imports it and already wraps it in `_urlopen_model_catalog_request`, whose own docstring reads "Open catalog requests without forwarding headers across origins." Eleven other catalog fetchers in this module go through that wrapper; this one did not. Fix: call the wrapper that is already defined a few lines below the import. The call is signature-compatible, so the change is the call site only — no new import, no change to URL resolution, headers, or parsing. Same remedy and same shape as the merged "fix(providers): route Actual's fetch_models through the credential-redirect guard".
…the catalog opener `fetch_ai_gateway_models` and `fetch_ai_gateway_pricing` are the other two `urllib.request.urlopen` call sites the AI Gateway revert restored in this module. They carry no credential header today, so this is not a second credential leak — but they are the only catalog fetchers here that bypass `_urlopen_model_catalog_request`, and that wrapper is where the module's whole HTTP policy lives, not just its redirect rule. Concretely, `open_credentialed_url` resolves an explicit TLS context via `_resolved_https_context`, which honors `HERMES_CA_BUNDLE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` and falls back to certifi on macOS. Plain `urlopen` honors none of those except `SSL_CERT_FILE`, which the stdlib reads on its own. So on a machine behind a TLS-inspecting proxy configured through Hermes, or on a macOS Python without a usable system trust store, these two calls fail verification while the module's eleven other catalog fetchers succeed — and both swallow the exception, so the picker silently shows the built-in `VERCEL_AI_GATEWAY_MODELS` list with no live pricing and no error. Routing them also means the guard applies by construction rather than by review if the gateway catalog ever grows an auth header, which is the point of having one opener for the file. The eight `urllib.request.urlopen` mocks in `tests/hermes_cli/test_ai_gateway_models.py` are repointed to `hermes_cli.models._urlopen_model_catalog_request`, which is the convention the rest of the suite already uses (twenty-eight sites across `test_model_validation.py`, `test_api_key_providers.py`, `test_models.py` and others) and the same repoint merged as "fix(tests): patch catalog urlopen wrapper in gemini probe tests". Without it the existing tests would stop intercepting anything and reach the network. Those repointed tests are the regression guard for this commit: with the mocks on the wrapper and the production calls left raw, seven of the nine tests in the file fail.
… probe Wire-level regression test for `_fetch_ai_gateway_models`, which had no coverage at all. Two loopback `ThreadingHTTPServer`s: the first answers the probe's `/models` request with a `302` to the second, which records the headers it receives. The real `SafeCredentialRedirectHandler` runs -- the security module is not mocked -- so the test fails if the probe is ever routed around `_urlopen_model_catalog_request` again, which is exactly how the raw call got back into this file. Mutation-verified: reverting only the `_fetch_ai_gateway_models` call site to `urllib.request.urlopen` fails the test with the sink observing `authorization: Bearer ai-gateway-secret`; the other nine tests in the file stay green, so the failure isolates that one hunk. The test also asserts the redirect was followed and its payload parsed (`result == ["gateway/redirected-model"]`) so it cannot pass vacuously on a connection error, and that `user-agent` still arrives at the sink -- the guard drops the credential, not every header. Mirrors the harness in `tests/hermes_cli/test_urllib_security.py` (`test_cross_host_redirect_drops_arbitrary_credentials_on_wire`, `test_same_host_different_port_drops_credentials_on_wire`); the redirect target differs from the source by port, which `url_origin` already treats as a distinct origin.
There was a problem hiding this comment.
Pull request overview
This PR closes a security gap in hermes_cli/models.py by routing the AI Gateway catalog fetchers through the existing guarded catalog opener, ensuring credential-bearing requests do not forward headers across cross-origin redirects and that catalog fetches follow the module’s established urllib/TLS policy.
Changes:
- Switched the remaining AI Gateway catalog fetchers in
hermes_cli/models.pyfrom rawurllib.request.urlopen(...)to_urlopen_model_catalog_request(...). - Updated existing AI Gateway model/pricing tests to patch
_urlopen_model_catalog_requestinstead ofurllib.request.urlopen. - Added a wire-level regression test that verifies
Authorizationis stripped when a request redirects to a different origin.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
hermes_cli/models.py |
Routes AI Gateway catalog requests through the guarded catalog opener to enforce redirect/header and TLS policy consistently. |
tests/hermes_cli/test_ai_gateway_models.py |
Repoints mocks to the guarded opener and adds a redirect regression test verifying credential stripping on cross-origin redirects. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
looks mergeable All three AI Gateway model-catalog requests in the patch now use the existing credential-safe opener. Current main's raw urlopen path could forward Authorization or installed-opener credentials across a redirect. The replayed patch changes only those sinks and adds regression coverage; source review, focused catalog tests, and direct redirect-handler probes found no residual credential-forwarding bypass or unrelated security regression. Security evidence:
Not checked:
Signed: GPT-5.6-luna-max in Codex |
fix(models): route the AI Gateway catalog fetchers through the credential-redirect guard — No blocking issues found. A few minor observations:
|
andrexibiza
left a comment
There was a problem hiding this comment.
No blocking findings.
I traced all three changed sinks into _urlopen_model_catalog_request and then through open_credentialed_url. The enforcement boundary is sound: redirects are compared against the original normalized origin, cross-origin requests retain only Accept and User-Agent, and the final request sanitizer prevents installed urllib processors or opener-level headers from resurrecting credentials after the redirect handler runs.
I also replayed the wire behavior independently with the same request shape. Raw urllib.request.urlopen forwarded Authorization: Bearer ai-gateway-secret to the redirected origin. The guarded opener kept the bearer on the initial request, removed it from the cross-origin hop, still followed and parsed the response, and preserved Accept and User-Agent. That confirms the new regression is exercising the real leak rather than passing on a failed redirect.
The test split is sufficient. The credential-bearing _fetch_ai_gateway_models path gets the end-to-end loopback regression. The other two call sites are pinned to _urlopen_model_catalog_request by their mocks: reverting either to raw urlopen bypasses the mock and breaks the existing behavior tests. Their redirect/TLS semantics are already covered centrally by test_urllib_security.py; duplicating the wire harness for credential-free requests would add little signal. Likewise, adding server_close() would be deterministic cleanup, but it is non-blocking and the existing shared wire tests use the same shutdown() pattern.
Current main still contains the same three raw AI Gateway calls, so the fix has not been superseded. CI and Docker workflows are green at 3f61a27. The branch is 587 commits behind current main, so the head needs refreshing before merge, but I found no source-backed defect in the patch itself.
What does this PR do?
hermes_cli/models.pyhas one opener for catalog HTTP:_urlopen_model_catalog_request, a thin wrapper overopen_credentialed_urlfromhermes_cli/urllib_security.py. That module's docstring states the policy — "Security policy for credential-bearing stdlib urllib requests" — and the wrapper's own docstring states the guarantee: "Open catalog requests without forwarding headers across origins."On
mainthe module has eleven call sites going through that wrapper and three going through rawurllib.request.urlopen. All three raw ones are the AI Gateway catalog calls, and one of them carries a credential:Stdlib's default
HTTPRedirectHandlerre-sends every header it was given when it follows a redirect, including across origins. So a302from the configured gateway endpoint handsAI_GATEWAY_API_KEYto whatever host theLocationheader names. The endpoint is user-settable — the function readsAI_GATEWAY_BASE_URLfrom the environment and only falls back to thehermes_constantsdefault when it is empty — so pointing the provider at a self-hosted or proxied gateway is enough to make the redirect reachable. The function swallows every exception and the picker path (_fetch_ai_gateway_modelsis called while building the AI Gateway model list) shows the built-in curated list on failure, so nothing surfaces to the user either way.This is not a new class of bug in this repo — it is a hole a revert punched in an already-completed sweep. "fix(models): strip credentials on catalog redirects" and "fix(security): enforce one redirect credential policy" landed on 2026-07-11 and converted this file's catalog calls to the wrapper. These three functions were not in the file at the time: they had been deleted by "remove Vercel AI Gateway and Vercel Sandbox (#33067)" on 2026-05-27, so the sweep never saw them. On 2026-07-29 "Revert 'remove Vercel AI Gateway and Vercel Sandbox (#33067)'" restored them verbatim — including their pre-sweep raw
urlopencalls and the eight pre-sweeppatch("urllib.request.urlopen")mocks in their test file.git merge-base --is-ancestorconfirms the sweep is an ancestor of the revert, so this is purely restored-stale code, not a design decision.Same remedy and same shape as the merged "fix(providers): route Actual's fetch_models through the credential-redirect guard". The mock repoint is the same one merged as "fix(tests): patch catalog urlopen wrapper in gemini probe tests".
Related Issue
No filed issue — found by auditing this module's raw
urllib.request.urlopencall sites against the redirect-credential policy the module already implements.Type of Change
Changes Made
Three atomic commits.
fix(models): route the AI Gateway model probe through the catalog opener—hermes_cli/models.py,_fetch_ai_gateway_models. The credential-bearing site. Call-site change only: no new import (the file already importsopen_credentialed_urland defines_urlopen_model_catalog_requesta few lines below it), and no change to URL resolution, headers, or parsing.fix(models): route the remaining AI Gateway catalog fetchers through the catalog opener—hermes_cli/models.py,fetch_ai_gateway_modelsandfetch_ai_gateway_pricing, plus the mock repoint intests/hermes_cli/test_ai_gateway_models.py. These are the other two sites the same revert restored. They carry no credential header today, so this is not a second leak — but they are the only catalog fetchers in the module that bypass the wrapper, and the wrapper is where the module's whole HTTP policy lives, not only its redirect rule.open_credentialed_urlresolves an explicit TLS context through_resolved_https_context, which honorsHERMES_CA_BUNDLE,REQUESTS_CA_BUNDLEandCURL_CA_BUNDLEand falls back to certifi on macOS; plainurlopenhonors none of those exceptSSL_CERT_FILE, which the stdlib reads on its own. So behind a TLS-inspecting proxy configured through Hermes, or on a macOS Python without a usable system trust store, these two calls fail verification while the module's other eleven succeed — and both swallow the exception, so the picker silently shows the built-inVERCEL_AI_GATEWAY_MODELSlist with no live pricing and no error.test(models): pin cross-origin credential stripping on the AI Gateway probe—tests/hermes_cli/test_ai_gateway_models.py. Wire-level regression test for_fetch_ai_gateway_models, which had no coverage at all.Coverage of the root cause
Every raw
urllib.request.urlopencall site inhermes_cli/models.pyis converted; the file now has zero. Deliberately not widened beyond this file:gateway/relay/media.py,gateway/relay/__init__.pydashboard_register.py,gateway_enroll.py,nous_account.py,nous_billing.pycopilot_auth.py,web_server.py(ElevenLabs)banner.py,browser_connect.py,debug.py,diagnostics_upload.py,model_catalog.py,security_audit.py,webhook.py,tui_gateway/methods_images.pyHow to Test
⇒
85 passedon this branch (84 onmain; the extra one is the new test).Mutation proof for commit 1. Revert only the
_fetch_ai_gateway_modelscall site tourllib.request.urlopenand re-run:The other nine tests stay green, so the failure isolates that one hunk. The sink is a second local server on a different port; the assertion is the header it actually received off the wire, with the real
SafeCredentialRedirectHandlerin the path —hermes_cli.urllib_securityis not mocked. The test also asserts the redirect was followed and its payload parsed, so it cannot pass vacuously on a connection error, and thatuser-agentstill arrives: the guard drops the credential, not every header.Mutation proof for commit 2. Restore
hermes_cli/models.pyfromorigin/main(git checkout origin/main -- hermes_cli/models.py) with this branch's test file in place ⇒8 failed, 2 passed: the seven repointed tests plus the new redirect test. Restoring the branch's version ⇒10 passed.Pre-existing baseline, unrelated to this PR. Running
tests/hermes_cli/test_api_key_providers.pyin the same session as the files above failsTestDeepInfraProviderProfile::test_profile_registered_with_alias_and_auxwithKeyError: 'DEEPINFRA_API_KEY'. It reproduces identically on a cleanorigin/mainworktree with the same command (1 failed, 188 passedthere vs1 failed, 189 passedhere — the extra pass is the new test) and passes in isolation on both, so it is a cross-file ordering baseline, not a regression from this change.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the four focused files above (85 passed), not the full suiteuv runDocumentation & Housekeeping
docs/, docstrings) — the test module docstring and the_mock_urlopenhelper docstring, which namedurlopen()after the repointcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A127.0.0.1on an ephemeral port, the same patterntests/hermes_cli/test_urllib_security.pyalready uses in CI