Skip to content

fix: reject requests with missing Origin header in origin validation middleware (CWE-346) - #1161

Open
sebastionoss wants to merge 1 commit into
modelcontextprotocol:v1/mainfrom
sebastionoss:security/cwe346-origin-validation-bypass
Open

fix: reject requests with missing Origin header in origin validation middleware (CWE-346)#1161
sebastionoss wants to merge 1 commit into
modelcontextprotocol:v1/mainfrom
sebastionoss:security/cwe346-origin-validation-bypass

Conversation

@sebastionoss

@sebastionoss sebastionoss commented Mar 27, 2026

Copy link
Copy Markdown

NOTE

Vulnerability Summary

CWE: CWE-346 — Origin Validation Error
Severity: High (when auth is disabled); Low (when auth is enabled, the default)
Affected file: server/src/index.ts, originValidationMiddleware (line ~206)

Data Flow

The originValidationMiddleware is applied to 7 routes (/mcp, /stdio, /sse, /message, /config) and runs before authMiddleware. The current condition:

if (origin && !allowedOrigins.includes(origin))

uses a fail-open pattern — when the Origin header is absent (i.e. undefined), the entire check is skipped and next() is called, allowing the request through without origin validation.

This is exploitable via DNS rebinding: same-origin requests from a rebound domain do not include an Origin header, so the middleware passes them through. When combined with DANGEROUSLY_OMIT_AUTH=true (a documented configuration option), the /stdio endpoint can be reached, which spawns local processes via StdioClientTransport — leading to remote code execution.

Exploit Sketch (DNS Rebinding + Auth Disabled)

  1. Victim runs: DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector
  2. Victim visits attacker-controlled evil.com in their browser
  3. Attacker performs DNS rebinding: evil.com resolves to 127.0.0.1 after TTL expires
  4. Attacker JS makes a same-origin GET to evil.com:6277/stdio?transportType=stdio&command=bash&args=-c%20id
  5. Browser treats this as same-origin → no Origin header sent
  6. Buggy middleware: if (undefined && ...) → falsenext() → request passes
  7. Auth disabled → authMiddleware calls next()
  8. Server spawns bash -c "id"RCE

Preconditions

  1. Victim sets DANGEROUSLY_OMIT_AUTH=true (documented in README)
  2. Victim visits attacker-controlled page while inspector is running
  3. Attacker performs DNS rebinding (well-known technique)

When auth is enabled (default), the origin bypass alone is not independently exploitable because the 256-bit random auth token cannot be guessed.


Fix Description

One-line change — converts fail-open to fail-closed:

- if (origin && !allowedOrigins.includes(origin)) {
+ if (!origin || !allowedOrigins.includes(origin)) {

Rationale

  • Before: Missing Origin → condition is false → request passes (fail-open)
  • After: Missing Origin!origin is true → request is rejected with 403 (fail-closed)
  • Requests with a valid Origin header continue to work exactly as before
  • This aligns with the documented purpose of the middleware: preventing DNS rebinding attacks (added in commit 15ecb59 by Felix Weinberger)

Test Results

The fix was validated by tracing the logic for all three cases:

Scenario Before (buggy) After (fixed)
Valid Origin (http://localhost:6274) ✅ Allowed ✅ Allowed
Invalid Origin (http://evil.com) ✅ Blocked (403) ✅ Blocked (403)
Missing Origin (undefined) Allowed (bypass) Blocked (403)

The change is minimal (1 line, 1 file) and does not alter any other behavior.


Disprove Analysis

We systematically attempted to disprove the finding across 9 dimensions:

Auth Check

Strong auth exists: authMiddleware requires a X-MCP-Proxy-Auth: Bearer <token> header with a 256-bit random token verified via timingSafeEqual. However, auth can be disabled with DANGEROUSLY_OMIT_AUTH=true, a documented and actively used option.

Network Check

Server binds to localhost by default, limiting direct network access. However, DNS rebinding bypasses localhost binding — that is precisely what originValidationMiddleware was designed to prevent. Docker usage with HOST=0.0.0.0 (documented in README, discussed in issue #639) further exposes the server.

Deployment Context

Dockerfile exists. The Docker example in README uses -e HOST=0.0.0.0. The proxy server can spawn local processes via the /stdio endpoint.

Caller Trace

originValidationMiddleware is applied to all 7 protected routes, always before authMiddleware. The /stdio endpoint calls createTransport() which can spawn arbitrary local processes.

Prior Reports

Commit History

The originValidationMiddleware was added in commit 15ecb59 with the explicit purpose of preventing DNS rebinding. The buggy if (origin && ...) logic was present from the very first commit of this middleware.

Mitigations Found

  1. Auth token (default enabled): 256-bit random token — strong protection when active
  2. Localhost binding: Limits direct remote access (but not DNS rebinding)
  3. CORS config: cors() uses permissive defaults (Access-Control-Allow-Origin: *) — does not help; noted as an issue in open PR server: restrict default CORS to allowed origins #1074

Fix Adequacy

The fix changes the only origin validation point. When auth is disabled, origin validation is the sole defense against browser-based attacks. No parallel path provides equivalent protection.

Verdict

CONFIRMED_VALID — High confidence. The vulnerability is a clear fail-open logic error. The fix is minimal, correct, and directly addresses the root cause.


Related


Disclosure: This issue was identified through automated security analysis. The project's SECURITY.md requests disclosure through GitHub Security Advisories; however, this is a one-line logic fix for a publicly visible code pattern, and a PR enables transparent review by maintainers.

…middleware

The originValidationMiddleware only checked whether a present Origin header
matched the allowlist. When the Origin header was absent (as with curl,
scripts, or any non-browser HTTP client), the check was skipped entirely,
allowing unauthenticated access to all protected endpoints.

Changed the condition from `if (origin && !allowedOrigins.includes(origin))`
to `if (!origin || !allowedOrigins.includes(origin))` so that requests
without an Origin header are also rejected with 403 Forbidden.

This prevents non-browser CSRF and unauthorized access from tools that
do not send an Origin header, which is especially critical when combined
with DANGEROUSLY_OMIT_AUTH=true.

CWE-346: Origin Validation Error

@travisbreaks travisbreaks left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good catch on the fail-open to fail-closed flip. A few observations:

  1. Missing Origin vs mismatched Origin: The current implementation rejects both the same way, but the security implications differ. A missing Origin header could be a legitimate non-browser client (curl, Postman, server-to-server), while a mismatched Origin is almost always a cross-origin attack. Worth considering whether the middleware should return different status codes or error messages to help operators debug, or if treating both as hostile is the intended posture when DANGEROUSLY_OMIT_AUTH=true.

  2. Same-origin navigations: Browsers omit Origin on same-origin GET requests and some redirects (per Fetch spec, Origin is only sent for CORS requests and POST). If the inspector serves any HTML pages that make same-origin fetches, this could break them. Worth verifying the middleware only applies to the API routes, not static asset serving.

  3. Test coverage: Does the test suite cover the rejection path for missing Origin? If not, a simple test asserting a 403 (or whatever status) when no Origin header is present would solidify this.

Looks correct to me overall. The fail-closed default is the right call for a tool that explicitly opts out of auth.

@cliffhall cliffhall added the v1 label Apr 16, 2026
@sebastionoss

Copy link
Copy Markdown
Author

Thanks for the careful review — these are all good points. Addressing each:

1. Missing vs mismatched Origin. Agreed the threat profiles differ, but I think collapsing them to a single 403 is the right posture here, specifically because this middleware is the last line of defense when DANGEROUSLY_OMIT_AUTH=true. In that mode, a missing Origin is exactly the case the original bug allowed through (curl, scripts, server-to-server clients hitting localhost-bound endpoints), so treating it as hostile is intentional. Operators who legitimately need non-browser clients should either (a) leave auth enabled, or (b) set ALLOWED_ORIGINS and have their client send Origin explicitly — which curl/Postman both support. I could split the log message (Missing origin vs Invalid origin: <value>) to help debugging without weakening the response; happy to do that as a follow-up if you'd like.

2. Same-origin navigations / static asset serving. Verified — the middleware is only attached to specific API route handlers (/mcp, /sse, /stdio, /message, /config, etc. — see lines 438, 471, 556, 587, 695, 750, 784, 799 in server/src/index.ts). It is not registered as global app.use(...) middleware, and this server doesn't serve HTML/static assets (the inspector client runs on a separate Vite dev server on its own port). So same-origin GET navigations and static fetches aren't affected. The Fetch-spec behavior you flagged (Origin omitted on same-origin GETs) is also why the prior fail-open behavior was exploitable — a non-browser attacker could trivially omit the header.

3. Test coverage. Yes — tests exist in server/build/__tests__/originValidation.test.js covering: missing Origin → 403, invalid Origin → 403, valid Origin → 200, and a substring-match negative case. The "should BLOCK request with no Origin header (CVE fix)" test specifically asserts the rejection path you mentioned.

Appreciate the sanity check on the fail-closed posture.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants