Security Reference

MCP server HTTP header injection: CRLF injection, response splitting, and redirect forgery

HTTP header injection exploits the fact that response headers are CR+LF delimited. If user-controlled input reaches a response header value without stripping \r\n, an attacker can forge additional headers — injecting Set-Cookie, overriding Content-Type, or splitting the HTTP response into two entirely separate responses.

The CRLF injection mechanism

HTTP/1.1 separates header lines with carriage-return + line-feed (\r\n). A response header block ends with a blank line (\r\n\r\n). If user input containing \r\n is embedded in a header value, the server appears to emit multiple headers — or terminates the header block early and starts a new body:

// VULNERABLE: redirect URL from user input — no CR/LF stripping
app.get('/mcp/auth/callback', (req, res) => {
  const returnTo = req.query.return_to;
  // If returnTo = "/home\r\nSet-Cookie: session=attacker"
  // The response header block becomes:
  //   Location: /home
  //   Set-Cookie: session=attacker
  res.redirect(returnTo);  // Express doesn't strip CRLF before Node 14.18
});

// SAFE: strip CR/LF before embedding in any header value
function sanitizeHeaderValue(val) {
  return String(val).replace(/[\r\n]/g, '');
}

app.get('/mcp/auth/callback', (req, res) => {
  const rawReturnTo = req.query.return_to || '/';
  const returnTo = sanitizeHeaderValue(rawReturnTo);

  // Also validate it's a relative path — don't allow open redirects
  if (!returnTo.startsWith('/') || returnTo.startsWith('//')) {
    return res.redirect('/');
  }
  res.redirect(returnTo);
});

Response splitting

Response splitting is CRLF injection carried further. By injecting a full \r\n\r\n sequence the attacker terminates the header block and controls the response body — allowing HTML injection for shared-proxy attacks:

// Injected input: "/page\r\n\r\nFake page with malware script"
// Result:
//   HTTP/1.1 302 Found
//   Location: /page
//
//   Fake page with malware script
//   HTTP/1.1 200 OK            ← second fabricated response follows in the stream
//   Content-Type: text/html
//   ...legitimate response...

// Modern Node.js (v14.18+) and Express v4+ throw on CRLF in headers:
// Error: Invalid character in header content
// — but older versions silently pass through, and some reverse proxies
// normalize responses before forwarding, allowing the split to survive

Dependency risk: If your MCP server runs behind a reverse proxy (nginx, Caddy) that terminates HTTP/1.1 and re-encodes to HTTP/2, the response splitting mechanism differs. HTTP/2 uses binary framing with no CRLF delimiters, but CRLF payloads can still corrupt logs and intermediate caches. Sanitize regardless of HTTP version.

Redirect header injection

MCP servers that implement OAuth flows, capability negotiation, or PKCE redirects frequently set Location headers based on request parameters. Validate strictly:

const ALLOWED_REDIRECT_HOSTS = new Set([
  'mcp.myserver.com',
  'localhost',
  '127.0.0.1'
]);

function safeRedirectURL(raw) {
  // Strip CRLF
  const cleaned = String(raw || '').replace(/[\r\n\t]/g, '');

  // Relative paths: allow
  if (cleaned.startsWith('/') && !cleaned.startsWith('//')) {
    return cleaned;
  }

  // Absolute URLs: only allowed hosts
  try {
    const u = new URL(cleaned);
    if (!ALLOWED_REDIRECT_HOSTS.has(u.hostname)) {
      throw new Error('Redirect host not allowed');
    }
    // Only allow https (no javascript:, data:, etc.)
    if (u.protocol !== 'https:' && u.protocol !== 'http:') {
      throw new Error('Redirect scheme not allowed');
    }
    return cleaned;
  } catch {
    return '/';  // Fallback to root on any validation failure
  }
}

app.get('/mcp/oauth/callback', (req, res) => {
  const target = safeRedirectURL(req.query.redirect_uri);
  res.redirect(302, target);
});

Content-Type injection via filename reflection

MCP file tools that set Content-Disposition with user-controlled filenames are vulnerable to header injection through the filename parameter:

// VULNERABLE: filename from user input embedded in Content-Disposition
app.get('/mcp/download', (req, res) => {
  const filename = req.query.name;
  // Input: "report.pdf\r\nContent-Type: text/html\r\nX-Injected: yes"
  res.set('Content-Disposition', `attachment; filename="${filename}"`);
  res.send(fileContent);
});

// SAFE: strip CRLF, quote-encode the filename, strip path separators
function sanitizeFilename(raw) {
  return String(raw)
    .replace(/[\r\n\t"\\\/]/g, '_')  // strip unsafe chars
    .slice(0, 255);                   // length cap
}

app.get('/mcp/download', (req, res) => {
  const filename = sanitizeFilename(req.query.name || 'file');
  res.set({
    'Content-Disposition': `attachment; filename="${filename}"`,
    'Content-Type': 'application/octet-stream'  // explicit — never inherit
  });
  res.send(fileContent);
});

Log injection via reflected headers

MCP servers commonly log the User-Agent, X-Request-ID, or Referer headers for debugging. These fields accept arbitrary strings from the client. A CRLF payload in a logged header can forge log entries, corrupt log parsers, or inject ANSI escape codes that produce misleading terminal output:

// VULNERABLE: logging raw User-Agent header
app.use((req, _res, next) => {
  console.log(`[MCP] ${req.method} ${req.path} ua=${req.headers['user-agent']}`);
  // Input: "claude-code\r\n[MCP] POST /mcp/delete-all ua=attacker"
  // Log shows a fabricated delete-all line with attacker identity
  next();
});

// SAFE: sanitize logged header values
function sanitizeLogValue(val, maxLen = 200) {
  return String(val || '')
    .replace(/[\r\n\x00-\x1f\x7f]/g, ' ')  // strip control chars
    .slice(0, maxLen);
}

app.use((req, _res, next) => {
  const ua = sanitizeLogValue(req.headers['user-agent']);
  const reqId = sanitizeLogValue(req.headers['x-request-id']);
  console.log(`[MCP] ${req.method} ${req.path} ua=${ua} rid=${reqId}`);
  next();
});

SkillAudit grading criteria

FindingSeverityScore impact
User input embedded in Location/Set-Cookie/Content-Disposition without CRLF strippingHIGH−18
Open redirect via user-controlled Location headerHIGH−15
Raw request headers logged without control-character sanitizationMEDIUM−8
User-controlled filename in Content-Disposition without encodingMEDIUM−8
CRLF stripping on all header values derived from user inputPASS+6
Redirect allowlist limiting Location to known originsPASS+5

Related SkillAudit checks