Repository navigation
fix(proxy): refuse HTTP requests from untrusted browser origins - #4853
chopratejas wants to merge 5 commits into
Conversation
The provider routes (/v1/messages, /v1/chat/completions, /v1/responses, the passthrough) add operator credentials to what they forward: extra headers, the Copilot token, provider env keys on the litellm/anyllm backends. Loopback callers skip HEADROOM_PROXY_TOKEN, and a page open in the user's browser reaches the proxy as a loopback caller with a text/plain body that needs no preflight. Only the WebSocket routes checked Origin. The security gate now applies the WebSocket Origin rule to every HTTP request before routing: a request with an Origin that is not a loopback page, an origin in HEADROOM_WS_ORIGINS/HEADROOM_CORS_ORIGINS, or (for a non-loopback caller) the proxy's own address gets 403. Cross-site subresource loads without Origin are refused via Sec-Fetch-Site unless they are top-level navigations. Requests without Origin are unaffected. Assay-Kind: bugfix Assay-Ask: A web page open in the user's browser can make the local proxy send requests upstream with the operator's credentials, because the HTTP routes never check Origin. 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. |
The browser-origin refusal now logs the (escaped) Origin and its 403 body points at HEADROOM_WS_ORIGINS. The security model doc lists the setups that need an explicit origin (same-host reverse proxy, app-scheme desktop shells). The /stats gating test keeps the Origin and Referer cases separate instead of branching inside one parametrized test. Assay-Kind: bugfix Assay-Ask: Operators whose browser page is refused by the proxy cannot tell which origin was refused or how to allow it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when both lists are set
The WebSocket helper reads HEADROOM_CORS_ORIGINS only when
HEADROOM_WS_ORIGINS is unset, so with both set an origin listed only for
CORS passed preflight and then got 403. The HTTP check now accepts an
origin from either list ("*" still lists nothing).
Assay-Kind: bugfix
Assay-Ask: A browser origin allowed in HEADROOM_CORS_ORIGINS is refused by the proxy when HEADROOM_WS_ORIGINS is also set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
JerrettDavis
left a comment
There was a problem hiding this comment.
The origin gate and the fix accepting either configured HTTP origin list look consistent with the intended policy. One documentation correction: explicitly listing tauri://localhost still leaves requests with that Origin rejected, because _normalize_origin permits only HTTP(S)/WS(S). Please describe app-scheme origins as unsupported, or add explicit configured-scheme support with a regression test. Isolated helper checks reproduced this and passed 11 expected policy cases; I did not run the full application suites or live HTTP validation. Approval qualification remains held separately by the inherited workflow provenance audit.
Picks up the dependency updates from #4835 so the audit checks pass. 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>
Only http/https origins can be listed for the browser-origin check; pages with an app scheme or a null Origin are always refused. The doc suggested listing an app-scheme origin, which cannot match. Assay-Kind: docs Assay-Ask: The security model doc tells desktop-app users to list an app-scheme origin that the proxy can never accept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| A reverse proxy on the same host that forwards a public hostname to `127.0.0.1` needs the | ||
| page's origin (`https://name`) listed in `HEADROOM_WS_ORIGINS`: the proxy sees a loopback | ||
| peer with a non-loopback `Host`, which is indistinguishable from a re-pointed hostname. | ||
| Only `http`/`https` origins can be listed. Pages that send an app scheme (such as |
JerrettDavis
left a comment
There was a problem hiding this comment.
The app-scheme documentation correction resolves the prior finding. Fresh isolated helper checks pass 13 policy cases; these do not replace the full application suites or live HTTP proof. Current-head hosted application checks pass, but CI run 43519922036 reports a failed commitlint job (132211435827). Please supply its diagnostic output and resolve the failure before approval; the staged check contains no error details, so its cause is unconfirmed. The inherited workflow provenance hold remains separate from this PR. The existing scheme-wording thread remains nonblocking.
Description
The provider routes (
/v1/messages,/anthropic/v1/messages,/v1/chat/completions,/v1/responses, Bedrock/Vertex routes and the catch-all passthrough) add operator-held credentials to what they forward:*_extra_headers, the Copilot token (apply_copilot_api_auth), and the provider env key on the litellm/anyllm backends when the request carries none. Loopback callers skipHEADROOM_PROXY_TOKEN, and a page open in the user's browser reaches the proxy as a loopback caller. It can send a JSON body asContent-Type: text/plain, which needs no CORS preflight, and the proxy parses and forwards it. CORS only stops the page from reading the answer, not the proxy from sending the request. Only the WebSocket routes checkedOrigin.The security gate now applies the WebSocket Origin rule to every HTTP request, before routing. A request is refused with 403 when it carries an
Originthat is not:http(s)://127.0.0.1|localhost|[::1]on any port; this is the dashboard),HEADROOM_WS_ORIGINS/HEADROOM_CORS_ORIGINS(the same list_is_allowed_websocket_originhonours),HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS). A loopback caller gets no same-origin pass, since a non-loopbackHostwith a loopback peer means a page's hostname was pointed at 127.0.0.1.A cross-site subresource load (
<img>,<script>) sends noOriginon GET, soSec-Fetch-Site: cross-siteis refused too, except for a top-level navigation. Requests withoutOrigin(Claude Code, Codex, Copilot's extension host, SDKs, curl) are unaffected.Because the check sits in the gate, it covers every backend, including the litellm/anyllm env-key fallback, without changing how a keyless request from a local tool is authenticated.
Type of Change
Changes Made
headroom/proxy/server.py:_is_refused_browser_request(request)and a step 0 in_security_gatethat answers 403 before the token check and routing. The 403 comes with the usual security headers and a hint namingHEADROOM_WS_ORIGINS, and anevent=proxy_browser_origin_rejectedlog line records the escaped Origin. An origin listed in eitherHEADROOM_WS_ORIGINSorHEADROOM_CORS_ORIGINSis accepted, even when both are set.tests/test_proxy_browser_origin.py: text/plain POSTs to/v1/messagesand/v1/chat/completionsfrom a loopback peer with a foreign /null/ look-alike Origin never reach the upstream; without Origin, from a loopback page, and from a configured origin they still forward with the operator header; cross-site subresource GET refused; cross-site navigation still served; loopback peer with a re-pointed hostname refused.tests/test_proxy_loopback_gating.py: two existing tests that sent a foreignOriginto/statsand/metricsnow expect the gate's 403 (they already asserted refusal / no metadata; the gate now refuses earlier).docs/content/docs/security-model.mdx: describes the rule, and lists the setups that need an explicit origin: a reverse proxy on the same host, and app-scheme desktop shells.Testing
pytest)ruff check .)mypy headroom)Test Output
Real Behavior Proof
headroom proxy --host 127.0.0.1 --no-optimize --anthropic-api-url http://127.0.0.1:<L> --anthropic-extra-headers '{"x-api-key":"OPERATOR_KEY_SENTINEL"}', no proxy token, with a small HTTP listener on<L>that logs each request'sx-api-keyand prompt. One run on origin/main, one on this branch.curl -X POST http://127.0.0.1:<port>/v1/messages -H 'Content-Type: text/plain' --data '<messages JSON>', withOrigin: http://page.example, withOrigin: http://127.0.0.1:<port>, and with no Origin.GET /dashboardas a cross-site navigation -> 200; same-originPOST /settings-> 200; same-originGET /stats-> 200.Runtime Rollout Safety
Originthat is neither loopback, configured, nor (for a remote caller) the proxy's own address now get 403. A browser app on another origin that calls the proxy directly must be listed inHEADROOM_WS_ORIGINS(orHEADROOM_CORS_ORIGINS, which also governs CORS). Electron/webview renderers that sendOrigin: nullor an app scheme are refused unless listed.HEADROOM_WS_ORIGINS.Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Additional Notes
A companion PR (#4846) stops a
*entry inHEADROOM_WS_ORIGINS/HEADROOM_CORS_ORIGINSfrom accepting every Origin. Both PRs share_is_allowed_websocket_origin, so once both land*is ignored on HTTP too; until then,HEADROOM_CORS_ORIGINS=*keeps HTTP open to every Origin, as it keeps the WebSocket routes today.🤖 Generated with Claude Code