MCP Server HTTP Request Smuggling: CL.TE, TE.CL, and CONNECT Tunneling Behind Reverse Proxies
HTTP request smuggling exploits disagreement between a reverse proxy and the origin server about where one HTTP/1.1 request ends and the next begins. For MCP servers deployed behind Caddy, nginx, AWS ALB, or Traefik, the consequence is severe: an attacker who can route requests to the same server can inject tool call requests into other users' active sessions, bypass authentication headers added by the proxy, and hijack SSE streams mid-stream. This post walks through CL.TE, TE.CL, and TE.TE obfuscation variants, explains why the MCP /message and /sse endpoints make this particularly dangerous, and gives you the Node.js and proxy configuration fixes to eliminate the attack surface.
Why MCP servers are higher-risk than typical web apps: MCP servers keep HTTP/1.1 connections open for long periods (SSE streaming over /sse, long-polling over /message). Request smuggling in a long-lived shared connection means the attacker's smuggled prefix rides inside the victim's connection, injecting a forged tool call into the victim's session context — including any session token or tool-access scope already established.
The attack model: front-end and back-end disagree on request boundaries
HTTP/1.1 allows two mechanisms to specify how long a request body is: the Content-Length header (an exact byte count) and Transfer-Encoding: chunked (variable-length chunks, terminated by a zero-length chunk). RFC 7230 says that if both are present, Transfer-Encoding wins and Content-Length must be ignored. But real implementations disagree — and that disagreement is the attack.
In a typical MCP deployment: Claude Code → Caddy (reverse proxy) → Node.js MCP server. Caddy accepts requests from Claude Code over HTTP/2 or HTTPS/1.1. It then forwards those requests to the back-end Node.js process, often over a persistent HTTP/1.1 connection (connection pooling). Multiple sequential requests from multiple Claude sessions may share the same keep-alive connection between Caddy and Node.js. If an attacker can insert a request that causes Caddy and Node.js to disagree about where the first request ends, the remaining bytes become the start of the "next" request — a request that Node.js will attribute to whatever session sends the next real request.
Attack 1 — CL.TE: front-end uses Content-Length, back-end uses Transfer-Encoding
CL.TE — proxy trusts Content-Length, Node.js honors Transfer-Encoding
In this variant, the reverse proxy (Caddy, nginx with certain configs, AWS ALB) forwards the request based on the Content-Length value. The back-end Node.js MCP server sees the Transfer-Encoding: chunked header, honors it, and terminates reading when it encounters the 0\r\n\r\n chunk terminator — which arrives before Content-Length bytes have been consumed. The bytes remaining after the terminator are buffered in Node.js's HTTP parser and prepended to the next incoming request on that keep-alive connection.
The attacker sends a single HTTP request. The proxy forwards the full Content-Length bytes as one request. But Node.js reads only to the chunk terminator, leaving the attacker's crafted prefix sitting in the buffer as the start of the "next" request. The next real request from victim Claude session arrives on the same connection, and Node.js glues it onto the attacker's prefix — effectively injecting the attacker's chosen headers and body prefix into the victim's tool call.
What makes this particularly dangerous for MCP servers: the /message endpoint processes tool call JSON bodies, and the smuggled prefix can override the tool name, arguments, or session context before the victim's real message arrives. The Node.js MCP server sees what looks like a complete, valid tool call — it just happens to have the attacker's chosen parameters.
Attack 2 — TE.CL: front-end uses Transfer-Encoding, back-end uses Content-Length
TE.CL — proxy processes chunked encoding, Node.js stops at Content-Length
In the TE.CL variant, the front-end proxy honors Transfer-Encoding: chunked and dechunks the request before forwarding it to the back-end. The back-end Node.js process was configured to ignore Transfer-Encoding (or it was stripped by the proxy middleware) and reads exactly Content-Length bytes. The attacker specifies a small Content-Length that is shorter than the actual chunked body — Node.js reads only the first Content-Length bytes and considers the request done. The remaining bytes (the "large chunk" that the proxy dechunked) are left in the TCP receive buffer and interpreted by Node.js as the start of the next request.
This variant is common with AWS ALB, which dechunks requests before forwarding to EC2 targets. If the Node.js target reads Content-Length rather than chunked boundaries, the TE.CL condition exists. The smuggled suffix can contain a complete forged request that will be parsed as the next request on the reused connection from the victim's Claude session to the load balancer back-end target.
// Detecting TE.CL condition in Node.js: what to check
// TE.CL is present when:
// 1. Front-end proxy dechunks before forwarding (AWS ALB, HAProxy with dechunking)
// 2. Back-end reads Content-Length after proxy already processed chunked encoding
// 3. The combined effect: proxy forwards extra bytes, Node.js ignores them
// In your Node.js MCP server, add this diagnostic middleware to detect mismatches:
app.use((req, res, next) => {
const hasTE = req.headers['transfer-encoding'];
const hasCL = req.headers['content-length'];
// If both are present after proxy processing, you have a potential smuggling vector
if (hasTE && hasCL) {
// RFC 7230 §3.3.3: Transfer-Encoding overrides Content-Length
// If your proxy already dechunked and both are present, log and investigate
console.warn('Ambiguous framing headers detected', {
'transfer-encoding': hasTE,
'content-length': hasCL,
path: req.path,
remoteAddr: req.socket.remoteAddress,
});
// Safest: reject the request
res.status(400).json({ error: 'Ambiguous Content-Length and Transfer-Encoding' });
return;
}
next();
});
Attack 3 — TE.TE obfuscation: both headers present, one is deliberately malformed
The RFC is clear that if Transfer-Encoding is present, it wins. But what if an attacker sends a deliberately malformed Transfer-Encoding value — one that one implementation parses and another doesn't? This is TE.TE obfuscation. Examples that have worked against specific proxy/server combinations include:
Transfer-Encoding: xchunked # non-standard, some parsers accept
Transfer-Encoding:\x20chunked # leading space in header value
Transfer-Encoding: chunked, identity # comma-extension
Transfer-Encoding: chunked\x0d # trailing carriage return
X-Transfer-Encoding: chunked # non-standard header name, forwarded as-is
If Caddy normalizes Transfer-Encoding: xchunked and treats it as chunked, but Node.js's HTTP parser rejects the unknown value and falls back to Content-Length, the CL.TE condition is re-created even when the attacker cannot use a literal Transfer-Encoding: chunked header (e.g., because the proxy explicitly removes it). SkillAudit's scanner fuzzes TE header values against your MCP server's actual response to determine which variants create boundary disagreement.
Attack 4 — CONNECT tunneling through the proxy
CONNECT tunneling — establishing arbitrary byte channel through proxy to back-end
HTTP/1.1 CONNECT is designed for proxied HTTPS: the client asks the proxy to open a TCP tunnel to a target host:port, then sends TLS over that tunnel. Some reverse proxy configurations forward CONNECT requests to the back-end origin instead of handling them at the proxy layer. If the MCP server's Node.js HTTP parser accepts CONNECT without explicitly rejecting it, an attacker can establish a raw byte channel to the back-end — bypassing all proxy-level authentication, IP allowlisting, and header injection (like the X-Real-IP or X-Forwarded-User headers that carry session identity).
This is particularly impactful for MCP servers that trust proxy-injected headers for authentication. If the attacker tunnels directly past the proxy, those headers are absent, and the MCP server either errors (best case) or falls back to an unauthenticated state (catastrophic case).
// Node.js: explicitly reject CONNECT method before any route handling
import http from 'http';
const server = http.createServer(app);
// Reject CONNECT at the raw HTTP layer, before Express routing
server.on('connect', (req, clientSocket, head) => {
// CONNECT requests should never reach an MCP origin server
// They should be handled (or rejected) by the proxy
clientSocket.write('HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
clientSocket.destroy();
console.error('CONNECT tunnel attempt blocked', {
url: req.url,
remoteAddr: clientSocket.remoteAddress,
});
});
// Also reject at the Express layer as defense-in-depth
app.all('*', (req, res, next) => {
if (req.method === 'CONNECT') {
return res.status(405).set('Allow', 'GET, POST, DELETE').json({
error: 'CONNECT method not supported',
});
}
next();
});
Why MCP SSE endpoints amplify the risk
Standard web applications process each HTTP request independently — a smuggled prefix affects one response. MCP servers are different: the /sse endpoint establishes a long-lived streaming connection that carries all tool results back to the Claude client for the duration of the session. A smuggled request that arrives while a victim session has an active SSE connection causes Node.js to attribute the injected content to the victim's stream. The attacker's forged tool call response (or injected data: SSE event) is delivered to the victim's Claude session as if it came from a legitimate tool execution.
This is not a theoretical concern. The attack sequence:
GET /sse with Authorization: Bearer victim-token. Connection stays open. Caddy and Node.js share a keep-alive channel for this session.POST /message prefix as the smuggled suffix.POST /message\r\n…) is left in the TCP read buffer.POST /message with a JSON body. Node.js reads the victim's request, but the first bytes it sees are the attacker's smuggled headers — the victim's intended request is appended to the attacker's forged prefix.Defenses: Node.js configuration
Reject ambiguous framing at the Node.js HTTP layer
Node.js 18.10+ and 20+ include the --insecure-http-parser flag (disabled by default) and the opposite: the strict HTTP parser that rejects non-compliant requests. The secure default already rejects many TE.TE obfuscation variants. You can further harden by adding explicit validation middleware that blocks any request where both Content-Length and Transfer-Encoding are present simultaneously.
// middleware/reject-ambiguous-framing.js
// Mount as first middleware — before body parsers, auth, routing
export function rejectAmbiguousFraming(req, res, next) {
const te = req.headers['transfer-encoding'];
const cl = req.headers['content-length'];
// RFC 7230 §3.3.3: Transfer-Encoding and Content-Length MUST NOT coexist
if (te !== undefined && cl !== undefined) {
res.status(400).set('Connection', 'close').json({
error: 'invalid_request',
detail: 'Ambiguous message framing: both Content-Length and Transfer-Encoding present',
});
return;
}
// Reject chunked encoding from proxy-forwarded requests
// (Proxy should have dechunked before forwarding; chunked from a proxy is suspicious)
if (te && te.toLowerCase().includes('chunked') && req.socket.remoteAddress === PROXY_ADDR) {
// If proxy is supposed to dechunk, receiving chunked means the proxy didn't
// or an attacker bypassed the proxy and is speaking directly to the origin
res.status(400).set('Connection', 'close').json({
error: 'invalid_request',
detail: 'Unexpected chunked encoding from proxy address',
});
return;
}
next();
}
// Register BEFORE any other middleware:
import express from 'express';
const app = express();
app.use(rejectAmbiguousFraming); // must be first
Defenses: Caddy configuration
# Caddyfile — MCP server reverse proxy config
{
servers {
# Force HTTP/2 on both the front-end listener and the back-end connection
# HTTP/2 uses binary framing — request smuggling is an HTTP/1.1-only attack
protocols h2 h2c
}
}
skillaudit.dev {
# Enforce H2 to back-end (if Node.js supports H2C on internal interface)
reverse_proxy localhost:3000 {
transport http {
versions 2
}
# Strip any Transfer-Encoding header before forwarding (normalise to CL only)
header_up -Transfer-Encoding
}
# Reject CONNECT at the proxy layer — never forward to origin
@connect method CONNECT
respond @connect 405
# Strip hop-by-hop headers that smuggling attacks might inject
header_up -X-Forwarded-Host
header_up -X-Original-URL
header_up -X-Rewrite-URL
}
Defenses: nginx configuration
# nginx.conf fragment — MCP server upstream block
upstream mcp_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 443 ssl http2;
# ...
location / {
proxy_pass http://mcp_backend;
# Critical: use HTTP/1.1 to backend AND clear Connection header
# This prevents connection reuse from accumulating state across requests
proxy_http_version 1.1;
proxy_set_header Connection "";
# Explicitly strip both TE and CL — nginx rewrites these anyway,
# but belt-and-suspenders against custom nginx builds
proxy_set_header Transfer-Encoding "";
# Reject requests where both TE and CL are present before proxy_pass
set $ambiguous 0;
if ($http_transfer_encoding != "") { set $ambiguous "${ambiguous}1"; }
if ($http_content_length != "") { set $ambiguous "${ambiguous}1"; }
# nginx 'if' is limited — use map or lua for production-grade checks
# This pattern is illustrative; use OpenResty+Lua for strict enforcement
}
}
# In OpenResty (Lua) — production-grade CL+TE rejection:
# access_by_lua_block {
# if ngx.var.http_transfer_encoding ~= nil and ngx.var.http_content_length ~= nil then
# ngx.exit(ngx.HTTP_BAD_REQUEST)
# end
# }
Defenses: use HTTP/2 end-to-end
The most robust mitigation is to run HTTP/2 on every hop — from Claude Code to the proxy, and from the proxy to the Node.js origin. HTTP/2 uses a binary multiplexed framing protocol where request boundaries are defined by stream IDs, not header-based byte counts. There is no ambiguity to exploit. Request smuggling as described in this post is impossible when both the front-end and back-end speak HTTP/2.
Node.js supports HTTP/2 via the built-in http2 module or via h2c (cleartext HTTP/2) for internal connections. For MCP servers that only receive connections from a local proxy:
// Use Node.js native HTTP/2 for the MCP server
// This eliminates the CL vs TE ambiguity entirely — H2 has neither
import http2 from 'http2';
import fs from 'fs';
// For internal H2C (cleartext HTTP/2 — safe on localhost/private network)
const server = http2.createServer();
server.on('stream', (stream, headers) => {
const method = headers[':method'];
const path = headers[':path'];
// Route based on :method and :path pseudo-headers
// No Transfer-Encoding, no Content-Length ambiguity
if (method === 'POST' && path === '/message') {
handleToolCall(stream, headers);
return;
}
if (method === 'GET' && path === '/sse') {
handleSseStream(stream, headers);
return;
}
stream.respond({ ':status': 404 });
stream.end();
});
server.listen(3000, '127.0.0.1', () => {
console.log('MCP server listening on H2C localhost:3000');
});
SkillAudit findings for this vulnerability class
Proxy-specific quick-reference
| Proxy | Default behavior with both CL + TE | Risk | Fix |
|---|---|---|---|
| Caddy 2.x | Strips TE before forwarding; rewrites as CL. Generally safe. | Low | Add header_up -Transfer-Encoding; enable H2 transport to origin |
| nginx 1.24+ | Removes TE, recalculates CL. Safe for standard chunked. TE.TE obfuscation risk. | Medium | proxy_http_version 1.1; proxy_set_header Connection ""; + Lua CL+TE rejection |
| AWS ALB | Dechunks before forwarding, removes TE. Forwarded CL may mismatch dechunked body length. | Medium (TE.CL) | Enable H2 on target group; reject CL+TE in Node.js middleware |
| HAProxy 2.6+ | Rejects ambiguous requests by default in strict HTTP parsing mode. | Low (if strict mode) | Verify option http-buffer-request not set; enable option http-strict-mode |
| Traefik 3.x | Uses Go's net/http which processes TE; forwards dechunked with CL. | Medium (TE.CL to Node.js) | Add entryPoint transport configuration to reject malformed TE; use H2 ServersTransport |
Five-question self-audit
- Does your Node.js MCP server have any middleware that checks for simultaneous
Content-LengthandTransfer-Encodingheaders and returns400immediately? If not, send a request with both present and observe whether the server accepts it. Acceptance is the first indicator of a potential smuggling surface. - What version of Node.js is your MCP server running? Node.js 18.x (LTS) and 20.x use the strict HTTP parser by default, which rejects several TE.TE obfuscation variants. Older Node.js (<16) with the lenient parser is significantly more vulnerable. Check with
node --versionandnode -e "const h = require('_http_common'); console.log(h.parsers)". - Does your reverse proxy (Caddy, nginx, ALB) maintain persistent keep-alive connections to the Node.js origin? Persistent connections are the precondition for request smuggling — if the proxy opens a new connection for every request, there is no shared buffer to poison. Check your
upstream keepalive(nginx) ortransport http { keepalive … }(Caddy) settings. - Does your MCP server handle the HTTP
CONNECTmethod? Check withcurl -v -X CONNECT https://your-mcp-server.com/. If the response is not405 Method Not Allowed, your Node.js server is accepting CONNECT, and the proxy bypass path exists. - Does your proxy-to-origin channel use HTTP/2? Check with
curl -v --http2 http://localhost:3000/(direct to the Node.js port). If the response isHTTP/1.1, you are on an HTTP/1.1 keep-alive channel and the smuggling precondition exists. Upgrade the internal channel to H2C to eliminate it.
Request smuggling is one of the vulnerability classes that SkillAudit's scanner probes automatically. The scanner sends a test sequence of CL.TE and TE.CL probes against your MCP server's /message and /sse endpoints, measures response timing and body synchronisation, and flags any condition where the back-end processes more or fewer bytes than the declared Content-Length. The finding report includes a curl-reproducible proof-of-concept payload for each confirmed smuggling variant. Run a free audit at skillaudit.dev to check your MCP server's transport layer before deploying to Claude Code or listing in the Anthropic Skills Directory.