Skip to content

fix(httpapi): stop sending Access-Control-Allow-Origin: * on the REST API and /events - #1345

Open
Dumbris wants to merge 1 commit into
mainfrom
fix/cors-loopback-allowlist
Open

Dumbris wants to merge 1 commit into
mainfrom
fix/cors-loopback-allowlist

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 22, 2026

Copy link
Copy Markdown
Member

What

The REST API (/api/v1/*) and the SSE stream (/events) sent Access-Control-Allow-Origin: * on every response. CORS now echoes the request Origin only when it is loopback (any port) or a host in config trusted_hosts, and sends no CORS headers at all otherwise — including when there is no Origin header, which is every non-browser client. Vary: Origin is added to every response.

Why

With *, any website could read responses to key-authenticated requests once an API key reached a browser context. That happens in practice: the Web UI is routinely opened with ?apikey=. There is no Access-Control-Allow-Credentials anywhere in the tree, so this was a key-leak amplifier rather than a cookie-session hole — but the wildcard bought nothing either, since the embedded UI is same-origin under /ui/.

No new config field. trusted_hosts already exists, is already documented as validating the Origin header, is hot-reloadable, and has an env override (MCPPROXY_TRUSTED_HOSTS) and an escape hatch ("*"). Reusing it gives the REST and MCP surfaces one origin policy instead of two that can drift.

The duplicate wildcard write inside handleSSEEvents is deleted, not converted: /events is a chi route on the same router, so the Use-registered middleware already covers it. The test asserts the header on /events — that is what proves it.

The allowlist helpers (originAllowed/isLoopbackHost/hostMatchesTrusted) move verbatim out of internal/server/host_validation.go into a new leaf package internal/httpx, because internal/server imports internal/httpapi and they could not be shared in place. One-line delegates stay behind and internal/server/host_validation_test.go passes unchanged — that is the proof the extraction is behavior-preserving.

Upgrade note

If a separate web app calls the REST API cross-origin from a public domain, it now needs its host in trusted_hosts. Unlike the Host check, which only ever ran on the MCP endpoints, this one applies to the REST surface — so a REST-only reverse-proxied deployment may have been running with trusted_hosts empty. Documented in docs/operations/reverse-proxy.md and docs/configuration.md.

How verified

  • TDD: the table test was written first and failed against the wildcard on both doors.
  • go test -race on internal/httpapi, internal/httpx, and internal/server (CI skip regex) — green. golangci-lint both passes (bare + --build-tags server) — no new issues.
  • Real instance on a scratch data dir: loopback v4/v6/localhost and a configured trusted host are echoed; https://evil.example, null, http://127.0.0.1.evil.com and a missing Origin get no CORS headers; /events behaves identically; preflight still works without an API key; /ui/ still loads.
  • Cross-model review (codex gpt-5.6-sol), 2 rounds, clean verdict. Two findings fixed: OriginAllowed accepted a trailing ? or # (url.Parse hides a bare ? in ForceQuery and drops a bare #), and Allow-Methods omitted PATCH although the API registers PATCH routes. One finding rejected as out of scope: the middleware short-circuits every OPTIONS with 200 rather than only true preflights — that is byte-for-byte the pre-existing behavior and belongs in its own change.

🤖 Generated with Claude Code

… API and /events

The REST surface and the SSE stream sent `Access-Control-Allow-Origin: *`
unconditionally, so any website could read responses to key-authenticated
requests once a key reached a browser context — which happens in practice,
since the Web UI is routinely opened with `?apikey=`. There is no
`Access-Control-Allow-Credentials` anywhere, so this was a key-leak amplifier
rather than a cookie-session hole, but the wildcard bought nothing: the
embedded UI is same-origin under /ui/.

CORS now echoes the request Origin only when it passes the allowlist the MCP
surface already uses — loopback on any port, or a host in config
`trusted_hosts` — and emits no CORS headers at all otherwise, including when
there is no Origin header (every non-browser client). `Vary: Origin` is Add'ed
on every response so a shared cache never serves one origin's response to
another. No new config field: reusing `trusted_hosts` gives the REST and MCP
surfaces one origin policy, and it is already documented as governing Origin
validation, hot-reloadable, and overridable via MCPPROXY_TRUSTED_HOSTS.

The duplicate wildcard write inside handleSSEEvents is deleted rather than
converted: /events is a chi route on the same router, so the Use-registered
middleware already covers it, and a second call site is only somewhere for the
two policies to drift.

The allowlist helpers move verbatim from internal/server/host_validation.go
into a new leaf package internal/httpx that both surfaces import (internal/server
already imports internal/httpapi, so it could not be shared in place). One-line
delegates stay behind, and internal/server/host_validation_test.go passes
unchanged — that is the proof the extraction is behavior-preserving.

Two defects found in cross-model review are fixed alongside: OriginAllowed
accepted a trailing `?` or `#` (url.Parse hides a bare `?` in ForceQuery and
drops a bare `#`, so neither struct check saw it), and Allow-Methods omitted
PATCH although the API registers PATCH routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: e130340
Status: ✅  Deploy successful!
Preview URL: https://44d05e38.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-cors-loopback-allowlist.mcpproxy-docs.pages.dev

View logs

@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

❌ Patch coverage is 93.33333% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/httpapi/server.go 86.36% 2 Missing and 1 partial ⚠️
internal/httpx/origin.go 96.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/cors-loopback-allowlist

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (22 MB)
  • smart-mcp-proxymcpproxy-goWYDX41.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 35748051985 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants