Skip to content

fix(proxy): refuse HTTP requests from untrusted browser origins - #4853

Open
chopratejas wants to merge 5 commits into
mainfrom
fix/http-browser-origin
Open

chopratejas wants to merge 5 commits into
mainfrom
fix/http-browser-origin

Conversation

@chopratejas

@chopratejas chopratejas commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

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 skip HEADROOM_PROXY_TOKEN, and a page open in the user's browser reaches the proxy as a loopback caller. It can send a JSON body as Content-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 checked Origin.

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 Origin that is not:

  • a loopback page (http(s)://127.0.0.1|localhost|[::1] on any port; this is the dashboard),
  • an origin listed in HEADROOM_WS_ORIGINS / HEADROOM_CORS_ORIGINS (the same list _is_allowed_websocket_origin honours),
  • for a non-loopback caller only, the proxy's own address (a remote dashboard behind HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS). A loopback caller gets no same-origin pass, since a non-loopback Host with a loopback peer means a page's hostname was pointed at 127.0.0.1.

A cross-site subresource load (<img>, <script>) sends no Origin on GET, so Sec-Fetch-Site: cross-site is refused too, except for a top-level navigation. Requests without Origin (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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Code refactoring (no functional changes)

Changes Made

  • headroom/proxy/server.py: _is_refused_browser_request(request) and a step 0 in _security_gate that answers 403 before the token check and routing. The 403 comes with the usual security headers and a hint naming HEADROOM_WS_ORIGINS, and an event=proxy_browser_origin_rejected log line records the escaped Origin. An origin listed in either HEADROOM_WS_ORIGINS or HEADROOM_CORS_ORIGINS is accepted, even when both are set.
  • tests/test_proxy_browser_origin.py: text/plain POSTs to /v1/messages and /v1/chat/completions from 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 foreign Origin to /stats and /metrics now 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

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

# new test file on origin/main (before the fix)
$ pytest tests/test_proxy_browser_origin.py
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers0-/v1/chat/completions]
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers0-/v1/messages]
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers1-/v1/chat/completions]
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers1-/v1/messages]
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers2-/v1/chat/completions]
FAILED ...test_foreign_page_request_is_refused_before_the_upstream[browser_headers2-/v1/messages]
FAILED ...test_configured_origin_still_forwards
FAILED ...test_cross_site_subresource_without_origin_is_refused
========================= 8 failed, 5 passed in 11.06s =========================

# with the fix
$ pytest tests/test_proxy_browser_origin.py tests/test_proxy_loopback_gating.py tests/test_proxy_cors.py
======================= 175 passed, 5 warnings in 34.36s =======================

# wider sweep: tests/test_proxy*.py tests/test_proxy/ tests/test_header_isolation.py
#   tests/test_upstream_credential_scoping.py tests/test_openai_codex_routing.py
#   tests/test_dashboard_* tests/test_copilot* (-k "not playwright")
= 12 failed, 2368 passed, 114 skipped, 30 deselected in 483.68s =
# the 12 failures (tests/test_proxy_uds.py x11, one test_proxy_compress_endpoint lossy-inline case)
# fail identically on origin/main in this environment

$ ruff check headroom/proxy/server.py tests/test_proxy_browser_origin.py tests/test_proxy_loopback_gating.py
All checks passed!
$ mypy headroom/proxy/server.py --ignore-missing-imports
Success: no issues found in 1 source file

Real Behavior Proof

  • Environment: macOS, Python 3.12. 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's x-api-key and prompt. One run on origin/main, one on this branch.
  • Exact command / steps: three curl -X POST http://127.0.0.1:<port>/v1/messages -H 'Content-Type: text/plain' --data '<messages JSON>', with Origin: http://page.example, with Origin: http://127.0.0.1:<port>, and with no Origin.
  • Observed result:
    == origin/main
    Origin: http://page.example -> HTTP 200
    Origin: http://127.0.0.1:18961 -> HTTP 200
    (no Origin) -> HTTP 200
    listener saw:
    {"path": "/v1/messages", "x-api-key": "OPERATOR_KEY_SENTINEL", "prompt": "request-1 (Origin: http://page.example)"}
    {"path": "/v1/messages", "x-api-key": "OPERATOR_KEY_SENTINEL", "prompt": "request-2 (Origin: http://127.0.0.1:18961)"}
    {"path": "/v1/messages", "x-api-key": "OPERATOR_KEY_SENTINEL", "prompt": "request-3 (X-No-Origin: 1)"}
    == this branch
    Origin: http://page.example -> HTTP 403
    Origin: http://127.0.0.1:18962 -> HTTP 200
    (no Origin) -> HTTP 200
    listener saw:
    {"path": "/v1/messages", "x-api-key": "OPERATOR_KEY_SENTINEL", "prompt": "request-2 (Origin: http://127.0.0.1:18962)"}
    {"path": "/v1/messages", "x-api-key": "OPERATOR_KEY_SENTINEL", "prompt": "request-3 (X-No-Origin: 1)"}
    
    Dashboard on the same branch: GET /dashboard as a cross-site navigation -> 200; same-origin POST /settings -> 200; same-origin GET /stats -> 200.
  • Not tested: a real Copilot upstream (the refusal happens before any handler, so it is the same for every route).

Runtime Rollout Safety

  • Rollout-managed feature(s): none
  • Minimum rollout channel: n/a
  • Stable/default behavior changed: HTTP requests carrying an Origin that 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 in HEADROOM_WS_ORIGINS (or HEADROOM_CORS_ORIGINS, which also governs CORS). Electron/webview renderers that send Origin: null or an app scheme are refused unless listed.
  • Kill switch / disable path: list the origin in HEADROOM_WS_ORIGINS.
  • Unsafe override required: no
  • Qualification impact: none
  • Rollback path: revert this commit

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit 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 in HEADROOM_WS_ORIGINS / HEADROOM_CORS_ORIGINS from 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

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>
@chopratejas chopratejas added bug Something isn't working size: small At most 100 added/deleted lines. risk: medium Scope estimate: bounded implementation or tooling change; verification still required. area: proxy Touches proxy files; triage routing, not a CODEOWNERS assignment. labels Nov 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR does not yet satisfy the required template fields:

  • Fill in Real Behavior Proof → Observed result.

Please update the PR body, or move the PR back to draft while it is still in progress.

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates status: ci failing Required or reported CI checks are failing labels Nov 13, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

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>
@github-actions github-actions Bot removed the status: ci failing Required or reported CI checks are failing label Nov 13, 2026
…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-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Allowed origin schemes are misstated

_normalize_origin also accepts ws and wss entries in the configured origin lists. The claim that only HTTP schemes can be listed excludes supported entries.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch was successfully deployed

1 active deployment
Preview — b32c9118 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: proxy Touches proxy files; triage routing, not a CODEOWNERS assignment. bug Something isn't working risk: medium Scope estimate: bounded implementation or tooling change; verification still required. size: small At most 100 added/deleted lines. status: needs author action Pull request body or readiness checklist still needs author updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants