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
- LLM-supplied string interpolated into fetch() header values without CRLF stripping — HIGH; header injection and potential credential exfiltration
- LLM-supplied URL used directly in proxy-style fetch, including host component — HIGH; host header manipulation and SSRF
- X-Forwarded-For header trusted without source IP check — WARN; IP-based access control bypass
- Tool output or LLM-supplied argument reflected into HTTP response headers — WARN; CRLF header injection in responses
See also
- MCP server SSRF — SSRF patterns in MCP tool handlers
- MCP server input validation — Zod schema validation for tool arguments
- MCP server CORS security — CORS configuration for HTTP-transport MCP servers
- MCP server request validation — request body and header validation patterns
- MCP server security checklist — comprehensive pre-submission checklist
Check your MCP server for header injection, CRLF splitting, and host header manipulation findings.
Run a free audit → How grading works →