CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting')
Overview
HTTP Request/Response Splitting occurs when untrusted user input is included in HTTP headers without validation or encoding, letting an attacker inject CRLF characters and add headers, requests or responses of their own.
The name covers two directions, and findings are filed under it for both. Response splitting is the common one: input reaches a header the application is writing to a client, and the injected CRLF adds a header or terminates the header block early so the rest is read as a second response. Request splitting is the same mechanism on an outbound call - a value that reaches a header of a request the application makes to another service, where it can add headers to that request or append a second request entirely. The sink is different (an HTTP client rather than a response object) and so is the check that would catch it, so establish which one a finding is before reading the remediation below.
What This Is Not
Three neighbouring weaknesses get reported as CWE-113, and each has a different fix:
- Request smuggling (CWE-444) is a disagreement between two servers about where one request ends - typically a
Content-Lengthand aTransfer-Encodingheader that a front-end proxy and a back-end server resolve differently. It usually needs no injected CRLF at all and is not fixed by validating an application value; it is fixed by making the two hops parse alike, or by not reusing the connection. If the finding is about a proxy/origin pair rather than about a value your code writes, it is CWE-444. - Open redirect (CWE-601) is an unvalidated but otherwise well-formed URL in a
Locationheader. No CRLF is involved:https://evil.exampleis a perfectly legal header value. This one matters here because the two arrive through the same parameter and one check can cover both - but only if the check is written as an allowlist of acceptable targets rather than as a CRLF filter. Stripping newlines from a redirect target leaves the open redirect completely untouched. - CRLF injection into something that is not an HTTP header (CWE-93) - a log line, an email header, an LDAP or SMTP field. Same character, different grammar and different consequence.
Relationship to Other CWEs
- CWE-113 (this page) - CRLF sequences injected into an HTTP header, so a proxy and an origin server disagree about where one message ends
- CWE-93 (Improper Neutralization of CRLF Sequences) - one of this page's two parents, and the entry to use instead when the injection targets logs, email headers, or another line-based protocol field rather than an HTTP header
- CWE-436 (Interpretation Conflict) - this page's other parent, the Class for any disagreement between two components that are each behaving correctly. No page here
- CWE-115 (Misinterpretation of Input) - the sibling under CWE-436 covering that disagreement in general, with this page as its worked example
Check Whether the Sink Is Still Reachable
Every mainstream server-side stack now does something about a CR or LF in a header, so on a current runtime the classic payload rarely produces a split response. What it produces instead is not the same everywhere, and the difference decides what the finding is. Measured while reviewing the language pages here:
| Runtime | CR/LF in a header value | CR/LF in a header name |
|---|---|---|
| ASP.NET Core / Kestrel, .NET 10 | InvalidOperationException - unhandled, so 500 |
same exception |
| Node 24.3 / Express 5.2.1 | ERR_INVALID_CHAR - unhandled, so 500 |
ERR_INVALID_HTTP_TOKEN |
| Django 6.1 | BadHeaderError |
BadHeaderError |
| Werkzeug 3.1.8 / Flask 3.1.3 | ValueError - unhandled, so 500 (and so since 0.8, in 2011) |
not checked by the framework - the WSGI server decides (werkzeug.serving emits it; waitress and wsgiref raise, so 500) |
| Tomcat 10.1.59 and 11.0.25 | no exception; each CR and LF - and every other control character bar TAB, plus DEL - is replaced with a space as the header is written | same substitution |
| Jetty 12.1.12 | no exception; same space substitution, extended to control characters bar TAB and to code points above 0xFF, but not to DEL |
illegal characters replaced with . |
| PHP 8.5.8 | warning, and the whole header is discarded; the script carries on - unless the newline only trails, which is trimmed and the header sent | n/a - header() takes one string |
Three different behaviours, then: raise, silently repair, silently drop. None of them is a fix, and each leaves a different bug behind:
- Where the runtime raises, the unhandled exception is an availability bug any client can trigger at will. The injection is closed; a
500on demand is not. - Where the runtime repairs - Tomcat and Jetty - nothing fails and nothing is logged. The header that goes out is not the one the code set, and the substitution happens at write time only:
response.getContentType()still returns the string with the CRLF in it, so application code that reads a header back, logs it, or passes it to another component still holds the original. - Where the runtime drops the header - PHP - a
header("Location: $target")followed byexitbecomes anexitwith no redirect. That is a broken endpoint rather than a secured one. - The stacks also disagree about what they check. Header names are validated by Django, Node and ASP.NET Core, sanitised by Tomcat and Jetty, and not checked at all by Werkzeug - so the same construction is an exception in one ecosystem and, in Python, a question about the WSGI server rather than the framework: the same Flask app injects a header under
werkzeug.servingand answers500under waitress orwsgiref. A finding here can pass in development and change behaviour in production, in either direction. - The check lives in the framework's or container's header object, so a raw WSGI/ASGI response, a hand-built proxy, a logging or caching layer that reconstructs headers, or bytes written straight to the response stream do not have it.
- An older runtime, or a platform not on that list, may split as described.
The language pages state the behaviour and the version for their own ecosystem. Verify against the runtime actually deployed rather than against this table, and treat "the platform handles it" as a reason to write the test differently, not as a reason to skip the fix.
OWASP Classification
A05:2025 - Injection
Risk
High: Where the value reaches the wire intact, an injected CRLF adds a header of the attacker's choosing, or ends the header block early so what follows is read as a second response - the basis for web cache poisoning and for cross-site scripting (XSS). On a runtime that intervenes instead, the split is closed, but what is left is a 500, a rewritten header or a dropped one rather than a fix.
Remediation Steps
Core Principle: Never allow untrusted input to influence HTTP response headers; header values must be validated or constructed by the server so they cannot introduce CRLF characters or alter response structure.
Locate the Vulnerable Header Construction
Start from the finding and work out which value reaches which header:
- Source: where the untrusted value enters - an HTTP parameter, a database row, a response from another service
- Sink: the call that sets the header, and which header it is.
Location,Set-Cookieand the others carry different consequences, and each has a different framework API that replaces the manual construction - Missing control: the frame between the two where a decision about what the value is allowed to be should have been made and was not
Use Framework-Provided Header APIs
The primary fix is to stop assembling the header text. Use the API that builds it:
- Use framework redirect methods (
redirect(),Redirect(),res.redirect()) instead of assigningLocationyourself - Use cookie APIs (
set_cookie(),Response.Cookies.Append(),ResponseCookie.from(),setcookie()) instead of building aSet-Cookiestring. These serialize the attributes for you and get the quoting right, which hand-written concatenation reliably does not. - Use a content-disposition builder rather than interpolating a filename into
attachment; filename="..." - Never derive a header name from input. Names should be literals in the source. This is the half that framework checks disagree about, so it is the half where "the framework will catch it" is least safe to assume.
Validate the Value Against What the Header Expects
Then validate what you pass in. Validate, do not repair - a stripped value is a value nobody chose, it hides the attempt from the logs, and the endpoint carries on with a target or filename the user did not ask for:
- Enumerated values (a charset, a theme, a status): check set membership. Nothing about encoding has to be reasoned about.
- Redirect targets: check against an allowlist of acceptable destinations, or that the value is a relative path built from a known character class. Doing this as an allowlist covers response splitting and open redirect (CWE-601) together; a CRLF filter covers only the first.
- Free text that must appear in a header (a filename, a request ID): allowlist the characters that are valid in that header's grammar, which for a quoted parameter means excluding
",;,\and control characters, and bound the length. - Reject with a 4xx. If validation is skipped and the platform's own handling fires instead, the answer is whatever the table above says for that runtime - a
500, a header quietly rewritten, or a header quietly dropped. None of those is the answer the endpoint should be giving. - Anchor validation patterns at the true end of the string. In Python, .NET and PCRE,
$also matches immediately before a trailing newline, so^...$admits exactly the character being excluded. Usere.fullmatch()in Python,\zin .NET and Java, and PCRE's\zorDmodifier in PHP. Python is the odd one out and its own spelling is worth pinning down:\zis not a Python escape at all andre.compile(r'\A...\z')raisesPatternError: bad escape \zup to and including 3.13 (it was added in 3.14). Python's strict end anchor is the capital\Z, which is the opposite of the convention everywhere else.re.fullmatch()sidesteps the whole question and is the spelling to use there. - Make it a whole-string match, not a search. A pattern applied with a search rather than a full match answers "does this appear somewhere", which is not the question. Java's
String.matches()is already a full match, but its.does not cross a line terminator - so a denylist written as.*[\\x00-\\x1F].*returnsfalsefor/home\r\nX-Injected, because no.*can span the newline. An allowlist of permitted characters has neither problem.
Where User Data Most Often Reaches a Header
Two sinks account for most findings, and both have a framework API that removes the need to touch the header at all:
Location, from anext/returnUrl/redirect_uriparameter after login, checkout, or an OAuth callback. Also the sink for open redirect, so validate the destination rather than filtering characters out of it.Set-Cookie, from a preference, a locale, a tracking identifier, or a returned session value. The cookie APIs escape or reject control characters; a hand-built string does neither, and also silently dropsHttpOnly,SecureandSameSite.
Content-Disposition (a user-supplied download filename) and reflected diagnostic headers (X-Request-Id, an echoed Origin or User-Agent) are the next most common, in that order.
Monitor and Log Header Manipulation Attempts
- Log what is set in each header, and log every value that validation rejects, so an attempt is visible after the fact
- Alert on CR, LF or NUL characters and on multiple header separators in a value
- Rate-limit the endpoint to slow automated attempts
Test with HTTP Request/Response Splitting Payloads
Verify the fix prevents header injection, and assert on the status code and the emitted value, not just on the header list. Every runtime in the table above handles a CRLF somehow whether or not your validation ran, so "no injected header appeared" passes against a fixed endpoint and an unfixed one alike - and on Tomcat and Jetty the unfixed endpoint answers 302 with a plausible-looking Location, which is the hardest of the three to spot:
- Verify legitimate values still work. Follow an accepted redirect to its destination, download a file with an ordinary name, read back a cookie you set. This is the assertion that fails when a fix over-tightens, and it is the one usually missing - every malicious-input test passes against a validator that rejects everything.
- Test with CR/LF characters: try
value%0d%0aInjected-Header: maliciousand assert the response is400and that the emitted header is absent or the safe default. Anything else means the value reached the header layer and your validation did not run: a500on ASP.NET Core, Node, Flask or Django, a302whoseLocationcontainsvalue Injected-Header: maliciouson Tomcat or Jetty, and a missingLocationon PHP. - Test a trailing newline on its own:
value%0a. This is the case a^...$-anchored pattern lets through in Python, .NET and PCRE. - Test the header name, if any name is derived from input, and read the raw bytes off a socket rather than through a test client - a test client shows the framework's header mapping, not what the server serialised.
- Test double encoding:
%250d%250a, to confirm nothing downstream decodes a second time. - Test response splitting: attempt a complete second response with
%0d%0a%0d%0a<html>....
Common Vulnerable Patterns
Directly inserting user input into HTTP headers
# Dangerous: user input in header, with no decision made about it
response.headers['Location'] = user_input
Why this is vulnerable: Nothing here decides what user_input is allowed to be, so what the response says is decided by the client and by the runtime rather than by the code. What the CRLF payload produces depends on the platform - see the table above - but the line is the same bug in every one of them, and two consequences do not depend on the platform at all. A well-formed https://evil.example is a legal header value everywhere, so this is also an open redirect (CWE-601). And the platform check belongs to the framework's header object, so the same expression written against a raw WSGI/ASGI response, a proxy shim, or bytes on a socket splits the response as described.
Secure Patterns
Use Framework Redirect APIs (Best Practice)
# SECURE - validate first, then let the framework build the header
# The argument is named validated_url for a reason - see below
return redirect(validated_url)
Why this works: The redirect helper builds the Location header from a value rather than taking a header line you assembled, so there is no concatenation for a CRLF to break out of, and the same applies to the cookie and content-disposition builders. What it does not do is decide whether the destination is acceptable: redirect("https://evil.example") is a valid call on every framework here and emits exactly that. The helper closes the splitting sink; the validation in front of it closes the open redirect, and that is why both halves are in the line above.
Do not read "the framework builds it" as "the framework encodes it". The helpers differ as widely as the runtimes do: Django's HttpResponseRedirect percent-encodes a CRLF into the target (http://x/%0D%0ASet-Cookie:%20admin=true), Flask's and Express's raise, and a servlet sendRedirect() reaches the container, where Tomcat 11 substitutes spaces and Jetty 12 raises. Only one of those four is an escape, and none of them is a guarantee you can carry to the next sink.
Validate Against the Header's Grammar, Then Reject
# SECURE - decide what the value is allowed to be, and refuse anything else
def handle_redirect(user_url):
# An allowlist of the characters legal in a same-origin path.
# Whole-string match, not a search, and not an anchor that tolerates
# a trailing newline.
if not full_match(r'/[a-zA-Z0-9/_-]*', user_url):
return respond(400)
return redirect(user_url) # framework builds the Location header
Why this works: The allowlist is defined by what is legal in this context rather than by a list of characters to remove, so nothing has to be enumerated for it to hold - CR, LF, NUL, : and // are all excluded as a consequence of not being in the permitted set, and so is the next separator somebody discovers. Because it is a whole-string match it also covers response splitting and open redirect (CWE-601) in one check, which a CRLF filter cannot do. And rejecting with a 400 means the request that carried the payload appears in the logs as a rejection, instead of quietly succeeding against a value nobody chose.
Where the Value Cannot Be Constrained
# SECURE - free text that must survive intact - encode it into a safe
# character set rather than removing characters from it
encoded = base64url(user_note)
response.headers['X-Note'] = encoded # decoded at the other end
# For a download filename, the header grammar has its own encoding:
response.headers['Content-Disposition'] = content_disposition_builder(
'attachment', filename=user_filename) # emits filename*=UTF-8''...
Why this works: Some values genuinely cannot be reduced to a character class - a note, a search term, a filename in a language the ASCII class excludes. Encoding is the answer there rather than filtering, because it is a reversible transformation with a defined inverse: every input maps to a legal header value and comes back unchanged. Filtering has no inverse, so the receiver has no way to distinguish a value the user sent from one the filter produced. Use the encoding the header's own grammar defines where it has one - RFC 5987 filename*=UTF-8''... for Content-Disposition - and let a builder emit it rather than assembling the parameter by hand.
Common Pitfalls
- Stripping CR/LF out of the value instead of rejecting it: this is what most older guidance for this CWE recommends, and it is the pattern to move away from.
"/account\r\nSet-Cookie: admin=true"with the newlines removed becomes"/accountSet-Cookie: admin=true"- a redirect target nobody chose, emitted with a302, and nothing in the log to distinguish that request from an ordinary one. A strip also has to be complete to be worth anything, and completeness moves: CR, LF and NUL are the usual three, and a filter that stops there misses U+0085 on Node, and misses"and;inside a quotedContent-Dispositionparameter, neither of which needs a newline to change the response. Decide what the value is allowed to be instead. - Reading a passing scan as a fixed endpoint: none of the platform behaviours in the table above is a fix, and two of them are silent - the space substitution on Tomcat and Jetty costs neither a failed request nor a log line, and PHP's discarded header leaves a redirect that never happens. A scanner that only asks "did an extra header appear" reports success against all three.
- Encoded/double-encoded CRLF, on the wrong side of the decode: a
%0d%0aarriving in a query string has already been decoded once by the time the framework hands it to you, so it is a raw CRLF at the sink and a%0dstill present in the value is literal text. Filtering for the percent sequence there defends against nothing and corrupts legitimate input. The case that is real is a second decode downstream - a proxy, another framework layer - which turns%250d%250ainto%0d%0aand then into a raw CRLF after your check ran. That decode is the sink to fix, not this one. - Open-redirect validation mistaken for CRLF protection: confirming a Location value is a "local" or same-origin URL blocks open redirects (CWE-601), but a hand-written same-origin check can be satisfied by
/account\r\nSet-Cookie: admin=true. Some framework helpers cover both - ASP.NET Core'sUrl.IsLocalUrl()rejects control characters as well - and a reimplementation of the same predicate has whichever behaviour it was written with. Check the one you are actually calling. - Sanitizing at the wrong layer: validating in application code but leaving a downstream logging, caching, or reverse-proxy component that reconstructs headers from raw request/response data - the fix protects one sink while a different code path still builds the header unsafely.
- Assuming the platform blocks it everywhere: redirect helpers and cookie builders reject or escape CRLF, but a custom header call a few lines away in the same handler does not go through them - verify each header-setting call site, not just the ones that were fixed first.
Language-Specific Guidance
- C# - what Kestrel's header check does and does not cover,
Url.IsLocalUrl(),ContentDispositionHeaderValue, and why not to add aUrlEncoderpass - Java - what Tomcat and Jetty do with a CRLF instead of rejecting it,
ResponseCookie.from(), and the silentencode()inUriComponentsBuilder - JavaScript -
res.redirect()andres.cookie()over manual header assignment, and which Unicode line terminators Node actually accepts - PHP -
header()discarding the whole header rather than rejecting the value,setcookie()over rawSet-Cookiestrings - Python - Werkzeug validating values but not header names, Flask/Django header assignment, redirect URL allowlisting