A percent-encoded carriage-return plus line-feed in a reflected parameter terminates the intended response header and starts a new one under attacker control - the sink it reaches decides whether that is low noise or a cookie-injection path.
CRLF injection lands in the gap between URL decoding and header output. When a server reads a request parameter, decodes it, and writes the result directly into a response header, a percent-encoded carriage-return plus line-feed - %0d%0a - terminates the header the server intended to write and starts a new one under the attacker's control. A reflected test header with a benign nonce proves the boundary is open without touching any cookie, redirect target, or content-type that would affect a real session.
HTTP/1.1 headers are separated by the two-byte sequence CR LF (\r\n, ASCII 0x0D 0x0A). A status line ends with one; each header field ends with one; the blank line separating headers from the body is two in a row. When a server reads a URL-decoded parameter value and writes it verbatim into a header field - a Location redirect, a Set-Cookie name, a Content-Disposition filename - any CRLF still in the value is treated by the client's parser as a real header terminator. Everything after the injected \r\n becomes a new header field the application never wrote.
The same mechanism that injects a harmless probe header can inject Set-Cookie: session=forged, Content-Type: text/html, or a second Location header that overwrites the intended redirect target - which of those sinks is reachable decides the final severity, not the CRLF character pair itself.
The proof requires two requests. First a baseline: the unmodified parameter, recording every response header the server returns. Second, the injection probe: %0d%0a appended to the reflectable value, followed by a header name and a benign arithmetic nonce. The confirming artifact is the injected header appearing in the probe response and being absent from the baseline response. No malicious content is ever sent - the nonce is readable noise, not a payload that executes or sets a real cookie.
# 1. Baseline - record all response headers GET /redirect?url=https://example.com # HTTP/1.1 302 Location: https://example.com (no extra headers) # 2. CRLF probe - inject a benign header after the encoded CRLF GET /redirect?url=https://example.com%0d%0aX-CRLF-Probe: 62615533 # HTTP/1.1 302 # Location: https://example.com # X-CRLF-Probe: 62615533 <-- injection confirmed # Repeat probe 2 more times to confirm not a server-side fluke. # Then probe for Set-Cookie and Content-Type sinks to grade severity.
Some servers strip bare %0d%0a but pass the double-encoded form %250d%250a - or vice versa. Probe both. Frameworks that decode only once (Express, Flask, Spring MVC with default UriComponentsBuilder) stop at the first decode layer; those that decode twice, or that pass the value through an intermediary that decodes again, expose the double-encoded form. The confirmed form is the one that belongs in the finding.
A CRLF pair that reaches a response header does not have a fixed severity - the sink decides it. Testing only whether the CRLF survives and stopping there conflates a low-impact header reflection with a confirmed XSS delivery path.
| Sink | Injected header | Impact | Severity |
|---|---|---|---|
| Custom debug header | X-Debug: attacker-value | Reflected noise; no user-visible effect | Low - confirmed injection, minimal impact |
| Location redirect | Location: https://attacker.example | Open redirect served from a trusted origin | Medium - browser follows without warning |
| Set-Cookie | Set-Cookie: session=forged; Path=/ | Cookie injection or session fixation on victim's browser | High - authenticated context can be planted |
| Content-Type + body split | Content-Type: text/html\r\n\r\n<script>... | HTTP response splitting delivers XSS from trusted origin | Critical - arbitrary script in victim's browser origin |
The Content-Type escalation is the most common path to critical. HTTP/1.1 servers that buffer and stream responses separately may split the body at the injected blank line, yielding a malicious HTML document served under the application's domain. HTTP/2 servers are generally not vulnerable to classic response splitting because the framing layer is binary and header fields are encoded without CRLF as a delimiter - but a backend that speaks HTTP/1.1 behind an HTTP/2 front-end still exposes the injection surface at the hop where HTTP/1.1 is used.
Modern framework defaults vary widely. Knowing which layer actually sanitizes - and which assumes the caller already did - is what prevents a false confidence in framework protection.
# Express (Node.js) res.setHeader('Location', userInput) throws TypeError if \r or \n present # protected since Node 14.18 # Flask (Python) redirect(userInput) # strips newlines in Werkzeug >= 2.1 Response(headers={'Location': userInput}) # older versions: NOT stripped # Spring (Java) response.sendRedirect(userInput) # encodes \r\n since Spring 5.3.22 HttpHeaders.set('X-Custom', userInput) # direct header set: NOT filtered pre-6.0 # Ruby on Rails redirect_to userInput # raises ArgumentError on CRLF since Rails 6.0 response.headers['X-Custom'] = userInput # NOT filtered
The pattern is consistent: high-level helpers (redirect, send_file, set_cookie wrappers) gained CRLF guards over time; direct header-field assignment almost never has them. Code that constructs custom headers, Content-Disposition filenames, or X-forwarded-* values from user input is the surface a scanner needs to probe regardless of framework version, because the guard lives in the convenience method, not in the header write path itself. A version bump that protects redirect_to does not protect raw response.headers[]=.
The fix is unconditional: strip or reject any CR or LF character from user-supplied values before they reach any header-write path. Allowlist-based output encoding - permitting only printable ASCII characters excluding 0x0D and 0x0A in header values - removes the injection surface without breaking legitimate redirects or filenames. A finding that reaches a Set-Cookie or Content-Type sink carries a 7-day SLA; a finding that reaches only a benign custom header is a 30-day remediation item, because the root cause is identical and the only protection is the sink not being interesting yet.