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. For download names, keep only letters, digits and a few separators, then let res.attachment() write Content-Disposition. Never paste request data into a log line or a header by hand.
- Find every log call and every header write that takes request data: console.log and fs.appendFile with template strings, logger messages built by concatenation, res.setHeader, res.set and res.append, and Content-Disposition, Location and Set-Cookie built from req.query, req.body or req.params.
- Replace text log lines with a structured logger (pino here): pass request data as fields, logger.warn({ event: 'login_failed', user, ip }, 'login failed'), never inside the message string.
- If a log must stay plain text, encode instead of deleting: escape every control character (C0, DEL and C1) and the separator you use, so the reader can still see what was sent.
- Build download names from an allowlist (letters and digits in any script, space, dot, hyphen, underscore), trim them and cap the length; fall back to a fixed name when nothing is left.
- Write the header with res.attachment(name) or the content-disposition package, which quotes the name and adds an RFC 5987 filename* for characters outside Latin-1, instead of a template string.
- Add regression tests that sign in with LF, CR, CRLF and ESC in the username and read the log back with a reader that ends lines at LF, CR and CRLF, and that download with CR, LF, quotes and non-Latin names.
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
export function createAuditLog(file) {
const write = (level, message) =>
appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`);
return {
loginFailed: (username, ip) => write('WARN', `login failed user=${username} ip=${ip}`),
loginSucceeded: (username, ip) => write('INFO', `login succeeded user=${username} ip=${ip}`),
};
}With IMPL=vulnerable, 8 of 13 tests fail (7 exploit tests).
The vulnerable code: the download header
export function sendCsv(res, name, csv) {
res.setHeader('Content-Disposition', `attachment; filename="${name}.csv"`);
res.type('text/csv').send(csv);
}With IMPL=vulnerable, 8 of 13 tests fail (7 exploit tests).
The fix: structured logging
// pino escapes CR, LF, ESC and every other control character inside JSON strings, so a value
// can never end the record or start a forged one, whatever it contains.
export function createAuditLog(file) {
const logger = pino(
{ timestamp: pino.stdTimeFunctions.isoTime },
pino.destination({ dest: file, sync: true }),
);
return {
loginFailed: (username, ip) =>
logger.warn({ event: 'login_failed', user: username, ip }, 'login failed'),
loginSucceeded: (username, ip) =>
logger.info({ event: 'login_succeeded', user: username, ip }, 'login succeeded'),
};
}With IMPL=fixed, 13 of 13 tests pass.
The fix: a clean name and res.attachment()
// Keep letters and digits in any script, spaces, dots, hyphens and underscores, and replace
// everything else, including control characters, quotes, slashes and bidi overrides.
const UNSAFE = /[^\p{L}\p{N} ._-]+/gu;
export function safeFileName(name) {
const cleaned = name
.normalize('NFC')
.replace(UNSAFE, '_')
.replace(/^[ ._]+|[ ._]+$/g, '')
.slice(0, 100);
return cleaned || 'export';
}
export function sendCsv(res, name, csv) {
// res.attachment() uses the content-disposition package: it quotes the name and adds an
// RFC 5987 filename* parameter for characters outside Latin-1.
res.attachment(`${safeFileName(name)}.csv`);
res.send(csv);
}With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
const stripNewlines = (value) => value.replace(/\n/g, '');
export function createAuditLog(file) {
const write = (level, message) =>
appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`);
return {
loginFailed: (username, ip) =>
write('WARN', `login failed user=${stripNewlines(username)} ip=${ip}`),
loginSucceeded: (username, ip) =>
write('INFO', `login succeeded user=${stripNewlines(username)} ip=${ip}`),
};
}With IMPL=wrong, 6 of 13 tests fail (5 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.
describe(`exploit (IMPL=${implName})`, () => {
for (const [label, separator] of [
['an LF', '\n'],
['a lone CR', '\r'],
['a CRLF', '\r\n'],
]) {
test(`${label} in the username cannot add a forged log record`, async () => {
await signIn(`alice${separator}${FORGED}`);
const records = logRecords();
assert.equal(records.length, 1, `one failed sign-in became ${records.length} records:\n${records.join('\n')}`);
assert.ok(!records.some((record) => record.startsWith(FORGED)), records.join('\n'));
});
}
test('terminal escape sequences in the username do not reach the log raw', async () => {
// 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.
await signIn('alice\u001b[2K\u001b[1A');
assert.ok(!readFileSync(logFile, 'utf8').includes('\u001b'), 'the log contains a raw ESC character');
});
for (const [label, separator] of [
['a CRLF', '\r\n'],
['a lone CR', '\r'],
]) {
test(`${label} in the file name neither adds a header nor breaks the download`, async () => {
const res = await download(`report${separator}Set-Cookie: session=attacker`);
const name = savedName(res);
assert.equal(res.headers.get('set-cookie'), null);
assert.doesNotMatch(name, /[\u0000-\u001f\u007f]/, JSON.stringify(name));
assert.ok(name.endsWith('.csv'), name);
});
}
test('a double quote in the file name cannot add a filename* parameter', async () => {
// Browsers prefer filename* (RFC 6266), so this would save the CSV as evil.html.
const name = savedName(await download(`report"; filename*=UTF-8''evil.html; x="`));
assert.ok(name.endsWith('.csv'), name);
});
});- Deleting \n is not enough: a lone CR ends a line for Python's universal newlines, many terminals and pagers, and some log shippers, so the wrong fix's replace(/\n/g, '') still lets alice\r<forged record> through. CodeQL's js/log-injection treats exactly that replace() as a sanitizer.
- Escape sequences are injection too (CWE-150): ESC [2K and ESC [1A in a username erase and rewrite lines when an operator runs tail -f or cat on the log in a terminal.
- Node.js refuses CR, LF and any character above U+00FF in a header value (TypeError ERR_INVALID_CHAR from res.setHeader), so response splitting through res.setHeader fails in current Node.js. The hand-built header turns the attack into a 500 instead, and breaks every legitimate name outside Latin-1, such as 売上 2026.
- res.attachment() alone is not the whole fix: it encodes CR and LF safely as %0D%0A inside filename*, but the browser then receives a name with control characters in it. Clean the name first.
- A double quote in a hand-built header ends the quoted filename, so report"; filename*=UTF-8''evil.html; x=" adds a filename* parameter that browsers prefer (RFC 6266), and the CSV is saved as evil.html. Stripping quotes by hand fixes this one payload, not the class.
- JSON lines keep U+2028, U+2029 and U+0085 unescaped (they are valid in JSON strings). They do not end a line for this card's reader, but Python's str.splitlines() treats them as line breaks; encode them too if your log tooling splits on them.
- Log the fact, not the secret: never log the password field, and consider logging a hash or a truncated value of usernames on sign-in failures, since people often type their password into the username box.
- 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/node-express does this for you).
- Against your own app, sign in with the username alice%0D2026-10-10T09:00:00Z%20INFO%20login%20succeeded%20user%3Dadmin and confirm the log shows one failed attempt, then request a download whose name contains %0D%0A and %22 and check the response is a normal download whose name ends in your extension.
- Static analysis found nothing here (no Semgrep JavaScript rule exists, and CodeQL models neither fs.appendFileSync nor pino as a log sink), 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 | p/default, p/security-audit, p/owasp-top-ten, p/nodejs | No findings on any implementation. The registry has no JavaScript rule for log injection or for header injection: its CRLF rules are Java-only (java.lang.security.audit.crlf-injection-logs, java.lang.security.audit.http-response-splitting). A single-file probe that concatenates req.body.username into console.log and fs.appendFileSync, and req.query.name into res.setHeader('Content-Disposition', ...), drew no log or header finding either.Checked: Semgrep 1.180.0 (CE); semgrep scan --config <pack> app.js impl |
| CodeQL | js/log-injection | Not expected to flag this card. Its sinks are LoggerCall arguments (console, loglevel, winston, log4js, npmlog, fancy-log, debug); fs.appendFileSync, which the vulnerable and wrong logs use, is not one, and pino is not modelled. Its barriers include JSON.stringify and any replace() that deletes a newline, so the wrong fix's replace(/\n/g, '') would count as sanitized in a console.log call although a lone CR still forges a record.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| pino | 10.4.0 |
| content-disposition (used by res.attachment(); the tests parse headers with it) | 2.0.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/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs crlf-log-injection/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- pino 10.4.0
- semgrep 1.180.0
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 8 of 13 tests fail (7 exploit tests); wrong: 6 of 13 tests fail (5 exploit tests)
- Test results
- fixed 13/13 pass; vulnerable 8/13 fail (7 exploit tests); wrong 6/13 fail (5 exploit tests), also offline after install with outbound network blocked
- Dependency freshness
- express 5.3.0 was published on 2026-10-09 and is taken at one day old because it is a security release (qs, body-parser and proxy-addr updates); pino 10.4.0 on 2026-10-02; content-disposition 2.0.1 on 2026-05-28 (3.0.0 exists, but Express 5.3.0 depends on ^2.0.1, so the tests parse with the same version Express writes with). npm audit found 0 vulnerabilities and npm audit signatures verified all 78 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
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)
- OWASP: Log Injection
- CWE-117: Improper Output Neutralization for Logs
- CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers
- pino API: logger methods and destinations
- Node.js errors: ERR_INVALID_CHAR
- Express 5 API: res.attachment()
- content-disposition (npm): create and parse Content-Disposition headers
- RFC 6266: Use of the Content-Disposition Header Field in HTTP
- RFC 9110 section 5.5: field values (CR, LF and NUL are invalid)
- CodeQL query help: js/log-injection
Attack patterns: