Skip to content

fix: drop permissive CORS, add security headers and same-origin guard on POST /admin/profile - #95

Open
lyeith wants to merge 1 commit into
rebel0789:mainfrom
lyeith:fix/admin-csrf-headers
Open

fix: drop permissive CORS, add security headers and same-origin guard on POST /admin/profile#95
lyeith wants to merge 1 commit into
rebel0789:mainfrom
lyeith:fix/admin-csrf-headers

Conversation

@lyeith

@lyeith lyeith commented Aug 17, 2026

Copy link
Copy Markdown

What breaks

POST /admin/profile rewrites 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:

app.use(cors({ exposedHeaders: ["Mcp-Session-Id"] }));

cors() with no options reflects any origin. So while CodexPro is running, any page the operator's browser happens to visit can fetch() 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 /mcp server-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/cors dependency pair. (cors remains 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 / nosniff block:

X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=()

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_denied via a sameOriginAdminRequest guard on POST /admin/profile.

Two deliberate details:

Requests with no Origin header pass. That keeps curl, the CLI, and other non-browser clients working. It is safe because express.json() is the only body parser on this route: a browser cannot produce an application/json body 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 sends Origin cross-origin.

The guard compares hosts, not full origins. The obvious implementation is:

const expected = `${req.protocol}://${req.get("host")}`;
if (origin !== expected) { /* 403 */ }

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.protocol is "http" while the browser correctly reports Origin: https://<tunnel-host> — so every legitimate admin save through a tunnel would 403. The Host header, by contrast, is forwarded intact by every tunnel provider, so comparing new URL(origin).host against req.get("host") is both correct behind a proxy and sufficient: a cross-site attacker cannot forge Origin, and same-host-different-scheme is not a meaningful attacker position for a locally bound dev server.

A malformed Origin that fails to parse is treated as cross-origin and rejected.

Verification

npm run build and the full npm run smoke suite pass.

Added assertions to scripts/http-smoke.mjs:

  • all five security headers present with the expected values
  • no Access-Control-Allow-Origin on responses
  • POST /admin/profile with Origin: https://attacker.example returns 403 / origin_denied
  • the same POST with a matching Origin is not rejected by the guard, so the onboarding form still works

🤖 Generated with Claude Code

… 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.
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.

1 participant