REQUEST SMUGGLING, CRLF SPLITTING, CACHE POISONING
Three attacks that live between your reverse proxy and your app server. The AI generator doesn't think about them because the bug isn't in app code — it's in the disagreement between two HTTP parsers about where one request ends and the next begins.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.
Bugs that live in disagreement
The three bugs in this article all share a structure: two pieces of HTTP-handling software disagree about something, and the attacker exploits the disagreement. Request smuggling lives in disagreement about where one request ends. CRLF splitting lives in disagreement about whether a header value can contain newlines. Cache poisoning lives in disagreement about which headers affect the response.
AI-generated app code rarely introduces these directly. What it does is fail to mitigate them at the app layer, and trust the proxy to do the right thing — which the proxy may or may not be configured to do.
Request smuggling
The classic CL.TE attack. The proxy reads Content-Length: 13. The app reads Transfer-Encoding: chunked. The body is crafted such that the proxy thinks one request ends after 13 bytes, and the app thinks the request goes longer. The “extra” bytes that the proxy sees as the next request, the app sees as a continuation of the first.
Smuggled requests bypass the proxy’s auth checks (because they were never seen as separate requests). They can also poison the next user’s connection — a common cloud setup is keep-alive between proxy and app, where the smuggled prefix attaches to the next legitimate user’s request.
The fix is at the proxy and app layer simultaneously:
- Use a proxy that strips ambiguous headers (modern nginx, modern Cloudflare). Reject any request that has both
Content-LengthandTransfer-Encodingheaders. - Use an app server with the latest body-parsing code. Express has had several smuggling-related advisories; keep dependencies current.
- Disable HTTP/1.1 keep-alive between proxy and app, or at minimum use HTTP/2 between them — HTTP/2 doesn’t have the same parser-disagreement issue.
Live: https://gapbench.vibe-eval.com/site/request-smuggling/.
CRLF response splitting
app.get('/redirect', (req, res) => {
res.setHeader('Location', req.query.url)
res.status(302).end()
})
If the user supplies ?url=/dashboard%0d%0aSet-Cookie: session=hijacked, the response now has an injected Set-Cookie header. The browser respects it. The session is now whatever the attacker chose.
Modern frameworks neutralize this for known headers — res.redirect in Express filters CRLF. But:
- Custom header construction (
res.setHeader('Custom-Header', userInput)) doesn’t always. - Older frameworks or unusual middleware may pass user input through directly.
- AI-generated code that builds headers manually, especially in Go or Python frameworks where the contract isn’t as clear, often misses the filter.
Fix: use the framework’s built-in redirect/cookie helpers, not raw setHeader. If you must build headers manually, validate or strip \r and \n from user input.
Live: https://gapbench.vibe-eval.com/site/crlf-response-splitting/.
Cache poisoning
A request hits your CDN. The CDN keys its cache on URL + a small set of headers (Vary). The request includes:
Host: your-site.com
X-Forwarded-Host: attacker.example
Your app reads X-Forwarded-Host to construct internal links (because some framework or middleware does so). The response body contains URLs that point at attacker.example. The CDN caches this response under the URL your-site.com/page because it doesn’t include X-Forwarded-Host in its Vary key. Every subsequent visitor to that URL gets the poisoned response with attacker URLs.
The same shape with other unkeyed headers: X-Original-URL, X-Forwarded-Server, X-Rewrite-URL. Each one a potential vector if the app reads it and the cache ignores it.
Fix:
- Don’t let your app trust headers the CDN doesn’t key on. If
X-Forwarded-Hostmatters, the CDN’s Vary must include it. - Better: don’t trust those headers at all. Pin your app’s idea of the host to a config value.
- Disable caching for any path that varies based on headers your CDN can’t key. Authenticated paths usually shouldn’t be cached at the CDN at all.
Live: https://gapbench.vibe-eval.com/site/cache-poisoning/.
A specific incident — cache poisoning via X-Forwarded-Host
Anonymized. A SaaS used Cloudflare as the CDN and an Express app behind it. The Express app read X-Forwarded-Host to construct internal links — for emails, for OG tags, for “share this page” buttons. Cloudflare’s default cache key is URL + the headers in Vary. X-Forwarded-Host was not in Vary.
Attacker sent:
GET /article/popular-post HTTP/1.1
Host: legitimate-site.com
X-Forwarded-Host: attacker.example
Express built the rendered HTML with attacker.example in the canonical link, the OG image URL, and the share buttons. Cloudflare cached the response under legitimate-site.com/article/popular-post. Subsequent visitors to that URL got back the poisoned response — content served from the legitimate site, with attacker’s URLs in the OG tags. The attacker’s URL hosted a phishing clone.
Cache TTL was 5 minutes, so the poisoning was self-healing. But during that 5-minute window, a popular post got ~300 visitors all served the poisoned version, all with the attacker’s URLs in social shares. The team noticed because their analytics showed an unusual spike in outbound clicks to a domain they didn’t own.
The cleanup: stop reading X-Forwarded-Host (use Host plus an explicit allowlist), add X-Forwarded-Host to Cloudflare’s Vary header for any path that needed it, audit other unkeyed-header reads. The deeper fix was teaching the team that any header the CDN ignores is effectively user-controlled, and the app shouldn’t trust them.
A taxonomy of unkeyed headers
Beyond X-Forwarded-Host, the headers worth auditing:
X-Forwarded-Server— same shape, less common, equally dangerous if read.X-Original-URL,X-Rewrite-URL— used by some frameworks for routing. Attacker-controllable in some configs.X-Forwarded-For— used for client IP. Trusting it for rate-limiting or auth without atrust proxyconfig gives the attacker control of “their” IP.X-HTTP-Method-Override— used by some frameworks to override the HTTP method via header. Lets attacker turn a GET into a POST/DELETE in some setups.Hostheader itself — covered in host-header-injection for password-reset poisoning. Same root cause.
Anywhere you read these and the CDN/proxy doesn’t normalize them, you have a potential bug.
Smuggling — the modern variants
CL.TE and TE.CL are the classic. Modern proxies are mostly hardened against both. The variants that still land:
- HTTP/1.1 vs HTTP/2 downgrade smuggling. Frontend speaks HTTP/2, backend speaks HTTP/1.1, the translation between them is occasionally lossy. James Kettle’s research has documented several variants over the past few years.
- TE.TE smuggling. Both proxy and app see
Transfer-Encoding, but they parse it differently (one treatsTransfer-Encoding: chunked, X-Stuffas chunked, the other as unknown). - Header-name encoding. Proxy treats
Transfer-Encodingas the header but the app treatstransfer-encoding(lowercased) only — case sensitivity divergence. - Pseudo-header smuggling in HTTP/2. Specific to certain proxy implementations.
The detection landscape evolves with the published research. The mitigation is universal: keep proxies updated, keep app servers updated, prefer HTTP/2 end-to-end if your stack supports it.
Wrong fix vs right fix
// WRONG: trust X-Forwarded-Host blindly
const host = req.headers['x-forwarded-host'] || req.headers.host
const link = `https://${host}/share?id=${id}`
// WRONG: Set CDN to vary on X-Forwarded-Host
// This works but inflates cache key cardinality, bad for cache hit rate
// RIGHT: pin host to a config value
const TRUSTED_HOST = process.env.PUBLIC_HOST // 'example.com'
const link = `https://${TRUSTED_HOST}/share?id=${id}`
// Doesn't matter what header arrives; the URL is always the trusted value
Cross-stack notes
- Express:
app.set('trust proxy', N)controls whetherX-Forwarded-*headers are trusted. Default is false. AI-generated apps sometimes settrust proxy: trueblindly, which trusts the headers from any upstream — bad if you’re behind multiple proxies of varying trust levels. - Next.js: Reads
X-Forwarded-Hostin some middleware patterns. Audit specifically. - Django:
USE_X_FORWARDED_HOST = Trueis the unsafe pattern unless paired withALLOWED_HOSTSstrictly. - Rails:
request.hostreadsX-Forwarded-Hostif the proxy is trusted. Same caveat. - Go (net/http): No automatic trust; you have to manually parse the header. Less convenient, less risky.
How we detect
Request smuggling: we send carefully malformed CL.TE and TE.CL probes and observe the proxy/app pair’s behavior. The detection is one of the harder ones because false positives are easy and the actual exploit primitives differ across stacks.
CRLF: we send redirect endpoints and other header-emitting routes with CRLF payloads and observe whether the response headers reflect the injection.
Cache poisoning: we send the same URL twice with different unkeyed headers, observe whether the cached response carries content that depends on the header.
All three are runtime detections. Static scanners can’t do any of them — they need to observe the proxy + app pair’s behavior under hostile HTTP.
CWE / OWASP
- CWE-444 — HTTP Request/Response Smuggling
- CWE-93 — Improper Neutralization of CRLF Sequences (CRLF Injection)
- CWE-349 — Acceptance of Extraneous Untrusted Data With Trusted Data
- OWASP Top 10 — A03:2021 Injection, A05:2021 Security Misconfiguration
Reproduce it yourself
- Request smuggling: https://gapbench.vibe-eval.com/site/request-smuggling/
- CRLF response splitting: https://gapbench.vibe-eval.com/site/crlf-response-splitting/
- Cache poisoning: https://gapbench.vibe-eval.com/site/cache-poisoning/
Related reading
COMMON QUESTIONS
PROBE YOUR PROXY LAYER
We send the malformed-but-RFC-bendy requests these attacks rely on and observe the proxy's behavior.