Skip to content

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-Length and a Transfer-Encoding header 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 Location header. No CRLF is involved: https://evil.example is 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 500 on 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 by exit becomes an exit with 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.serving and answers 500 under waitress or wsgiref. 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-Cookie and 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 assigning Location yourself
  • Use cookie APIs (set_cookie(), Response.Cookies.Append(), ResponseCookie.from(), setcookie()) instead of building a Set-Cookie string. 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. Use re.fullmatch() in Python, \z in .NET and Java, and PCRE's \z or D modifier in PHP. Python is the odd one out and its own spelling is worth pinning down: \z is not a Python escape at all and re.compile(r'\A...\z') raises PatternError: bad escape \z up 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].* returns false for /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 a next/returnUrl/redirect_uri parameter 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 drops HttpOnly, Secure and SameSite.

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: malicious and assert the response is 400 and 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: a 500 on ASP.NET Core, Node, Flask or Django, a 302 whose Location contains value Injected-Header: malicious on Tomcat or Jetty, and a missing Location on 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 a 302, 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 quoted Content-Disposition parameter, 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%0a arriving 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 %0d still 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%250a into %0d%0a and 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's Url.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 a UrlEncoder pass
  • Java - what Tomcat and Jetty do with a CRLF instead of rejecting it, ResponseCookie.from(), and the silent encode() in UriComponentsBuilder
  • JavaScript - res.redirect() and res.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 raw Set-Cookie strings
  • Python - Werkzeug validating values but not header names, Flask/Django header assignment, redirect URL allowlisting

Additional Resources