Software Securityhome
Fix a findingSearch
Fix card · A09:2025 Security Logging and Alerting Failures

Fix CRLF and log injection in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks2 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerable…/audit/impl/VulnerableRequestDataOutput.javaJava
@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

Vulnerable…/audit/impl/VulnerableRequestDataOutput.javaJava
@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

Fixed…/audit/impl/FixedRequestDataOutput.javaJava
// 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

Fixed…/audit/impl/FixedRequestDataOutput.javaJava
// 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

Wrong fix…/audit/impl/WrongRequestDataOutput.javaJava
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 regression test

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.

Test…/example/audit/ExploitTest.javaJava
/** 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");
    }
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix CRLF and log injection in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.crlf-injection-logs.crlf-injection-logsNo 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
Semgrepjava.lang.security.audit.http-response-splitting.http-response-splittingNo 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)
CodeQLjava/log-injectionFrom 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
CodeQLjava/http-response-splittingNot 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC on Tomcat 11.0.26, Logback)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.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

Run the tests

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).

Shell
mvn -B -q test
Shell
# 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 test

Each 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.

Download the project ZIP, 26 KB

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

  1. OWASP Logging Cheat Sheet (event data sanitization)
  2. Spring Boot: structured logging
  3. Spring Framework API: ContentDisposition.Builder.filename(String, Charset)
  4. Logback: PatternLayout conversion words
  5. Find Security Bugs: CRLF_INJECTION_LOGS
  6. RFC 6266: Use of the Content-Disposition Header Field in HTTP
  7. CWE-117: Improper Output Neutralization for Logs
  8. CodeQL query help: java/log-injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.