Topic: mcp server header injection

MCP server header injection — HTTP header splitting and CRLF attacks

MCP servers that construct HTTP headers from LLM-supplied tool arguments are vulnerable to header injection. The classic attack is CRLF injection: if an argument value contains \r\n, the attacker can inject arbitrary HTTP headers into outbound requests or responses. In MCP context, header injection can exfiltrate credentials (by injecting an Authorization header pointing to an attacker-controlled host), bypass access controls, or perform cache poisoning attacks on downstream proxies. This page covers four header injection patterns with detection and defenses.

Attack 1: CRLF injection in outbound request headers

HTTP headers are delimited by \r\n. If a LLM-supplied string that contains \r\n is concatenated into a header value, the injected newlines terminate the current header and start a new one. An attacker-controlled LLM call with a crafted argument can inject headers like Authorization: Bearer attacker-token into outbound API requests, redirecting authentication to the attacker's server.

// WRONG: LLM-supplied value interpolated into header — CRLF injection vector
server.tool('fetch_with_trace', 'Fetch a URL with a custom trace header', {
  url: z.string().url().max(2000),
  traceId: z.string().max(200),  // No CRLF check
}, async ({ url, traceId }) => {
  // Attack: traceId = 'abc\r\nAuthorization: Bearer stolen-token\r\nX-Evil: injected'
  const res = await fetch(url, {
    headers: {
      'X-Trace-Id': traceId,  // CRLF in traceId splits the header
      'Authorization': `Bearer ${process.env.API_TOKEN}`,
    },
  })
  return { content: [{ type: 'text', text: await res.text() }] }
})

// CORRECT 1: Strip CRLF characters before using in headers
function sanitizeHeaderValue(value: string): string {
  // Remove all carriage return and line feed characters
  return value.replace(/[\r\n]/g, '')
}

server.tool('fetch_with_trace', 'Fetch a URL with a custom trace header', {
  // Allowlist-style regex: only allow safe header value characters
  traceId: z
    .string()
    .max(128)
    .regex(/^[a-zA-Z0-9_\-\.]{1,128}$/, 'Trace ID must be alphanumeric, dashes, dots only'),
}, async ({ url, traceId }) => {
  const safeUrl = assertSafeUrl(url)
  const res = await fetch(safeUrl.toString(), {
    headers: {
      'X-Trace-Id': sanitizeHeaderValue(traceId),  // Belt-and-suspenders
      'Authorization': `Bearer ${process.env.API_TOKEN}`,
    },
  })
  return { content: [{ type: 'text', text: await res.text() }] }
})

// CORRECT 2 (preferred): use a regex allowlist in the Zod schema
// that makes CRLF injection structurally impossible — no sanitization needed
const TraceIdSchema = z.string().regex(/^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$/)
// This regex physically cannot match strings containing \r or \n

Attack 2: Host header manipulation in proxy-style tools

MCP tools that act as HTTP proxies — accepting a URL argument and forwarding the request to a backend — can be manipulated to send requests with a crafted Host header. This enables host header injection attacks: the backend application uses the Host header to generate password reset links, canonical URLs, or CSRF tokens, and an attacker-controlled value poisons these outputs.

// WRONG: URL argument includes host — LLM controls the Host header implicitly
server.tool('proxy_request', 'Forward a request to a backend', {
  targetUrl: z.string().url().max(2000),
  path: z.string().max(500),
}, async ({ targetUrl, path }) => {
  // LLM can supply targetUrl = 'https://attacker.com' + path pointing to internal service
  const res = await fetch(`${targetUrl}${path}`)
  return { content: [{ type: 'text', text: await res.text() }] }
})

// CORRECT: fix the host, accept only path from LLM
const BACKEND_BASE = 'https://internal-api.example.com'  // Fixed, not LLM-controlled

server.tool('proxy_request', 'Forward a request to the internal API', {
  // Only accept a safe path component — host is always the fixed backend
  path: z
    .string()
    .regex(/^\/[a-zA-Z0-9\/_\-\.]{0,499}$/)
    .refine(p => !p.includes('..'), 'no traversal'),
}, async ({ path }) => {
  // Host is always BACKEND_BASE — LLM cannot substitute a different host
  const res = await fetch(`${BACKEND_BASE}${path}`, {
    signal: AbortSignal.timeout(10_000),
  })
  return { content: [{ type: 'text', text: await res.text() }] }
})

Attack 3: X-Forwarded-For spoofing for IP-based access control bypass

HTTP-transport MCP servers that sit behind a load balancer often implement IP-based access control using X-Forwarded-For headers to get the client's real IP. If the server trusts this header from any source (not just from the load balancer), an attacker can send a request directly to the MCP server with a spoofed X-Forwarded-For: 127.0.0.1 header, bypassing the IP allowlist.

import express from 'express'

// WRONG: trust X-Forwarded-For from any source
app.use((req, res, next) => {
  const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress
  if (!isAllowedIp(clientIp as string)) {
    return res.status(403).json({ error: 'IP not allowed' })
  }
  next()
})
// Attack: send 'X-Forwarded-For: 127.0.0.1' directly to the server — bypasses allowlist

// CORRECT: only trust X-Forwarded-For from known proxy IPs
const TRUSTED_PROXY_IPS = new Set([
  '10.0.0.1',   // Your load balancer's internal IP
  '10.0.0.2',
])

app.use((req, res, next) => {
  const remoteIp = req.socket.remoteAddress || ''

  let clientIp: string
  if (TRUSTED_PROXY_IPS.has(remoteIp)) {
    // Only parse X-Forwarded-For when the request comes from a trusted proxy
    const forwarded = req.headers['x-forwarded-for']
    clientIp = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0]?.trim() || remoteIp
  } else {
    // For direct connections, use the socket IP — ignore X-Forwarded-For
    clientIp = remoteIp
  }

  if (!isAllowedIp(clientIp)) {
    return res.status(403).json({ error: 'IP not allowed' })
  }
  next()
})

// Alternative: use express's built-in trust proxy setting
// app.set('trust proxy', ['10.0.0.1', '10.0.0.2'])
// Then req.ip is set correctly based on X-Forwarded-For only from trusted proxies

Attack 4: Response header injection in HTTP-transport MCP servers

HTTP-transport MCP servers that reflect tool arguments into HTTP response headers (e.g., for CORS, caching, or tracing) can be used to inject arbitrary headers into browser responses, enabling cache poisoning, CORS bypass, or session fixation.

// WRONG: reflect LLM-supplied value into response headers
app.post('/mcp/tool/result', (req, res) => {
  const { traceId, result } = req.body
  // Attack: traceId = 'x\r\nSet-Cookie: session=attacker-value; Path=/'
  res.setHeader('X-Trace-Id', traceId)  // CRLF in traceId sets a fake cookie
  res.json({ result })
})

// CORRECT: validate header values before setting them, or don't reflect them at all
const SAFE_TRACE_RE = /^[a-zA-Z0-9_\-\.]{1,128}$/

app.post('/mcp/tool/result', (req, res) => {
  const { traceId, result } = req.body

  if (typeof traceId === 'string' && SAFE_TRACE_RE.test(traceId)) {
    res.setHeader('X-Trace-Id', traceId)
  }
  // If traceId is not safe, simply omit the header rather than throwing
  res.json({ result })
})

What SkillAudit checks

See also

Check your MCP server for header injection, CRLF splitting, and host header manipulation findings.

Run a free audit → How grading works →