Repository navigation
fix(proxy): reject a private x-headroom-base-url on chat and responses with 400 - #4857
chopratejas wants to merge 5 commits into
Conversation
…s with 400 When the upstream guard rejected the x-headroom-base-url override, the OpenAI chat-completions and responses handlers dropped the header and sent the request, with the client's Authorization, to the configured OpenAI target. Every other route that reads the header refuses with 400. The two handlers now refuse the same way, so a prompt and key meant for the client's own gateway are not sent to a different host. The security model page is updated to describe one behaviour for all routes. Assay-Kind: bugfix Assay-Ask: A rejected private x-headroom-base-url on chat/responses is silently dropped and the request with the caller's key goes to the default OpenAI target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Review completed; approval remains pending the items below.
Reviewed head b2bccf4f6f8309d642bb6c1253800decee71ee04 against main 0368a872cdd5064b39888e13847200ff562ac192.
Qualification still required:
- Hosted check evidence changed
- Passing test evidence is missing
- Exact-source provenance review is incomplete
- Check/run 132188737092 is not terminal
- Check/run 132188736926 is not terminal
- Check/run 132186956047 is not successful or baseline-qualified
- Check/run 132186955946 is not successful or baseline-qualified
- Check/run 43512780771 is not successful or baseline-qualified
- Check/run 43512780689 is not terminal
Assay-Kind: deps Assay-Ask: The dependency audit checks failed on this PR because the branch predates the dependency updates on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JerrettDavis
left a comment
There was a problem hiding this comment.
Review completed; approval remains pending the items below.
Reviewed head 3ad01b49193d8dc592a3a48b4d4ba14f7d2c5158 against main 0368a872cdd5064b39888e13847200ff562ac192.
Qualification still required:
- Passing test evidence is missing
- Exact-source provenance review is incomplete
JerrettDavis
left a comment
There was a problem hiding this comment.
Reviewed 3ad01b4 against 0368a87. No blocking defects found. Independently ran the upstream-guard suite: 63 passed in the supported Python-only runtime. Both new chat/responses regressions return 400 without contacting the configured recording target; on recorded main the same tests fail because the target returns 200. Hosted checks also report success. Native Rust runtime and external provider behavior were not tested. Approval uses the verified review-only baseline; inherited workflow provenance remains held and no workflow execution is requested.
Sync PR #4857 with latest main.
JerrettDavis
left a comment
There was a problem hiding this comment.
Reviewed bd3ef99 against b324efb. No blocking defects found. Independently ran tests/test_upstream_guard.py: 63 passed in Python-only mode. Both new chat/responses cases return 400 without contacting the configured recording target; on recorded main both fail with HTTP 200 from that target. Current-head hosted checks report success. Native Rust runtime and external providers were not tested. Approval uses the verified review-only baseline; inherited workflow execution provenance remains held and no workflow execution is requested.
Kept both sides of the test_upstream_guard.py conflict (this branch's 400 test and main's non-ASCII pin test). Main's resolve-once test now expects the 400 this branch returns for a rejected override, instead of a fallback to the configured target. Assay-Kind: config Assay-Ask: The branch conflicts with main after the sibling upstream fix was merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fix/openai-base-url-reject Brings in the earlier merge of main already on the branch; no content change. Assay-Kind: config Assay-Ask: The branch conflicts with main after the sibling upstream fix was merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Jerrett: conflict-only merge with main (c2804aa, plus a5f9f46 to take in your earlier merge commit; no content change from that one). In tests/test_upstream_guard.py I kept both this PR's 400 test and main's non-ASCII pin test. Main's resolve-once test now expects the 400 this PR returns for a rejected override, instead of a fallback to the configured target. No production code changed in the merge. Area tests, ruff and mypy pass locally. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Jerrett: the approval was dismissed by the merge push after all, so I re-requested review. The one red check, test (3), is |
Description
When the upstream guard rejected an
x-headroom-base-urloverride (an address on a private, loopback, link-local or otherwise non-public network, or a host outsideHEADROOM_ALLOWED_BASE_URLSin allowlist mode),_resolve_openai_upstream_baselogged a warning and returnedNone./v1/chat/completionsand/v1/responsesthen sent the request — the prompt and the client'sAuthorization, meant for the gateway the client named — to the configuredOPENAI_API_URLinstead. Every other route that reads the header (headroom/providers/proxy_routes.py: Anthropic messages, Gemini, models, embeddings, audio, passthrough) refuses with400 Rejected unsafe upstream base URL.The OpenAI chat and responses handlers now refuse the same way.
Type of Change
Changes Made
headroom/proxy/handlers/openai.py:_resolve_openai_upstream_baseraisesHTTPException(400, "Rejected unsafe upstream base URL")instead of returningNonewhen the guard rejects the override (malformed / non-http(s) values are unchanged: still ignored).docs/content/docs/security-model.mdx: one behaviour for all routes (the page previously documented the two different outcomes).tests/test_upstream_guard.py::test_openai_routes_refuse_a_private_override_instead_of_using_the_default[chat|responses]: real app, configured OpenAI target is a local recording server; asserts 400 and that the configured target is never contacted.Sibling PRs that touch the same code (merge order note):
fix/upstream-guard-off-loopadds_resolve_openai_upstream_base_asyncnext to this function; whichever merges second must make the async resolver raise the same 400 (that PR's rejection branch is the one place to change).fix/upstream-idna-pin-oncetouches the call sites only. Suggested order: idna-pin-once, off-loop, then this PR rebased.Testing
pytest)ruff check .)mypy headroom)Test Output
Real Behavior Proof
python -m headroom.cli proxy --no-optimizefrom this branch and from origin/main,OPENAI_TARGET_API_URLpointing at a local server that records every request it receives.curl http://127.0.0.1:$PORT/v1/chat/completions -H 'authorization: Bearer client-gateway-key' -H 'x-headroom-base-url: http://192.168.1.50:8000/v1' -d '{"model":"gpt-4o","messages":[...]}', and the same for/v1/responses.Runtime Rollout Safety
x-headroom-base-urlpointing at a private/LAN/loopback address (or, in allowlist mode, an unlisted host) to/v1/chat/completionsor/v1/responsesnow gets a 400 instead of having its request answered by the configured OpenAI target. Such a request never reached the host the client named, so the only working setups affected are ones that relied on the silent fallback. A DNS lookup that fails or times out for a public gateway is also a rejection, so it now surfaces as a 400 instead of quietly sending the request to the default target.HEADROOM_ALLOWED_BASE_URLS(existing setting); clients can drop the header to use the configured target.Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Additional Notes
Fix adapted from a proposal by HetCreep.
Follow-up, not in this PR: the OpenCode plugin (
plugins/opencode/src/transport.ts,isRoutableUpstream) only keeps loopback upstreams out of the proxy, so an OpenCode provider on a LAN address is still routed through Headroom, which now answers 400 for it (previously the request went to the default OpenAI target). Sending private-network upstreams direct needs a source change plus regenerating the committed bundles (headroom/providers/opencode/_dist/index.js,hook-shim/handler.js), and hostnames that resolve to LAN addresses cannot be recognised without DNS, so it is left for a separate PR.🤖 Generated with Claude Code
Reported by @JoaoMarcos44. Thank you for your contribution! 🩵