Choose your stack
Fix CRLF and log injection in Node.js and Express
Log with a structured logger such as pino, so every field is JSON-encoded and a CR, LF or escape character in a value can never end the record.
Open the fix card Python · FlaskFix CRLF and log injection in Python and Flask
Log events as structured data: pass request values to the logger as fields and format each record as one JSON object, so CR, LF and escape characters are encoded and cannot end it.
Open the fix card Java · Spring BootFix CRLF and log injection in Java and Spring Boot
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.
Open the fix cardStacks without a card
- C# and ASP.NET Core: a card is planned (coverage row APP-22).
- TypeScript and Next.js: no card yet. The nearest is Fix CRLF and log injection in Node.js and Express, in the same language.
- Python and FastAPI: no card yet. The nearest is Fix CRLF and log injection in Python and Flask, in the same language.
- Python and Django: no card yet. The nearest is Fix CRLF and log injection in Python and Flask, in the same language.
- Go and net/http: no card yet. The nearest is Fix CRLF and log injection in Node.js and Express.
- Ruby on Rails: no card yet. The nearest is Fix CRLF and log injection in Node.js and Express.
- PHP and Laravel: no card yet. The nearest is Fix CRLF and log injection in Node.js and Express.
How it happens
- Log lines are built by interpolation: logger.warning('login failed user=%s', username) or a template string in fs.appendFile. A username of alice followed by LF and '2026-10-10T09:00:00Z INFO login succeeded user=admin' becomes two records, and the second looks like any other.
- The fix deletes only LF, or only CRLF pairs. A lone CR still ends a line for Python's universal newlines, many terminals, pagers and log shippers, and other control characters pass untouched. Static analysis encourages this: CodeQL's JavaScript and Python log-injection queries both count a replace() that deletes a newline as a sanitizer.
- Control characters other than line breaks still reach the reader: ANSI escape sequences such as ESC [2K and ESC [1A erase and rewrite lines when an operator views the log in a terminal, which is CWE-150.
- Header values are built by hand, such as
attachment; filename="${name}.csv". Current servers no longer let a CR or LF split the response: Node.js and Werkzeug refuse the value, so the request fails with a 500, and Tomcat 11 turns each CR and LF into a space. What still works is a double quote that ends the quoted file name and adds a filename* parameter, which browsers prefer, so the file is saved as evil.html; and a name outside Latin-1 breaks the download (a 500 in Node.js, a header Tomcat silently drops).
Fix principles
- Log events as structured data: pass request values as fields to a JSON logger (pino, a JSON formatter on Python's logging) so every control character is encoded and a value cannot end its record.
- When a log must stay plain text, encode every control character (C0, DEL and C1) and the field separator; deleting characters destroys evidence and misses the ones you did not think of.
- Never build a header value by concatenation. Reduce names to an allowlist of characters first, then let the framework write the header: res.attachment(), send_file(download_name=...), ContentDisposition.builder(), which quote the value and add an RFC 5987 filename* for non-ASCII names.
- Treat the server's handling of CR and LF in headers (a 500 in Node.js and Flask, a space in Tomcat) as a last line of defence, not as the fix: it differs between servers and proxies.
- Do not log secrets: never log passwords, and keep usernames from failed sign-ins short and encoded, since users often type their password into that field.
The scenario
Every card runs the same two features. POST /login takes a username and password and writes each attempt to an audit log, which people and tools read line by line; the tests read it the way Python's universal newlines do, ending a line at LF, CR or CRLF. GET /export?name= returns a CSV report as a download saved under the name the user typed, in Content-Disposition. Alice has an account; every exploit is a single request from an anonymous client.
How the cards prove it
- Run each card's tests in three modes: the fixed implementation passes, and the vulnerable and plausible-but-wrong implementations fail. run-all.mjs does this for every card.
- The exploit tests send LF, a lone CR and CRLF in the username followed by a forged success record, a username with ANSI escape sequences, line breaks in the download name followed by a Set-Cookie header, and a double quote that tries to add filename*. One failed sign-in must stay one record, the log must hold no raw ESC, and each download must answer 200 with only filename and filename* parameters and a name that ends in .csv and holds no control characters. Functional tests keep readable non-ASCII usernames, the chosen download name and names in other scripts working. On Spring the file-name line-break tests pass even for the vulnerable code, because Tomcat replaces CR and LF with spaces; they stay as regression tests for other containers.
- Static analysis is thin: Semgrep's registry has CRLF rules for Java only, and they need the logger and the servlet request in one method or a Cookie, so they found nothing here; CodeQL's JavaScript, Python and Java log-injection queries all accept a replace() of LF as a fix; and CodeQL's Python response-splitting query treats Werkzeug headers as unable to carry a newline. Each card's scannerRules records what ran and what it found.
Identifiers
CWE-117 (Improper Output Neutralization for Logs) is the main weakness: request data written into a log record unencoded, so CR, LF or escape characters forge or hide records; OWASP Top 10:2025 maps it to A09 Security Logging and Alerting Failures, and CAPEC-93 (Log Injection-Tampering-Forging) is the attack. CWE-93 (Improper Neutralization of CRLF Sequences) is its parent class, and CWE-113 (Improper Neutralization of CRLF Sequences in HTTP Headers) covers the download header; both map to A05 Injection, and CAPEC-34 (HTTP Response Splitting) is the classic header attack. The terminal escape-sequence test is closer to CWE-150 (Improper Neutralization of Escape, Meta, or Control Sequences). ASVS 5.0.0 16.4.1 asks that logging components encode data to prevent log injection, 1.2.1 that output encoding fit the context, HTTP header fields included, and 1.3.3 that data passed to a dangerous context keep only characters safe for it.
Attack patterns:
References
- OWASP Logging Cheat Sheet
- OWASP: Log Injection
- CWE-117: Improper Output Neutralization for Logs
- CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection')
- CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting')
- CWE-150: Improper Neutralization of Escape, Meta, or Control Sequences
- CAPEC-93: Log Injection-Tampering-Forging
- CAPEC-34: HTTP Response Splitting
- RFC 6266: Use of the Content-Disposition Header Field in HTTP
- RFC 9110 section 5.5: field values (CR, LF and NUL are invalid)
Guides on this weakness
Create security logs someone can investigate
Record meaningful decisions with consistent structure, protect the resulting evidence, and prove that important events reach an operational owner.
Prevent CRLF header injection, log forging and CSV formula injection
Carriage return and line feed separate HTTP header fields, log records and CSV rows, and a leading = turns a spreadsheet cell into a formula. Let the framework write headers, log structured JSON, and neutralize formula triggers in exports.