CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') - Java
Overview
HTTP Response Splitting occurs when attackers inject CRLF characters into HTTP headers, potentially allowing them to inject additional headers or response bodies.
What the Servlet Container Actually Does
Current servlet containers neither split the response nor reject the value - they silently rewrite it. Measured on Tomcat 10.1.59, Tomcat 11.0.25 and Jetty 12.1.12, all on JDK 26: response.setHeader("Location", "/home\r\nSet-Cookie: admin=true") throws nothing, and the bytes on the wire are
with each CR and each LF replaced by a single space. The same is true of addHeader() and of setContentType(). A CRLF in a header name is sanitised too: Tomcat substitutes spaces there as well, Jetty replaces the illegal characters with .. Of the sinks tested, only Jetty's sendRedirect() raises anything (IllegalArgumentException: Suspicious Path Character); Tomcat's emits the space-substituted header.
The rewrite is broader than CR and LF, and the two containers do not draw the line in the same place. Tomcat's Http11OutputBuffer replaces every control character except TAB, plus DEL - (buffer[i] > -1 && buffer[i] <= 31 && buffer[i] != 9) || buffer[i] == 127. Jetty's HttpGenerator.putSanitisedValue replaces control characters except TAB and any code point above 0xFF - c > 0xff || (c <= 0x1F && c != '\t') - but not DEL, which is neither, and so reaches the wire intact. NUL is filtered on both. A code point above 0xFF is replaced by Jetty before it can be encoded, which is why U+2028 and U+2029 are not a response-splitting vector there the way they are on Node. Do not generalise from "CR and LF are handled" to "every control character is": which ones survive depends on the container.
That is worth sitting with, because it is the opposite of what this weakness is usually written up as and the opposite of what a reader coming from ASP.NET Core or Node expects. Three consequences follow:
- There is no tell. No exception, no
500, no log line, no failed request. A codebase can carry this bug for years and every automated check will pass, which is precisely why the vulnerable patterns below still need fixing. - The rewrite happens on the way out, not on assignment.
response.getContentType()still returns the string with the CRLF in it, so any application code that reads a header back, logs it, copies it onto an outbound request, or writes bytes togetOutputStream()itself is holding the original value with the container's protection nowhere in sight. - The emitted header is not the one the code set. A
302goes out with aLocationof/home Set-Cookie: admin=true, which is a redirect target nobody chose. The splitting is closed; the missing decision about the value is not.
Older containers, other servlet implementations, and anything that serialises headers itself are not covered by this measurement. Confirm on the container in front of you, and see the main CWE-113 page for how the other ecosystems differ - they disagree completely.
Primary Defence: Use Spring Framework's redirect methods (redirect: prefix in @Controller return values, RedirectView), the ResponseCookie.from() builder, and ContentDisposition.builder(), which build the header for you rather than taking a string you assembled. When manual header manipulation is unavoidable, validate the value against what the endpoint expects - a relative path for Location, an allowlist for an enumerated value - and reject with 400 rather than stripping characters out of it.
Three details on this page are worth reading before trusting any of those APIs, because each is the kind of thing that looks handled and is not. UriComponentsBuilder encodes only when encode() is in the chain; build().toUriString() alone returns the input unchanged, CRLF included. ContentDisposition.filename() does not reject a filename containing a CRLF - it deletes the newlines and carries on, the same repair the container performs one layer down. And ResponseCookie is the one that does validate by throwing, so it rejects a space or a ; in a cookie value as readily as a CRLF - which is safe, and turns a legitimate value into a 500 unless it is encoded first. All three measured below on spring-web 7.0.9 / JDK 26.
Common Vulnerable Patterns
Direct Header Injection
// VULNERABLE - the request decides the Location header, unchecked
@GetMapping("/redirect")
public void redirect(@RequestParam String url, HttpServletResponse response) {
response.setHeader("Location", url);
}
// Input: "/home\r\nSet-Cookie: admin=true"
// Tomcat 10/11, Jetty 12: emitted as "Location: /home Set-Cookie: admin=true"
// (CR and LF each replaced with a space), no exception
// older/other containers: the CRLF ends the header and Set-Cookie is injected
//
// Input: "https://evil.example/login"
// every container: emitted verbatim - an open redirect (CWE-601)
Why this is vulnerable: Nothing here decides where the user is being sent, so the handler forwards whatever arrived. The CRLF reading is the one usually written up and it is the weaker of the two on a current container: Tomcat and Jetty rewrite the newlines to spaces on the way out, so no second header appears - but the 302 still carries a Location the application never chose, and the container's rewrite is invisible to the code, to the logs and to a scanner. The reading that works everywhere needs no control character at all: https://evil.example/login is a legal header value on every container, and this line emits it, which is a phishing redirect carrying the application's own domain in the link the victim clicked. Fix it by deciding what url is allowed to be, which closes both.
Cookie Manipulation via String Concatenation
// VULNERABLE - hand-built Set-Cookie string
response.setHeader("Set-Cookie", "session=" + sessionId);
// Input: "abc123\r\nSet-Cookie: admin=true; HttpOnly"
// Tomcat 10/11, Jetty 12: newlines become spaces, so one malformed cookie
// older/other containers: a second Set-Cookie header the browser stores
//
// Input: "abc123; Domain=example.com"
// every container: emitted verbatim, and needs no newline at all
Why this is vulnerable: Assembling the header text by hand throws away everything ResponseCookie does, and the CRLF is the least of it. HttpOnly, Secure, SameSite and an expiry are all absent, so the cookie is readable from JavaScript and sent over plaintext. And ; is the cookie grammar's own attribute separator: a sessionId containing ; Domain=example.com extends the attributes of the cookie being set, with no control character involved and nothing for a container to rewrite. That variant is emitted verbatim by every container listed above.
Pick the injected attribute for what a browser will actually honour, because that is where this attack is usually written up wrongly. Domain=evil.example on a response from app.example.com is discarded - RFC 6265 requires the Domain attribute to domain-match the host that sent it, so a domain the attacker owns buys nothing. What does work is widening within the site's own registrable domain: Domain=example.com promotes a cookie scoped to one host into one every subdomain receives, which is how a session set on app.example.com starts arriving at legacy.example.com. Path=/ widens the same way, and Max-Age=0 deletes a cookie the application is relying on. Use ResponseCookie.from() and constrain the value, rather than filtering characters out of a string you concatenated.
Content-Type Header Manipulation
// VULNERABLE - user-controlled Content-Type charset, no allowlist
@GetMapping("/content")
public void serveContent(@RequestParam String charset, HttpServletResponse response) {
response.setContentType("text/html; charset=" + charset);
}
// Input: "utf-8\r\nX-Injected: evil"
// Tomcat 10/11, Jetty 12: emitted as
// Content-Type: text/html; charset=utf-8 X-Injected: evil
// but response.getContentType() still returns the value with the CRLF in it
// older/other containers: X-Injected becomes a header of its own
Why this is vulnerable: charset reads as harmless, which is why this survives review, but the set of legal charsets is a short fixed list and this accepts any string at all. On a current container the CRLF form produces a malformed Content-Type rather than an injected header - and note the asymmetry measured above: the substitution happens as the header is written, so response.getContentType() inside the handler still hands back text/html; charset=utf-8\r\nX-Injected: evil. Anything that reads it there - a logging filter, a caching layer, an outbound call that copies the content type - gets the unrewritten value. Separately, the charset itself controls how the browser decodes the body, so an attacker choosing it is a problem before any newline is involved. An allowlist of the charsets the endpoint actually serves closes both.
Custom API Headers with User Data
// VULNERABLE - reflecting unbounded client-controlled text into a header
@GetMapping("/api/data")
public void getData(HttpServletRequest request, HttpServletResponse response) {
String userAgent = request.getHeader("User-Agent");
response.setHeader("X-User-Agent", userAgent);
}
Why this is vulnerable: The CRLF reading of this one does not hold, and it is worth saying so rather than repeating it: a request header cannot carry a raw CR or LF, because the server would have parsed it as two headers - and the obs-fold continuation line that used to allow it is rejected outright by current servers (measured: both Kestrel and Node answer 400 to X-Src: a\r\n b). So userAgent is at worst a long run of characters that are legal in a header. What is actually wrong is that it is unbounded attacker-controlled text reflected into a response that other systems parse, log and index, with no length limit and no format. Give a diagnostic header a declared format and a bound, or do not echo the value at all. Where the same shape is live is when the diagnostic value comes from a query string or a request body instead - a requestId parameter, say - because one decode has already happened by then and a CRLF arrives intact.
CORS Header Injection
// VULNERABLE - reflecting any Origin back as Access-Control-Allow-Origin
@GetMapping("/api/resource")
public void corsResource(HttpServletRequest request, HttpServletResponse response) {
String origin = request.getHeader("Origin");
response.setHeader("Access-Control-Allow-Origin", origin);
}
// Origin: https://evil.example
// Result: any site can read this endpoint's responses - no newline needed
Why this is vulnerable: This is a real vulnerability on every container, and it is not a CRLF one - which is why filing it under CWE-113 tends to get it fixed the wrong way. Reflecting the request's Origin makes the allowlist "whatever the attacker sent": a page on https://evil.example can call this endpoint cross-origin and read the response, and if Access-Control-Allow-Credentials: true is configured anywhere nearby it reads it with the victim's session attached. The payload is an ordinary origin string. The CRLF variant is the part that does not work, for the same reason as the User-Agent example above - a request header cannot carry a raw newline. Fix it with an exact-match allowlist of origins, which is the only thing that closes the case that is live.
Secure Patterns
Use Spring Framework Redirect (Best Practice)
// SECURE - framework handles header construction safely
@GetMapping("/redirect")
public String redirect(@RequestParam String returnUrl) {
// Validate URL is local (prevents open redirect)
if (returnUrl == null || !returnUrl.startsWith("/") || returnUrl.startsWith("//")) {
return "redirect:/";
}
// Spring's redirect: prefix handles Location header encoding
return "redirect:" + returnUrl;
}
Why this works: The check is what makes this safe, not the prefix. startsWith("/") with !startsWith("//") confines the target to this host, which is the open-redirect half (CWE-601) and the half no framework does for you - redirect:https://evil.example is a valid Spring return value and emits exactly that. The redirect: prefix then builds the Location header from a value rather than from a header line you concatenated, so there is no header text for a newline to break out of.
Do not read the prefix as an encoding step. It ends at HttpServletResponse.sendRedirect(), and what happens to a CRLF there is the container's decision: Tomcat 11 substitutes spaces, Jetty 12 throws IllegalArgumentException. Neither is an escape you can rely on, and neither is engaged at all if the value passed the check above - which is the point of putting the check first.
Note what this pattern does not accept: the startsWith("/") test admits /home?next=/x, so a query string survives, but nothing here bounds the length or restricts the characters inside the path. Where the set of legitimate destinations is small, matching against an allowlist of paths is stronger and simpler than reasoning about what a prefix test lets through.
Setting the Header Manually
import java.util.regex.Pattern;
// GOOD - one allowlist, applied as a whole-string match
private static final Pattern LOCAL_PATH =
Pattern.compile("/[a-zA-Z0-9/_-]*");
@GetMapping("/custom")
public ResponseEntity<Void> customRedirect(@RequestParam String returnUrl) {
// Reject rather than repair: a value that does not match is an attack or
// a bug, and stripping characters out of it produces a redirect target
// nobody asked for
if (returnUrl == null || !LOCAL_PATH.matcher(returnUrl).matches()) {
return ResponseEntity.badRequest().build();
}
// Set the header with the validated value.
// NOT UriComponentsBuilder.fromPath(x).build().toUriString() - build()
// without encode() returns the string unchanged, so that step encodes
// nothing at all; see below.
return ResponseEntity.status(302).header("Location", returnUrl).build();
}
Why this works: One allowlist replaces the stack of prefix tests and denylist checks this pattern usually carries, and it is stronger than all of them together: the leading / with no second one confines the target to this host (CWE-601), and a character class of letters, digits, /, _ and - excludes :, CR, LF, NUL and every other separator without any of them having to be named. Verified against /home, /reports/2026-q1 and /a/b/c_d-e accepted, and /home\r\nSet-Cookie: a=b, /home\r\nX-Injected, /home\n, //evil.example, https://evil.example and javascript:alert(1) all rejected. Rejecting with 400 rather than stripping means the request that carried the payload is visible in the logs as a rejection instead of quietly succeeding against a rewritten target.
The denylist version of this check does not work in Java, and the way it fails is silent. The usual spelling is returnUrl.matches(".*[\\x00-\\x1F\\x7F].*"), and on JDK 26 that returns false for "/home\r\nX-Injected" - so the value passes and reaches the header. String.matches() requires the whole string to match, and . does not cross a line terminator unless Pattern.DOTALL is set, so no .* can span the \n in the middle. It does catch a trailing \n (nothing has to follow it), which is exactly enough to make a quick test look convincing. Pattern.compile(..., Pattern.DOTALL) fixes it; the allowlist above does not have the problem at all, which is the better reason to prefer one.
Note that this pattern rejects a query string: /home?x=1 does not match. Where redirect targets legitimately carry parameters, extend the character class deliberately rather than falling back to a prefix test, and re-run the accept cases above afterwards.
The encoding step this pattern usually ends with does nothing, and that is worth knowing before relying on it. UriComponentsBuilder.fromPath(x).build().toUriString() returns x unchanged - build() produces an unencoded UriComponents, and only encode() (or build().encode()) applies percent-encoding. Measured on spring-web 7.0.9: fromPath("/account\r\nSet-Cookie: a=b").build().toUriString() returns /account\r\nSet-Cookie: a=b, CRLF intact, while fromPath("/acc ount").encode().build().toUriString() returns /acc%20ount. A pattern that reads as "validate, then encode as a backstop" therefore has exactly one control in it, and if the validation is ever loosened there is nothing behind it.
Use Spring ContentDisposition Builder
// SECURE - Use Spring ContentDisposition builder for file downloads
private static final Pattern SAFE_FILENAME =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}");
@GetMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam String filename) {
// Validate rather than strip - see below for what the builder does
// with a filename it cannot represent
if (!SAFE_FILENAME.matcher(filename).matches()) {
return ResponseEntity.badRequest().build();
}
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.attachment().filename(filename).build()
);
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
Why this works: The allowlist is defined by what is safe inside a quoted Content-Disposition parameter rather than by a list of characters to remove: " and ; are excluded because they terminate or extend the parameter, \ because it escapes inside it, and CR and LF because they end the header - and the length is bounded in the same expression. ContentDisposition.attachment().filename(...) then produces the RFC 6266 header, quoting a filename containing a space (attachment; filename="q3 report 2026.pdf", measured on spring-web 7.0.9) and escaping an embedded quote as \".
Do not put a replaceAll("[\\r\\n]", "") in front of it, which is what older versions of this pattern do. Spring's builder does not reject a filename containing a CRLF - measured on spring-web 7.0.9, filename("a\r\nX: y") returns attachment; filename="aX: y", silently deleting the newlines. So the strip is not adding a control; it is duplicating one the builder already performs, and both of them do the wrong thing with it. A file goes out under a name nobody chose, badRequest() never fires, and nothing appears in the log. Deciding first is what makes the attempt visible.
Spring's repair here is the same shape as the servlet container's, one layer up, and for the same reason it is easy to miss: nothing fails.
Use ResponseCookie Builder for Cookies
// SECURE - encode to the cookie-octet set first, so the builder's rejection is
// a programming error rather than something a request can trigger
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.springframework.http.ResponseCookie;
String encoded = Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
ResponseCookie cookie = ResponseCookie.from("session", encoded)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// On the way back out:
// String value = new String(Base64.getUrlDecoder().decode(raw), StandardCharsets.UTF_8);
Why this works: Spring's ResponseCookie builder formats the cookie according to RFC 6265 and validates the value as it goes, and it is the one API on this page that genuinely rejects rather than repairing. Measured on spring-web 7.0.9, ResponseCookie.from("session", "abc\r\nSet-Cookie: admin=true").build() throws IllegalArgumentException: RFC2616 cookie value cannot have '<CR>' - the offending character quoted literally in the message. That is worth contrasting with ContentDisposition.filename() two sections up, which deletes the same characters and returns a value; here the attempt is impossible to miss. The toString() output also has all attributes correctly positioned, which manual concatenation routinely gets wrong.
Because it rejects, the exception is part of the contract, which is why the example encodes rather than handing the raw value over. The same check refuses characters that are legal in plenty of application data: measured on the same version, "a b" raises cannot have ' ', "a;b" raises on ';' and "a\"b" raises on '"'. So a value that is not already restricted to the cookie-octet set - a display name, a search term, a serialized preference - turns an ordinary request into a 500 the first time it contains a space, and a secure example that passes value straight in is one space away from being the availability bug this page warns about.
Base64url is the encoding used above because its alphabet (A-Za-z0-9-_) is a subset of cookie-octet, so the builder can never reject its output and the failure mode disappears rather than being caught. Percent-encoding works too. If the value genuinely is already constrained - an opaque session identifier, a UUID - pass it through unencoded and say so in the code, because "abc123" comes out unchanged as session=abc123 and the extra layer only costs bytes. What is not safe is assuming it is constrained: that assumption is exactly what a display name in a cookie breaks.
Validate Content-Type with Allowlist
// SECURE - Allowlist validation for charset
private static final Set<String> ALLOWED_CHARSETS = Set.of(
"utf-8", "utf-16", "iso-8859-1"
);
@GetMapping("/content")
public void serveContent(@RequestParam String charset, HttpServletResponse response) {
// Validate against allowlist
if (charset == null || !ALLOWED_CHARSETS.contains(charset.toLowerCase())) {
charset = "utf-8"; // Safe default
}
response.setContentType("text/html; charset=" + charset);
}
Why this works: The charset is an enumerated value, so membership in a fixed set is the whole check and nothing about encoding has to be reasoned about. A CRLF payload, a nonsensical charset and a ; starting an extra parameter all fail the same test.
Substituting utf-8 here is not the "repair" this page tells you not to do, and the two are worth telling apart because they look alike. Repairing derives a third value from the attacker's input - "utf-8\r\nX: y" becomes "utf-8X: y", which nobody chose - while this discards the input and uses a value the application defined. Where an endpoint has no sensible default, or where quietly serving something other than what was asked for would confuse a legitimate caller, return 400 instead.
Give a Custom Header Value a Format
// SECURE - the header value has a declared shape, and anything else is a 400
private static final Pattern REQUEST_ID =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
@GetMapping("/api/data")
public ResponseEntity<Void> getData(@RequestParam String requestId) {
if (requestId == null || !REQUEST_ID.matcher(requestId).matches()) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok().header("X-Request-Id", requestId).build();
}
Why this works: The value is checked against what it is allowed to be, so CR, LF, NUL, ", ; and everything else meaningful in a header grammar are excluded as a consequence of not being in the class, rather than one at a time in a filter somebody has to keep complete. Matcher.matches() is a whole-string match, so there is no anchoring question and none of the .-does-not-cross-a-newline trap that the denylist form has. The length bound belongs in the same expression: a header value has no length limit in the application, and proxies disagree about where they stop accepting one.
The 400 is the part the container cannot give you. Left unvalidated, this endpoint answers 200 on Tomcat and Jetty with a rewritten X-Request-Id that nothing distinguishes from a legitimate one; validated, the attempt is a rejection with a log line.
Validate CORS Origins with Allowlist
// SECURE - Allowlist-based CORS validation
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://trusted.com",
"https://app.trusted.com"
);
@GetMapping("/api/resource")
public void corsResource(HttpServletRequest request, HttpServletResponse response) {
String origin = request.getHeader("Origin");
if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
}
}
Why this works: The header is set only when the request's Origin is one of a fixed set of strings the application chose, so a page on https://evil.example gets no Access-Control-Allow-Origin at all and cannot read the response - which is the live vulnerability in the pattern above. Exact string matching through Set.contains(), rather than a startsWith or a substring test, is what makes it hold: https://trusted.com.evil.com fails it and passes a substring check. The CRLF reading does not arise here, because the value never comes from anywhere but the set above.
Use UriComponentsBuilder for URL Parameters
// SECURE - Use UriComponentsBuilder, with encode(), for URL construction
import org.springframework.web.util.UriComponentsBuilder;
String url = UriComponentsBuilder.fromPath("/page")
.queryParam("returnUrl", userInput)
.encode() // REQUIRED - build() alone does not encode
.build()
.toUriString();
response.setHeader("Location", url);
Why this works: UriComponentsBuilder encodes each URI component according to RFC 3986 when - and only when - encode() is called, so a returnUrl containing a space, an &, a ? or a CRLF becomes percent-encoded text inside the query string instead of altering the URL's structure or the header's.
encode() is not optional and its absence is silent. build() returns an unencoded UriComponents, and toUriString() on that gives the string back as it went in. Measured on spring-web 7.0.9, the same expression without encode():
queryParam("returnUrl", "a\r\nSet-Cookie: x=y").build().toUriString()
-> /page?returnUrl=a\r\nSet-Cookie: x=y (CRLF intact)
queryParam("returnUrl", "a b&c=d").build().toUriString()
-> /page?returnUrl=a b&c=d (space and & intact)
The first of those goes straight into setHeader("Location", url). There is no exception and no warning; the builder simply did not do the thing its name suggests. Whenever a page or a codebase presents UriComponentsBuilder as the encoding step, check that encode() is in the chain - and note it must come before build() on the builder, or be called as build().encode() on the result.
Testing
Re-running the scanner is not verification here, and the reason is specific to this platform: on Tomcat and Jetty the unfixed endpoint answers 200 or 302 with a single well-formed header. There is no injected header for a scanner to find and no exception for it to trip over, so "the finding no longer reproduces" was already true before the fix. Assert on the emitted value.
- The accept, first. Request every redirect target, filename and header value the endpoint is supposed to allow, and assert the status and the exact header:
/reports/2026-q1returns302withLocation: /reports/2026-q1,q3 report 2026.pdfreturns200withContent-Disposition: attachment; filename="q3 report 2026.pdf". An allowlist tight enough to exclude CRLF is also tight enough to exclude a hyphen or a space somebody forgot to permit, and no malicious-input test will tell you. - A rejected value never reaches
Location, and the container's rewrite never happens. SendreturnUrl=/home%0d%0aSet-Cookie:%20admin=true. The failure to look for is302withLocation: /home Set-Cookie: admin=true- two spaces where the newlines were. That is Tomcat's and Jetty's substitution, and it means the validation did not run; because it is silent, this assertion has to be written against the emitted header rather than the status. The redirect example above answers302withLocation: /, discarding the bad value in favour of one the application chose; an endpoint with no sensible default should answer400instead. Assert whichever your endpoint does, not "either" - a test that accepts both passes against an endpoint that does neither. - The CRLF that carries no colon:
returnUrl=/home%0d%0aX-Injected. This is the input that amatches(".*[\\x00-\\x1F\\x7F].*")denylist accepts and a prefix test accepts, so it is what distinguishes a working check from one that only looks like it works. Assert the same outcome as the bullet above. - A trailing newline on its own:
returnUrl=/home%0a. Same outcome again - this is the one case a.*-style denylist does catch, so passing it proves nothing on its own; it is informative only alongside the embedded case above. - Read the bytes off a socket, not through
MockMvc. A test client shows the container's parsed view of the headers; only the socket shows what was serialised, and for the space-substitution case those are not the same thing. - Assert the cookie round-trips.
ResponseCookie.from()throws on a space, a;and a"as well as on a CRLF, so a value that is not already restricted tocookie-octetturns an ordinary request into a500the first time a user's data contains a space. Set a cookie with a realistic value, read it back, and assert it is unchanged.
Common Pitfalls
- Believing the container rejects a CRLF, because that is what the other ecosystems do: it does not. Measured on Tomcat 10.1.59, Tomcat 11.0.25 and Jetty 12.1.12,
setHeader,addHeaderandsetContentTypeall accept a value containing\r\nwithout complaint and replace each newline - along with the other control characters, as above - with a space as the header is written. No exception, no500, no log line, and the emitted header still looks plausible. So the diagnostic that works on ASP.NET Core and Node - trigger the payload, see the error - returns nothing here, and a codebase can hold the bug indefinitely with every test green. Assert on the emitted value, not on the absence of an extra header. - Reading the container's rewrite as coverage for the whole request: it happens on serialisation, not on assignment.
response.getContentType()still returns the CRLF-bearing string inside the handler, so a logging filter, a caching layer, an outbound client that copies headers across, or anything writing togetOutputStream()directly is working with the original value. The container protects one output path. - Using
URLEncoder.encode()to "sanitize" a header value:URLEncoder.encode()implementsapplication/x-www-form-urlencodedencoding (spaces become+, not%20) - it is meant for form bodies, not URI or header construction. Applying it to only part of a concatenated header value (e.g., just a filename) can leave the rest of the string, including any embedded CRLF, unencoded. - Treating the container's rewrite as a guarantee, when it has both a version floor and a gap: Tomcat's filter runs only when the value is not already held as bytes (
mb.getType() != MessageBytes.T_BYTES), so a value that reaches the buffer as bytes bypasses it; and until Tomcat 11.0.23, 10.1.56 and 9.0.119 the loop was bounded bybc.getLength()wherebc.getEnd()was meant, so a value at a non-zero offset had its tail written unfiltered. Neither is a reason to depend on the rewrite; both are reasons not to present it as dependable. The measurements above were taken on 10.1.59 and 11.0.25, which are above that floor - an older container in the same estate is not. - Letting a builder's silent repair stand in for a decision: two on this page do it. Spring's
ContentDisposition.filename()deletes CR and LF from the filename rather than rejecting it, and the servlet container substitutes spaces for them in any header value. Both leave the endpoint answering200with a value nobody chose.ResponseCookie.from()is the counter-example and the one to model: it throwsIllegalArgumentExceptionrather than repairing, which is why the exception has to be handled and why the attempt is visible when it happens.