Topic: MCP server CRLF injection security

MCP server CRLF injection — response header splitting attacks

HTTP response headers are terminated by \r\n (CRLF — carriage return + line feed). When an MCP server reflects user-controlled values into HTTP response headers without stripping or encoding these characters, an attacker can inject additional headers — or entire synthetic HTTP responses — into the HTTP stream. This attack, called HTTP response splitting, enables cookie injection, cache poisoning, cross-site scripting, and session fixation. Modern Node.js and Python HTTP frameworks largely prevent this at the library level, but custom header construction code remains vulnerable.

How CRLF injection works

HTTP/1.1 headers are separated by \r\n and the header section ends at \r\n\r\n. If user-controlled content is placed in a header value and the value contains \r\n characters, the attacker controls where the next header begins:

// Vulnerable: reflects user input into a redirect Location header
app.get('/tools/authenticate', (req, res) => {
  const returnUrl = req.query.return_url;
  // returnUrl is user-controlled and not sanitized
  res.setHeader('Location', returnUrl);
  res.status(302).send();
});

An attacker sends:

GET /tools/authenticate?return_url=https://mcp.corp.internal/%0d%0aSet-Cookie:%20mcp_session=attacker_session%0d%0a HTTP/1.1

%0d%0a is the URL-encoded form of \r\n. The resulting response headers:

HTTP/1.1 302 Found
Location: https://mcp.corp.internal/
Set-Cookie: mcp_session=attacker_session
Content-Type: text/html

The browser receives an injected Set-Cookie header, setting the victim's session to the attacker's known value (session fixation). When the victim later authenticates, their session is overwritten — the attacker already knows the token.

Response splitting for cache poisoning

With a double CRLF injection (\r\n\r\n), an attacker can inject a complete synthetic HTTP response into the stream, poisoning shared caches:

# URL-encoded injection that inserts a full synthetic response
# %0d%0a = \r\n, %20 = space

GET /api/resource?id=123%0d%0aContent-Length:%2044%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Length:%2039%0d%0a%0d%0a%3cscript%3ealert(document.cookie)%3c/script%3e HTTP/1.1

Some HTTP/1.1 intermediary proxies interpret this as two responses: the first (real) response ending at the injected Content-Length: 44, and the second (synthetic) response beginning with the attacker's HTTP/1.1 200 OK. If the second synthetic response is cached for a popular URL, all subsequent users receive the XSS payload from the cache.

Where MCP servers are vulnerable

The specific MCP server code patterns that introduce CRLF injection risk:

1. Reflection in redirect URLs

// Pattern seen in OAuth callback handlers
app.get('/oauth/callback', (req, res) => {
  const state = req.query.state; // contains return URL — user-controlled
  // Vulnerable: state is reflected directly into Location
  res.redirect(302, state);
});

// Fix: validate and allowlist the return URL before redirecting
const ALLOWED_RETURN_PATHS = ['/tools', '/dashboard', '/'];

app.get('/oauth/callback', (req, res) => {
  const state = req.query.state;
  const safeReturn = ALLOWED_RETURN_PATHS.includes(state) ? state : '/';
  res.redirect(302, safeReturn);
});

2. Custom header construction from tool arguments

// Vulnerable: MCP server passes tool arguments into custom response headers
app.post('/tools/call', authenticate, async (req, res) => {
  const { name, arguments: args } = req.body.params;
  const result = await callTool(name, args);

  // Dangerous: reflects tool name (attacker-controlled) into X-Tool-Called header
  res.setHeader('X-Tool-Called', name);
  res.json(result);
});

// Fix: sanitize before setting any response header
function sanitizeHeaderValue(value: string): string {
  // Remove all CR, LF, and null bytes
  return value.replace(/[\r\n\x00]/g, '');
}

res.setHeader('X-Tool-Called', sanitizeHeaderValue(name));

3. Log correlation headers

// Some MCP servers echo back correlation IDs for distributed tracing
app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] || generateId();
  // Vulnerable: echoes user-provided request ID
  res.setHeader('X-Request-Id', requestId);
  next();
});

// Fix: validate the request ID format before echoing
const REQUEST_ID_PATTERN = /^[a-zA-Z0-9\-]{8,64}$/;

app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'];
  const safeId = (requestId && REQUEST_ID_PATTERN.test(requestId))
    ? requestId
    : generateId();
  res.setHeader('X-Request-Id', safeId);
  next();
});

Framework-level protections (and their limits)

Modern HTTP frameworks provide partial protection:

The Node.js TypeError means a vulnerable pattern still causes a request failure — visible in logs — but does not silently succeed. In HTTP/2 deployments, CRLF is not the header separator, so the attack vector is reduced (headers are binary-framed). However, deployments often involve an HTTP/1.1 edge proxy that downgrades to H/1.1 for origin communication, re-introducing the risk.

SkillAudit detection

SkillAudit's static analysis checks for:

CRLF injection is a Medium-severity finding (High if the injection is in Set-Cookie or Location headers, where session fixation or cache poisoning impact is direct).

Check your MCP server's HTTP response headers for CRLF injection vectors.

Run a free audit →