fix: drop permissive CORS, add security headers and same-origin guard on POST /admin/profile - #95
Open
lyeith wants to merge 1 commit into
Open
fix: drop permissive CORS, add security headers and same-origin guard on POST /admin/profile#95lyeith wants to merge 1 commit into
lyeith wants to merge 1 commit into
Conversation
… guard POST /admin/profile had no CSRF protection while the server ran a fully permissive cors() middleware, so any page the operator's browser visited could rewrite the connector profile. This is the blocking gap for exposing the HTTP transport beyond loopback. - Remove the cors middleware and the cors/@types/cors dependency pair. Nothing reaches this server from browser JavaScript; ChatGPT calls /mcp server-side. - Add X-Frame-Options: DENY and a Permissions-Policy alongside the existing no-store / no-referrer / nosniff headers. - Reject cross-origin POSTs to /admin/profile with 403 origin_denied. Requests with no Origin header (curl, the CLI) still pass; a browser cannot produce a JSON body through a simple form post, so they cannot reach the JSON parser. The guard compares Host rather than the full origin, because behind a tunnel the request arrives over plain HTTP while the browser reports an https Origin. Covered by new assertions in scripts/http-smoke.mjs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks
POST /admin/profilerewrites the saved connector profile — tunnel, port, bash mode, write mode, tool mode, allowed roots — and it has no CSRF protection. At the same time the server runs a fully permissive CORS policy:cors()with no options reflects any origin. So while CodexPro is running, any page the operator's browser happens to visit canfetch()the admin endpoint cross-origin and change the connector's safety posture for the next launch. The token in the query string does not help: it is in the URL the operator pasted into ChatGPT, and it is not a CSRF defence in any case.The blast radius is a local dev connector's guardrails, and it grows the moment the HTTP transport is bound to anything other than loopback.
The permissive CORS is also unnecessary. Nothing reaches this server from browser JavaScript — ChatGPT calls
/mcpserver-side, and the onboarding page at/is same-origin with the admin form it posts to.exposedHeaders: ["Mcp-Session-Id"]only matters for a browser-based MCP client, which is not a supported topology here.The fix
Remove the CORS middleware and drop the direct
cors/@types/corsdependency pair. (corsremains in the tree transitively via@modelcontextprotocol/sdk; only the direct dependency goes.)Add two missing security headers next to the existing
no-store/no-referrer/nosniffblock:No page served by this server is meant to be framed, and the widget is served from
widgetDomain, not from here.Reject cross-origin admin writes with
403 origin_deniedvia asameOriginAdminRequestguard onPOST /admin/profile.Two deliberate details:
Requests with no
Originheader pass. That keepscurl, the CLI, and other non-browser clients working. It is safe becauseexpress.json()is the only body parser on this route: a browser cannot produce anapplication/jsonbody through a simple form post (form enctypes are limited to urlencoded / multipart / text-plain), and anything that can set the content type is a fetch/XHR, which always sendsOrigincross-origin.The guard compares hosts, not full origins. The obvious implementation is:
That misfires behind a proxy. CodexPro is routinely reached through a Cloudflare or ngrok tunnel, where TLS terminates at the edge and the request arrives at Express over plain HTTP. Without
trust proxy,req.protocolis"http"while the browser correctly reportsOrigin: https://<tunnel-host>— so every legitimate admin save through a tunnel would 403. TheHostheader, by contrast, is forwarded intact by every tunnel provider, so comparingnew URL(origin).hostagainstreq.get("host")is both correct behind a proxy and sufficient: a cross-site attacker cannot forgeOrigin, and same-host-different-scheme is not a meaningful attacker position for a locally bound dev server.A malformed
Originthat fails to parse is treated as cross-origin and rejected.Verification
npm run buildand the fullnpm run smokesuite pass.Added assertions to
scripts/http-smoke.mjs:Access-Control-Allow-Originon responsesPOST /admin/profilewithOrigin: https://attacker.examplereturns403/origin_deniedOriginis not rejected by the guard, so the onboarding form still works🤖 Generated with Claude Code