Encode request values before they reach a log line, turning every control character into a visible escape, or use Spring Boot's structured JSON logging; never delete only LF. For download names, keep only letters, digits and a few separators, then build the header with ContentDisposition.attachment().filename(name, UTF_8) instead of concatenating it.
- Find every log call and header write that takes request data: SLF4J calls with @RequestParam, @PathVariable or request body values, and HttpHeaders, ResponseEntity.header(), HttpServletResponse.setHeader and addHeader with names or values built by concatenation.
- Encode values for plain-text logs: escape the backslash and every control character (C0, DEL, C1, U+2028 and U+2029) as \uXXXX so the record stays one line and the original stays visible, as forLog() does here.
- Or switch the log to structured JSON with Spring Boot's logging.structured.format.file (ecs, gelf or logstash) and log request values as key-value pairs; the encoder escapes control characters in JSON strings.
- Build download names from an allowlist (\p{L}, \p{N}, space, dot, hyphen, underscore), trim them, cap the length and fall back to a fixed name.
- Write Content-Disposition with ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8).build() and headers.setContentDisposition(), which add an RFC 5987 filename* and an ASCII fallback.
- Add regression tests that run on a real server (MockMvc does not show what Tomcat does with a header) and read the log back with a reader that ends lines at LF, CR and CRLF.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public void loginFailed(String username, String ip) {
audit.warn("login failed user={} ip={}", username, ip);
}
@Override
public void loginSucceeded(String username, String ip) {
audit.info("login succeeded user={} ip={}", username, ip);
}With IMPL=vulnerable, 6 of 14 tests fail (5 exploit tests).
The vulnerable code: the download header
@Override
public ResponseEntity<String> download(String name, String csv) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + ".csv\"")
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.body(csv);
}With IMPL=vulnerable, 6 of 14 tests fail (5 exploit tests).
The fix: encode values for the log
// Encode, don't delete: the backslash and every control character (C0, DEL, C1 and the
// Unicode line and paragraph separators) become visible \\uXXXX escapes, so the record stays
// one line and the reader still sees what was sent.
static String forLog(String value) {
StringBuilder out = new StringBuilder(value.length());
value.codePoints().forEach(cp -> {
if (cp == '\\') {
out.append("\\\\");
} else if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp == 0x2028 || cp == 0x2029) {
out.append(String.format("\\u%04x", cp));
} else {
out.appendCodePoint(cp);
}
});
return out.toString();
}
@Override
public void loginFailed(String username, String ip) {
audit.warn("login failed user={} ip={}", forLog(username), ip);
}
@Override
public void loginSucceeded(String username, String ip) {
audit.info("login succeeded user={} ip={}", forLog(username), ip);
}With IMPL=fixed, 14 of 14 tests pass.
The fix: a clean name and ContentDisposition
// Keep letters and digits in any script, spaces, dots, hyphens and underscores, and replace
// everything else, including control characters, quotes, slashes and bidi overrides.
private static final Pattern UNSAFE = Pattern.compile("[^\\p{L}\\p{N} ._-]+");
static String safeFileName(String name) {
String cleaned = UNSAFE.matcher(Normalizer.normalize(name, Normalizer.Form.NFC))
.replaceAll("_")
.replaceAll("^[ ._]+|[ ._]+$", "");
cleaned = cleaned.codePoints().limit(100)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
return cleaned.isEmpty() ? "export" : cleaned;
}
@Override
public ResponseEntity<String> download(String name, String csv) {
// With a charset, ContentDisposition writes the name as an RFC 5987 filename* parameter.
ContentDisposition disposition = ContentDisposition.attachment()
.filename(safeFileName(name) + ".csv", StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.headers(headers -> headers.setContentDisposition(disposition))
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.body(csv);
}With IMPL=fixed, 14 of 14 tests pass.
A fix that looks safe but is not
private static String stripNewlines(String value) {
return value.replace("\n", "");
}
@Override
public void loginFailed(String username, String ip) {
audit.warn("login failed user={} ip={}", stripNewlines(username), ip);
}
@Override
public void loginSucceeded(String username, String ip) {
audit.info("login succeeded user={} ip={}", stripNewlines(username), ip);
}With IMPL=wrong, 4 of 14 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/** Request data cannot forge log records, rewrite the terminal or change the download header. */
class ExploitTest extends CrlfFlow {
@ParameterizedTest(name = "a line break ({0}) in the username cannot add a forged log record")
@ValueSource(strings = {"\n", "\r", "\r\n"})
void aLineBreakInTheUsernameCannotAddAForgedLogRecord(String separator) throws Exception {
signIn("alice" + separator + FORGED, "not-the-password");
List<String> records = newLogRecords();
assertThat(records).as("one failed sign-in").hasSize(1);
assertThat(records).noneMatch(record -> record.startsWith(FORGED));
}
@Test
void terminalEscapeSequencesInTheUsernameDoNotReachTheLogRaw() throws Exception {
// ESC [2K erases the line and ESC [1A moves the cursor up: in a terminal, tail -f or cat
// of the log would rewrite what the reader sees.
signIn("alice\u001b[2K\u001b[1A", "not-the-password");
assertThat(newLogText()).doesNotContain("\u001b");
}
@ParameterizedTest(name = "a line break ({0}) in the file name neither adds a header nor breaks the download")
@ValueSource(strings = {"\r\n", "\r", "\n"})
void aLineBreakInTheFileNameNeitherAddsAHeaderNorBreaksTheDownload(String separator) throws Exception {
HttpResponse<String> response = download("report" + separator + "Set-Cookie: session=attacker");
String name = savedName(response);
assertThat(response.headers().allValues("Set-Cookie")).isEmpty();
assertThat(name).doesNotContainPattern("[\\x00-\\x1f\\x7f]").endsWith(".csv");
}
@Test
void aDoubleQuoteInTheFileNameCannotAddAFilenameStarParameter() throws Exception {
// Browsers prefer filename* (RFC 6266), so this would save the CSV as evil.html.
assertThat(savedName(download("report\"; filename*=UTF-8''evil.html; x=\""))).endsWith(".csv");
}
}- Logback's PatternLayout writes %msg as it is, so audit.warn("login failed user={}", username) writes CR, LF and ESC straight into the file.
- Deleting \n is not enough: a lone CR ends a line for Python's universal newlines, many terminals and log shippers, so the wrong fix's replace("\n", "") still lets alice\r<forged record> through, and CodeQL's java/log-injection treats that replace() as a sanitizer.
- Tomcat 11.0.26 replaces each CR and LF in a header value with a space, so a CRLF in the hand-built Content-Disposition neither splits the response nor fails it: those exploit tests pass even for the vulnerable code here. Other servlet containers and proxies behave differently; do not rely on it.
- Tomcat drops a header whose value has a character above U+00FF and logs a warning ("has been removed from the response because it is invalid"), so a hand-built Content-Disposition for 売上 2026 silently disappears and the browser saves the file under the URL's name.
- A double quote in a hand-built header ends the quoted file name: report"; filename*=UTF-8''evil.html; x=" adds a filename* parameter that browsers prefer (RFC 6266), so the CSV is saved as evil.html.
- ContentDisposition.filename(name) without a charset writes the name in a quoted string only; pass StandardCharsets.UTF_8 so non-ASCII names get filename*.
- Never log passwords, and keep failed-sign-in usernames short and encoded: people type passwords into the username field.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs crlf-log-injection/java-spring does this for you).
- Against your own app, sign in with a username containing %0D followed by a fake success record and confirm the log shows one failed attempt; request a download whose name holds %22 and a non-Latin name, and check the saved names.
- Static analysis is little help: the Semgrep Java CRLF rules need the logger and the servlet request in one method or a Cookie, and CodeQL accepts the wrong fix, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.lang.security.audit.crlf-injection-logs.crlf-injection-logs | No findings on any implementation. The rule only matches a logger call inside a method that also has an HttpServletRequest or ServletRequest variable; here the controller takes @RequestParam values and the logging happens in another class.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/java.lang.security.audit.crlf-injection-logs.crlf-injection-logs src/main/java |
| Semgrep | java.lang.security.audit.http-response-splitting.http-response-splitting | No findings: the rule matches request data placed in a Cookie, not in other headers. p/default, p/security-audit, p/owasp-top-ten and p/java found nothing either.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/log-injection | From the source: SLF4J logger arguments are log-injection sinks (org.slf4j.model.yml), and String.replace() of "\n" or "\r" counts as a sanitizer (semmle/code/java/security/LogInjection.qll). So it should report the vulnerable code and accept the wrong fix's replace("\n", ""), although a lone CR still forges a record; the fixed forLog() is not a recognised sanitizer, so the fixed code may be reported too.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | java/http-response-splitting | Not expected to flag this card: the response-splitting sinks modelled are javax.servlet HttpServletResponse.setHeader, addHeader and addCookie; Spring's ResponseEntity header builder is not one.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC on Tomcat 11.0.26, Logback) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.apache.tomcat.embed:tomcat-embed-core (overridden from Boot's 11.0.24) | 11.0.26 |
| ch.qos.logback:logback-classic (managed by Spring Boot) | 1.5.38 |
| org.springframework.boot:spring-boot-starter-webmvc-test (test only) | 4.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the crlf-log-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs crlf-log-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- tomcat 11.0.26
- semgrep 1.180.0
- Matrix
- fixed: 14 of 14 tests pass; vulnerable: 6 of 14 tests fail (5 exploit tests); wrong: 4 of 14 tests fail (3 exploit tests)
- Test results
- fixed 14/14 pass; vulnerable 6/14 fail (5 exploit tests); wrong 4/14 fail (3 exploit tests), also offline after install with outbound network blocked. The three CR/LF-in-file-name tests pass in every mode because Tomcat 11.0.26 turns each CR and LF in a header value into a space.
- Dependency freshness
- Spring Boot 4.1.1 was released on 2026-08-20. The pom overrides Tomcat to 11.0.26 and Jackson to 3.1.7, as the other Spring cards do, because Boot 4.1.1 manages versions with published advisories (Tomcat CVE-2026-65182, CVE-2026-65905 and CVE-2026-68525 are fixed in 11.0.25, and CVE-2026-86350 and CVE-2026-78383 in 11.0.26).
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Logging Cheat Sheet (event data sanitization)
- Spring Boot: structured logging
- Spring Framework API: ContentDisposition.Builder.filename(String, Charset)
- Logback: PatternLayout conversion words
- Find Security Bugs: CRLF_INJECTION_LOGS
- RFC 6266: Use of the Content-Disposition Header Field in HTTP
- CWE-117: Improper Output Neutralization for Logs
- CodeQL query help: java/log-injection
Attack patterns: