CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection') - Java
Overview
CRLF (Carriage Return Line Feed) Injection occurs when attackers inject \r\n characters to manipulate HTTP headers, log files, or other line-based formats.
Primary Defence: Reject - do not strip - user input containing newline characters (\r, \n, \r\n) before it reaches an HTTP header or a log line. Validate header values against a strict allowlist rather than filtering them, let Spring's HttpHeaders, ContentDisposition and ResponseCookie builders assemble the header instead of concatenating the string by hand, and use a structured log encoder (SLF4J with a JSON layout) so a newline that does get through is escaped inside a field rather than starting a new record.
Common Vulnerable Patterns
Direct Header Manipulation
// VULNERABLE - CRLF in headers
@GetMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam String filename) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Disposition", "attachment; filename=" + filename);
return new ResponseEntity<>(fileBytes, headers, HttpStatus.OK);
}
// Attack: filename = "test.pdf\r\nContent-Type: text/html\r\n\r\n<script>alert('xss')</script>"
Why this is vulnerable: A CR/LF pair terminates the header block, so a value containing one lets the caller write headers the application did not, and past a blank line, a body of their choosing. That is how one parameter becomes a response-splitting or cache-poisoning primitive rather than a formatting bug.
Check whether the platform already refuses it before assuming exploitability: RFC 9110 forbids CR and LF in field values, and current servlet containers and HTTP libraries reject or strip them, which frequently turns this into a 500 rather than an injection. That is a reason to record the finding accurately, not to leave the code - the same untrusted value reaches logs, proxies and clients that do not all validate. For a filename specifically, the answer is the RFC 6266 encoding rather than concatenation.
Unvalidated Redirects
// VULNERABLE
@GetMapping("/redirect")
public String redirect(@RequestParam String url) {
return "redirect:" + url; // Can contain CRLF
}
// Attack: url = "/page\r\nSet-Cookie: session=evil"
Why this is vulnerable: The redirect target is two weaknesses in one string. Control characters in it split the response, which is the CRLF issue this page covers; the location itself being attacker-chosen is an open redirect, and that half survives even after every control character is stripped.
They need separate fixes, which is why stripping the CR and LF and closing the ticket is the common mistake. Spring's "redirect:" prefix is resolved by the view layer and will happily emit an absolute URL, so the string still has to be checked - accept a path from an allowlist, or a relative path that cannot begin with // or /\. See CWE-601 for the target check in full.
Log Injection
// VULNERABLE - Log forgery
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);
public void logUserAction(String username, String action) {
logger.info("User {} performed {}", username, action);
}
// Attack: username = "admin\r\nINFO: User hacker performed GRANT ADMIN"
Why this is vulnerable: A log line is delimited by a newline, so a value containing one produces two lines and the second is indistinguishable from a genuine entry. The result is not a leak but a loss of accountability: an attacker writes plausible records attributing their actions to someone else, and the audit trail that would identify them becomes evidence for the wrong conclusion.
Layouts do not escape this by default. Logback's PatternLayout writes %msg verbatim; Log4j2 supplies %enc{%m}{CRLF} precisely because the default does not encode. Downstream matters too - a line-oriented shipper parses the forged line as a separate event, so the forgery survives into the SIEM with a valid timestamp and severity.
SMTP Command Injection via Jakarta Mail (Unpatched Versions)
// VULNERABLE on jakarta.mail < 1.6.8 / 2.0.0-2.0.1, or org.eclipse.angus:smtp < 2.0.4
import jakarta.mail.*;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
public void sendConfirmation(Session session, String recipientAddress,
String subject, String body) throws MessagingException {
MimeMessage msg = new MimeMessage(session);
msg.setRecipient(Message.RecipientType.TO, new InternetAddress(recipientAddress));
msg.setSubject(subject);
msg.setContent(body, "text/html;charset=utf-8");
msg.saveChanges();
Transport.send(msg);
}
Why this is vulnerable: This is ordinary, correctly-written Jakarta Mail code - no string concatenation into a raw SMTP command, nothing that looks like an injection point. The gap was inside the library, not the application: SMTPTransport.sendCommand() wrote each protocol command's bytes straight to the socket with no check for an embedded \r or \n. InternetAddress MIME-encodes non-ASCII characters in an address before the transport ever sees it, and a crafted recipientAddress could make that encoded form carry a literal CR/LF pair - splitting one RCPT TO/MAIL FROM command into two, so the injected second line runs as an independent command the server executes. Tracked as CVE-2025-7962 (CWE-147: Improper Neutralization of Input Terminators), fixed in org.eclipse.angus:smtp 2.0.4 and com.sun.mail:jakarta.mail 2.0.2 / 1.6.8 - the implementation artifact that carries the socket-writing code, not the API module, so pinning jakarta.mail-api alone does not fix this.
Secure Patterns
Upgrade Past the Unvalidated SMTP Command Writer
// SECURE - same code, but on a patched implementation
// org.eclipse.angus:smtp >= 2.0.4, or com.sun.mail:jakarta.mail >= 2.0.2 / 1.6.8
import jakarta.mail.*;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
public void sendConfirmation(Session session, String recipientAddress,
String subject, String body) throws MessagingException {
MimeMessage msg = new MimeMessage(session);
msg.setRecipient(Message.RecipientType.TO, new InternetAddress(recipientAddress));
msg.setSubject(subject);
msg.setContent(body, "text/html;charset=utf-8");
msg.saveChanges();
Transport.send(msg); // patched SMTPTransport.sendCommand() throws IllegalArgumentException on embedded CR/LF
}
Why this works: A patched SMTPTransport.sendCommand() scans every outgoing command's bytes for a raw \r or \n before writing to the socket and throws IllegalArgumentException instead of sending a line that would smuggle a second command - the same reject-don't-repair defense this page's other patterns apply by hand, built into the transport layer so nothing above it can be bypassed. There is no application-level workaround on the vulnerable version: the injection happens inside the library's own protocol-writing code, after any validation the calling application performs on the original string, so the fix is the upgrade, not additional input checking. Confirm which artifact is actually resolved on the classpath rather than trusting the direct dependency - Maven can resolve org.eclipse.angus:smtp transitively to an older version even when jakarta.mail-api looks current, and that transport module is where the fix lives.
Safe Header Manipulation
// SECURE - reject the newline, then let Spring build the header
import java.util.regex.Pattern;
import org.springframework.http.*;
private static final Pattern NEWLINE = Pattern.compile("[\\r\\n]");
@GetMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam String filename) {
// Reject rather than repair - a download name carrying a newline is
// not a name this application should serve
if (filename == null || NEWLINE.matcher(filename).find()) {
return ResponseEntity.badRequest().build();
}
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.attachment()
.filename(filename)
.build()
);
return new ResponseEntity<>(fileBytes, headers, HttpStatus.OK);
}
Why this works:
This pattern refuses the value rather than repairing it, then hands what it accepted to Spring's type-safe header builder. The refusal is the part that matters: a filename carrying a CR or LF is not a request to serve a file, a 400 keeps the evidence of what was sent, and nothing is rewritten between the check and the header.
Do not leave that job to the builder, because it is version-dependent. Measured against ContentDisposition.attachment().filename(name).build(), spring-web 7.0.9 drops CR and LF from the name, while 6.0.11 and 6.2.16 - the 6.x line that Spring Boot 3 runs on - carry both straight into the header value.
What the builder does handle is the rest of the header's structure. .filename(filename) quotes the value and writes it as an RFC 2183 parameter, applying RFC 2231 extended parameter encoding for non-ASCII characters, so a name containing spaces, quotes, semicolons or international characters survives intact instead of breaking header parsing. headers.setContentDisposition() then places the semicolons and quotes for the whole header - the part that headers.add("Content-Disposition", "attachment; filename=" + filename) leaves the caller to get right at every call site.
URL Encoding for Redirects
// SECURE - reject control characters, then require a local path
import java.util.regex.Pattern;
private static final Pattern CRLF =
Pattern.compile("[\\r\\n]|%0[dDaA]");
@GetMapping("/redirect")
public String redirect(@RequestParam String returnUrl) {
// Reject first: a strip would only move the problem into the check below
if (returnUrl == null || CRLF.matcher(returnUrl).find()) {
return "redirect:/";
}
// Local paths only. Reject every form a browser reads as an authority.
if (!returnUrl.startsWith("/")
|| returnUrl.startsWith("//")
|| returnUrl.startsWith("/\\")) {
return "redirect:/";
}
return "redirect:" + returnUrl;
}
// SECURE - Use UriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder;
@GetMapping("/search")
public String search(@RequestParam String query) {
String url = UriComponentsBuilder
.fromPath("/results")
.queryParam("q", query)
.encode() // required - build() on its own encodes nothing
.build()
.toUriString();
return "redirect:" + url;
}
Why this works:
The first pattern rejects the value rather than repairing it, and the order of the two checks is the whole point. Strip the newlines first and the shape check sees a different string than the one the user sent; check the shape first and the strip afterwards can produce a shape that would have failed. That second mistake is easy to write and hard to see: /\r\n/evil.example starts with a single /, so it passes a startsWith("/") && !startsWith("//") check, and removing the CR and LF then leaves //evil.example - a protocol-relative URL to the attacker's host, produced by the sanitizer that was supposed to prevent it. Rejecting up front has no such ordering hazard, because nothing downstream is ever handed a value the checks did not see.
The local-path check then covers each form a browser resolves as an authority rather than a path: //host, and /\host, which the WHATWG URL parser normalizes to the same thing. Anything not starting with / - an absolute https://evil.example, a javascript: URI, a bare \\host - fails the first condition. Note the regex also rejects %0d%0a, so a payload that survives to a later decode is refused here rather than becoming a real newline downstream; a double-encoded %250d%250a is deliberately left for the shape check, since it needs two decodes to become anything and a filter that runs a fixed number of times can always be out-nested.
This is a path-only allowlist, which is the right shape for a returnUrl that should stay inside the application. If yours legitimately targets other hosts, the check has to become an allowlist of hostnames parsed from the resolved URL - a scheme check alone still permits https://evil.example. See CWE-601.
The second pattern is UriComponentsBuilder, which is the right way to assemble a URL from dynamic parameters in Spring - and the .encode() call is what makes it safe rather than merely tidy. queryParam() stores the value; it does not encode it. Measured on spring-web 7.0.9, fromPath("/results").queryParam("q", query).build().toUriString() returns the query value exactly as it arrived, CR and LF included. Adding .encode() before build() applies RFC 3986 encoding and produces /results?q=x%0D%0ASet-Cookie:%20admin%3Dtrue, which is inert wherever the URL is used.
Two things follow from where the encoding happens. .encode() treats the value as literal text, so an input that already reads %0d%0a is encoded again to %250d%250a - correct for a parameter that arrived as text, and wrong if you are rebuilding a URL whose components were already encoded, where build(true) is the call that says so. And encoding is not a destination check: it makes the value safe to put in the URL, but if query were a redirect target rather than a search term it would still need the local-path or host allowlist from the first pattern.
Safe Logging (Parameterized Plus an Encoding-Aware Layout)
// SECURE - parameterized call site, paired with an encoder that escapes control characters
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);
public void logUserAction(String username, String action) {
logger.info("User {} performed {}", username, action);
}
<!-- logback.xml - LogstashEncoder writes one JSON object per event -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
Why this works:
The call site and the layout do different halves of the job, and only the pair closes the finding. logger.info("User {} performed {}", username, action) keeps the template and the values separate, so nothing in username is treated as format syntax and no concatenation can mix control characters into the template itself. But the value is still substituted into the rendered message before the layout sees it. Under Logback's default PatternLayout, %msg writes that message verbatim, so a CR/LF pair in username still ends the line and starts a second one that reads as a genuine entry. Parameterization prevents template injection; on its own it does not prevent log forging.
The encoder is what removes the newline. LogstashEncoder writes each event as a single JSON object and JSON-escapes the field values, so a newline inside username is written as the two characters \ and n and the record stays on one line. Log4j2's JsonLayout does the same. Where JSON output is not an option, encode explicitly in the pattern instead: Log4j2's %enc{%m}{CRLF} converts CR and LF in the message to their escaped forms. That conversion, not the placeholder, is what makes the entry unforgeable.
Encoding is preferable to stripping because it preserves the evidence. The injected text stays in the record, visibly escaped, so a responder can still see what was attempted. See CWE-117 for the layout and encoder configuration in full.
Manual Sanitization for Logs
// SECURE - Explicit CRLF removal
import org.owasp.esapi.StringUtilities;
private String sanitizeForLog(String input) {
if (input == null) return null;
// Replaces CRLF, control characters - and all non-ASCII - with spaces.
// The null check above is required: stripControls throws NPE on null in 2.7.0.0.
return StringUtilities.stripControls(input);
}
public void logUserAction(String username, String action) {
logger.info("User {} performed {}",
sanitizeForLog(username),
sanitizeForLog(action));
}
// OR use Apache Commons
import org.apache.commons.lang3.StringUtils;
private String sanitizeForLog(String input) {
if (input == null) return null;
return StringUtils.normalizeSpace(input); // Replaces \r\n with space
}
Why this works:
Know what ESAPI's StringUtilities.stripControls() actually does before choosing it. It keeps the printable ASCII range 0x21-0x7e and rewrites every other character as a space: it removes nothing, so the string keeps its length, and "control characters" undersells the range. CRLF, tabs and the escape sequences that can drive a terminal are all covered, so it does stop both log forging and terminal escape attacks - but so is every non-ASCII character, so an accented Latin letter, a name written in CJK and an emoji each reach the log as nothing but spaces. On a system that logs names, addresses or free text from anywhere outside English, that is a high price for a control-character filter. Measured on ESAPI 2.7.0.0.
The Apache Commons alternative, StringUtils.normalizeSpace(), collapses every whitespace sequence - \r, \n and runs of spaces - into a single space. "admin\r\nINFO: Fake entry" becomes "admin INFO: Fake entry" on a single log line: the text is still readable, and it can no longer change the structure of the log file.
Using a dedicated sanitizeForLog() method creates a centralized point that can be applied consistently, and checking for null before processing keeps it safe to call anywhere. Reach for it when the encoding layer is not available to you: a text PatternLayout you cannot change, a legacy appender, or a library that logs through its own configuration. It is the fallback for those cases, not an optional extra on top of an encoder that is already doing the job.
Be aware of what stripping costs. stripControls() and normalizeSpace() both destroy the injected characters, so the record no longer shows what was attempted - admin\r\nINFO: Fake entry and a genuine admin INFO: Fake entry become the same log line. An encoding layout keeps them distinguishable, which is why it is the better answer wherever you can configure one.
Cookie Security
// SECURE - Use ResponseCookie builder
import java.util.regex.Pattern;
import org.springframework.http.ResponseCookie;
private static final Pattern NEWLINE = Pattern.compile("[\\r\\n]");
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletResponse response) {
String sessionId = generateSessionId();
// Validate no CRLF in session ID. find() on a compiled pattern, not
// matches() - see below for why matches() misses the pair it targets
if (NEWLINE.matcher(sessionId).find()) {
throw new IllegalStateException("Invalid session ID");
}
ResponseCookie cookie = ResponseCookie.from("SESSIONID", sessionId)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(Duration.ofHours(1))
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok().build();
}
Why this works:
The check runs before the cookie is built, and it throws an IllegalStateException rather than sanitizing: a session ID carrying a CR or LF points at a problem in the ID generation, which is something to investigate rather than quietly repair.
The check is written as NEWLINE.matcher(sessionId).find() for a reason worth knowing, because the obvious alternative is broken. sessionId.matches(".*[\\r\\n].*") anchors the pattern to the whole string, and . does not match a line terminator without Pattern.DOTALL - so on JDK 26 it returns true for a lone CR and false for the \r\n pair, which is the payload every scanner sends. A check that passes the single-character case and fails the real one is worse than none, because it looks tested. find() searches for the character class anywhere in the string and has neither problem.
Spring's ResponseCookie builder creates Set-Cookie headers that conform to RFC 6265, each method call (.httpOnly(true), .secure(true), and the rest) adding its attribute in the correct syntax. The httpOnly flag prevents JavaScript access to the cookie, mitigating XSS-based session hijacking. The secure flag ensures the cookie is only transmitted over HTTPS, preventing interception. The sameSite("Strict") setting prevents the cookie from being sent in cross-site requests, providing CSRF protection.
Manual construction - response.addHeader("Set-Cookie", "SESSIONID=" + sessionId + "; HttpOnly; Secure") - lets a semicolon or a CR/LF in the session ID break the cookie structure or append a cookie of the attacker's choosing. The builder places the delimiters itself, and it fails closed on the value: on spring-web 7.0.9, ResponseCookie.from("SESSIONID", value) throws IllegalArgumentException: RFC2616 cookie value cannot have '<newline>' when the value carries a CR or LF, rather than encoding it. That is the backstop if the check above is ever removed - an exception, not a quietly repaired cookie - which is why the explicit check exists as well, to fail at the point where you can log the attempt.
Alternative Encoding Libraries
For URL encoding, logging contexts, and other scenarios beyond HTTP headers:
Java URLEncoder (for URLs):
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
String safe = URLEncoder.encode(userInput, StandardCharsets.UTF_8);
Why this works: These encoding libraries provide layered defense against CRLF injection across different contexts. URLEncoder.encode() percent-encodes CRLF characters to %0D%0A, making them safe for URLs and preventing header injection in Location headers. Spring's UriUtils offers specialized methods for different URI components (path segments, query parameters), ensuring proper encoding for each part of a URL structure. Apache Commons StringUtils.normalizeSpace() replaces all whitespace including CRLF with single spaces, neutralizing log injection while preserving content. OWASP ESAPI stripControls() is the bluntest of the four: everything outside printable ASCII becomes a space, non-ASCII text included. Choose based on context: use URL encoding for redirects, normalization for logs, and control stripping when content preservation is less critical than security.
Spring UriUtils (comprehensive URI encoding):
import org.springframework.web.util.UriUtils;
String safe = UriUtils.encode(userInput, StandardCharsets.UTF_8);
String safePathSegment = UriUtils.encodePathSegment(userInput, StandardCharsets.UTF_8);
String safeQueryParam = UriUtils.encodeQueryParam(userInput, StandardCharsets.UTF_8);
Apache Commons (for log sanitization):
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
// Replace CRLF with single space
String safe = StringUtils.normalizeSpace(userInput);
// Escape for Java strings
String safeJava = StringEscapeUtils.escapeJava(userInput);
OWASP ESAPI (for log sanitization):
import org.owasp.esapi.StringUtilities;
// stripControls throws NullPointerException on null input in ESAPI 2.7.0.0
String safe = userInput == null ? null : StringUtilities.stripControls(userInput);
OWASP Encoder (for URLs):
import org.owasp.encoder.Encode;
String safeUri = Encode.forUri(userInput);
String safeUriComponent = Encode.forUriComponent(userInput);
Input Validation
import java.util.regex.Pattern;
public class CrlfValidator {
private static final Pattern CRLF_PATTERN = Pattern.compile("[\\r\\n]");
public static boolean containsCrlf(String input) {
return input != null && CRLF_PATTERN.matcher(input).find();
}
public static String removeCrlf(String input) {
if (input == null) return null;
return CRLF_PATTERN.matcher(input).replaceAll("");
}
public static void validateNoCrlf(String input, String paramName) {
if (containsCrlf(input)) {
throw new IllegalArgumentException(
paramName + " contains invalid CRLF characters"
);
}
}
}
// Usage
@GetMapping("/process")
public String process(@RequestParam String userInput) {
CrlfValidator.validateNoCrlf(userInput, "userInput");
// Process safely
return "success";
}
Why this works:
One utility class keeps the CRLF check in a single place instead of spread across call sites. The pattern [\\r\\n] matches a carriage return or line feed anywhere in the input, and compiling it once into a static Pattern rather than on every call keeps the check cheap enough to run on each request.
The three methods do different jobs. containsCrlf() detects without modifying, which suits conditional logic, security logging, or metrics that count attack attempts. removeCrlf() strips the characters and returns the remainder. validateNoCrlf() throws, so the request is rejected rather than repaired - the reject-don't-strip choice the rest of this page applies.
Throwing is the right response for header values, redirect targets and authentication data: a CR or LF in those is an attack attempt far more often than it is legitimate input, so reject the request and log the attempt. The exception message names the parameter, which is what tells an investigator which field carried it. Run the validator at the trust boundary - the controller or the input DTO - so the value never reaches the code that would put it in a header.
Common Pitfalls
- Using
URLEncoder.encode()as a general CRLF-safe encoder for header or redirect values - it's designed forapplication/x-www-form-urlencodedbodies (it encodes spaces as+, not%20), and using it outside that context can produce a value that doesn't mean what the developer expects rather than a correctly structured header. - Stripping CRLF with
filename.replaceAll("[\\r\\n]", "")in one method while a different method still builds the same header with plain concatenation (headers.add("Content-Disposition", "attachment; filename=" + filename)) - the sanitizer and the header-building call live in different places, so a later change to either one can silently drop the protection. - Treating SLF4J's
{}placeholders as the whole fix. They keep the template and the value apart, but Logback's defaultPatternLayoutstill writes the resolved value into the line verbatim, so a\r\nin it forges an entry exactly as concatenation would - the layout or encoder has to escape it as well. - Converting the happy path to placeholders and leaving a "quick" debug or catch-block statement on concatenation (
logger.info("User " + username)). The template half of the fix is closed per call site, not per class, so one leftover concatenation reopens it. - Writing the newline check as
value.matches(".*[\\r\\n].*").String.matches()requires the whole string to match and.skips line terminators, so the expression isfalsefor a value containing\r\n- it catches a lone CR or LF and misses the pair. UsePattern.compile("[\\r\\n]").matcher(value).find(), or add(?s)if thematches()form has to stay.