no-log-injection
Detects request data concatenated into a log message, which lets an attacker forge log records
CWE: CWE-117 OWASP: A09:2021 Security Logging and Monitoring Failures
A log file is a record with line boundaries. When a request field reaches the message text unneutralized, a \r\n inside that value ends the record early and starts a new one that the attacker writes. This rule is part of eslint-plugin-secure-coding.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-117 (Improper Output Neutralization for Logs) |
| Severity | Medium (CVSS 5.3) |
| Auto-Fix | ❌ No — the fix is a design choice (structured field vs. neutralization) |
| Category | Security |
Vulnerability and Risk
Vulnerability: A value the caller controls is concatenated into the text of a log line. The logger writes the text verbatim, so any CR/LF the value carries becomes a line boundary in the log.
Risk: The attacker writes log records of their own choosing. That is not a cosmetic problem — the forged record is indistinguishable from a real one to every downstream consumer: SIEM correlation rules, the on-call engineer's grep, and the incident timeline that a breach investigation is reconstructed from. An attacker who can forge [INFO] login ok for admin can hide the request that actually happened.
logger.info('login attempt: ' + req.body.username);
// username = "bob\n[INFO] login ok for admin"
//
// login attempt: bob
// [INFO] login ok for adminRule Details
The rule reports one thing, and abstains from everything else.
Sink. A call of the form <receiver>.<level>(…) where <level> is a log level (log, info, warn, error, debug, trace, fatal, verbose, silly) and <receiver> is a name that only a logger carries: console, log, logger, winston, pino, bunyan — either directly (logger.info) or as a property (this.logger.info, fastify.log.info, req.log.info). The level alone is worthless as evidence: error, warn and trace are method names on assertion libraries, span objects and event emitters. The receiver is what says "this string becomes a log record".
Message shape. Only a TemplateLiteral or a + concatenation is a message. An object argument is a field carrier, not a line fragment — the logger JSON-encodes it, so a newline inside it cannot end the record. That is why structured logging is silent here.
Attribution. The embedded expression must be traceable to an inbound request: a member expression rooted at req / request / ctx / event / message reading body, query, params, headers, url, path, cookies or data — reached directly, or through one hop of a local binding in the same function. Scope analysis resolves the hop; nothing is matched by name.
What makes it abstain. Anything that is not direct. sanitizeForLog(req.body.username) wraps the value in a call, so the rule can no longer say what reaches the line, so it says nothing. This is not a special case for functions named "sanitize" — any call, any operator, any indirection has the same effect. The consequence is deliberate: a log line with no request provenance (console.log('processed ' + count + ' items')) can never be reported, which is the shape almost every log statement in a published library takes.
One report per logging call. A template can interpolate four request fields; they are one defect with one fix, and four squiggles on one line would only make that fix harder to see.
Examples
❌ Incorrect
function onLoginAttempt(req) {
logger.info('login attempt: ' + req.body.username);
}
function auditRequest(req) {
const forwardedFor = req.headers['x-forwarded-for'];
logger.info(`request user=${req.query.user} ip=${forwardedFor}`);
}
console.error(`bad path ${req.path}`);✅ Correct
// Structured logging: the message is constant, the value is an encoded field.
function onLoginAttempt(req) {
logger.info({ event: 'login_attempt', username: req.body.username }, 'login attempt');
}
// Or neutralize the record separators before the value reaches the line.
function sanitizeForLog(value) {
return String(value).replace(/[\r\n\t]+/g, ' ').slice(0, 256);
}
function onLoginFailure(req) {
logger.info('login attempt: ' + sanitizeForLog(req.body.username));
}Configuration
{
rules: {
'secure-coding/no-log-injection': ['error', {
loggerNames: ['audit', 'tracer'],
requestRoots: ['payload']
}]
}
}Options
| Option | Type | Default | Description |
|---|---|---|---|
loggerReceivers | string[] | ["console","log","logger","winston","pino","bunyan"] | Receiver names whose level methods write a log line, compared as an exact name and never as a substring. Replaces the built-in list. |
loggerNames | string[] | [] | Additional receiver names whose level methods write a log line, on top of loggerReceivers |
requestRootNames | string[] | ["req","request","ctx","event","message"] | Identifier roots that denote an inbound request, matched as the exact ROOT of a member chain. Replaces the built-in list. |
requestRoots | string[] | [] | Additional identifier roots that denote an inbound request, on top of requestRootNames |
requestProperties | string[] | ["query","params","body","headers","url","path","cookies","data"] | Request properties that carry caller-supplied data, matched as a whole segment of the member chain. Replaces the built-in list. |
additionalRequestProperties | string[] | [] | Extra request properties, on top of requestProperties — hapi's request.payload belongs here |
Error Message Format
🔒 CWE-117 OWASP:A09-Logging CVSS:5.3 | Log message embeds req.body.username directly - a CR/LF in that value forges a log record | MEDIUM
Fix: Log it as a structured field (logger.info({ value }, "message")) or strip CR/LF/control characters first | https://cwe.mitre.org/data/definitions/117.htmlThe finding names the field it attributed. If the rule cannot name one, it does not report.
Known False Negatives
These are the price of the attribution rule above, and they are paid on purpose.
More than one hop
Why: Only a single local binding is followed. Two assignments away, the rule can no longer attribute the value.
const raw = req.body.username;
const name = raw;
logger.info('user: ' + name); // ❌ NOT DETECTEDMitigation: Prefer structured fields for anything that came off a request, regardless of how many bindings ago.
Values that crossed a function boundary
Why: A parameter's provenance belongs to the caller, and this rule does not do interprocedural analysis.
function audit(username) {
logger.info('user: ' + username); // ❌ NOT DETECTED
}
audit(req.body.username);Mitigation: Neutralize at the boundary where the value enters the process.
Values wrapped in a call that does not neutralize
Why: Any call breaks attribution, including one that does nothing useful — logger.info('user: ' + String(req.body.username)) is not reported.
Mitigation: Do not rely on the absence of a finding as proof a value was neutralized; the rule reports what it can attribute, not everything that is unsafe.
Further Reading
- CWE-117 — Improper Output Neutralization for Logs
- OWASP Log Injection — attack documentation
- OWASP Logging Cheat Sheet — what to log and how
Related Rules
no-sensitive-data-exposure— keeps secrets out of logs (what is logged, rather than how)no-unsafe-regex-construction— shares this rule's request-attribution model
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.