CWE-117: Improper Output Neutralization for Logs - JavaScript/Node.js
Overview
Log Injection in JavaScript/Node.js occurs when untrusted user input is written to logs without encoding, letting an attacker inject newlines that forge log entries or mislead the tools that parse them. An application is exposed wherever that input reaches a text-format line - console.log, winston's format.simple(), or a hand-concatenated file entry - because control characters like \n, \r, or ANSI escape codes pass through unchanged.
Primary Defence: Use structured logging with JSON/ECS output (winston format.json(), pino, bunyan) and pass values as fields rather than interpolating them into the message. All three serialize through JSON.stringify, which escapes the ASCII control range, so CR and LF land inside the field and cannot forge an entry. Two gaps remain and are worth knowing before you close the finding: JSON.stringify emits U+2028, U+2029 and U+0085 raw, and any console.log beside the structured call writes unescaped text. Where you must encode by hand, encode rather than strip - \n becomes the two characters \n, which keeps the attacker's payload readable - and cap length, because JSON encoding does nothing about log flooding.
Common Vulnerable Patterns
console.log with User Input
const express = require('express');
const app = express();
app.post('/login', express.json(), (req, res) => {
const username = req.body.username;
// VULNERABLE - User input directly in log
console.log(`Login attempt for user: ${username}`);
// Authenticate user...
res.json({ success: true });
});
// Attack: POST /login with username = "admin\nSUCCESS: admin logged in"
// Log output:
// Login attempt for user: admin
// SUCCESS: admin logged in
// Result: Fake success message injected into logs
Why this is vulnerable:
console.logwrites newline characters as real line breaks.- User input can inject
\n/\rto create extra log lines. - Attackers can forge or hide events in line-based log formats.
- Log analysis tools may treat injected lines as legitimate events.
winston Logger with String Interpolation
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.simple(),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
app.get('/search', (req, res) => {
const query = req.query.q;
// VULNERABLE - String interpolation with user input
logger.info(`Search query: ${query}`);
// Perform search...
res.json({ results: [] });
});
// Attack: /search?q=test%0A%0Ainfo: ADMIN ACCESS GRANTED for user: attacker
// Log output:
// info: Search query: test
//
// info: ADMIN ACCESS GRANTED for user: attacker
// Result: Fake admin access log entry
Why this is vulnerable:
- String interpolation does not escape newline/control characters.
- URL-encoded newlines (
%0A,%0D) become real line breaks. - Attackers can forge admin actions or fake success events.
- Simple text layouts emit injected lines as separate records.
bunyan Logger with Untrusted Data
const bunyan = require('bunyan');
const log = bunyan.createLogger({ name: 'myapp' });
app.post('/update-profile', express.json(), (req, res) => {
const email = req.body.email;
// VULNERABLE - Email directly in log message
log.info(`Profile updated for email: ${email}`);
// Update profile...
res.json({ success: true });
});
// Attempted attack: email = "user@test.com\n{\"level\":30,\"msg\":\"User promoted to admin\"}"
// Actual log output - ONE record, newline and quotes both escaped:
// {"name":"myapp",...,"msg":"Profile updated for email: user@test.com\n
// {\"level\":30,...}",...}
// The forged entry does not appear. See below for what IS wrong here.
Why this is vulnerable: Not by line injection - that part does not work, and it is worth knowing why before spending a fix on it. bunyan serializes each record with JSON.stringify, so the newline becomes the two characters \n and each injected " becomes \". Running this emits a single well-formed record; the fake promotion never materializes.
What is wrong is that a user-supplied email is being pasted into msg at all. The message field is the one part of a structured record that is meant to be constant, so this throws away the reason for using bunyan: msg becomes high-cardinality and unsearchable, an operator cannot group on it, and any downstream rule matching on message text is now matching attacker-controlled content. Put the value in its own field - log.info({ email }, 'Profile updated') - and the record stays queryable. The same interpolation in a text-format logger (see the winston format.simple() pattern above) is directly exploitable, which is why this shape is worth removing everywhere rather than only where it currently forges entries.
pino Logger with User-Controlled Fields
const pino = require('pino');
const logger = pino();
app.post('/api/action', express.json(), (req, res) => {
const action = req.body.action;
const userId = req.body.userId;
// VULNERABLE - User-controlled data in log
logger.info(`User ${userId} performed action: ${action}`);
res.json({ success: true });
});
// Attempted attack: userId = "123\nINFO: Security audit disabled by admin"
// Actual log output - ONE record, the newline escaped inside msg:
// {"level":30,"time":...,"msg":"User 123\nINFO: Security audit disabled
// by admin performed action: update"}
// The record does not split. See below for what IS wrong here.
Why this is vulnerable: Same shape as the bunyan pattern, and the same correction: pino JSON-escapes the newline, so the record stays on one line and the forged entry does not appear. Verified by running it.
The defect is that userId and action are interpolated into msg rather than passed as fields. That costs the searchability pino exists to provide, and it puts attacker-controlled text into the field alerting rules match against. Write logger.info({ userId, action }, 'User action') instead. Two things do still bite even with JSON output: a value containing U+2028, U+2029 or U+0085 is emitted raw by JSON.stringify (see the winston pattern below), and any console.log left beside the pino call writes unescaped text.
Logging Entire Request Objects
app.post('/api/data', (req, res) => {
// VULNERABLE - Logging entire request object
console.log('Request received:', JSON.stringify(req.body));
// User controls entire object structure, can inject arbitrary JSON fields
res.json({ success: true });
});
// Attack: POST with body = {"note": "a\u2028FAKE ENTRY", "password": "hunter2"}
//
// Actual output - one text line, and the JSON is only part of it:
// Request received: {"note":"a<raw U+2028>FAKE ENTRY","password":"hunter2"}
//
// JSON.stringify escaped nothing here: U+2028 is emitted raw, and the
// credential the user posted is now in the log verbatim.
Why this is vulnerable: This is a different weakness from the winston pattern below, and confusing the two sends you to the wrong fix. Nothing here is a structured record - console.log writes the literal prefix Request received: followed by a space and a string - so there are no logger-owned fields for an attacker to override, and no level to spoof. Three things are wrong instead:
JSON.stringifyleaves U+2028, U+2029 and U+0085 raw, so those are the one line-injection vector that survives it. CR and LF are escaped, which is why this looks safe on a casual test.- The whole body is logged, including whatever the user put in it - passwords, tokens, card numbers. Serializing an object you did not define means logging fields you have never seen.
- The size is unbounded, so a large body is a log-flooding vector. JSON encoding does nothing about length.
The fix is not encoding but selection: log the specific fields you need ({ action: req.body.action }), capped and encoded, rather than serializing whatever arrived.
ANSI Color Code Injection
const chalk = require('chalk');
app.get('/status', (req, res) => {
const component = req.query.component;
// VULNERABLE - User input with ANSI codes
console.log(chalk.blue(`Status check for: ${component}`));
res.json({ status: 'ok' });
});
// Attack: /status?component=server%1B[31m CRITICAL ERROR %1B[0m
// Terminal output: Status check for: server CRITICAL ERROR (displayed in red)
// Result: Fake critical error message in red causes confusion
Why this is vulnerable:
- ANSI escape sequences can change colors and formatting.
- Attackers can fake severity indicators (e.g., red "errors").
- Terminal control codes can hide or erase log lines.
- Dashboards that render ANSI codes can be misled.
Error Logging with Stack Traces
app.get('/api/data', (req, res) => {
const filter = req.query.filter;
try {
// Some operation that might fail
const data = processData(filter);
res.json(data);
} catch (error) {
// VULNERABLE - User input in error context
console.error(`Error processing filter "${filter}": ${error.message}`);
res.status(500).json({ error: 'Processing failed' });
}
});
// Attack: /api/data?filter=test"\nERROR: Database compromised - initiating emergency shutdown
// Log output:
// Error processing filter "test"
// ERROR: Database compromised - initiating emergency shutdown": Invalid input
Why this is vulnerable:
- User input is embedded in error strings without encoding.
- Newlines can inject fake critical errors into logs.
- Forged errors can trigger false incident responses.
- Audit trails become unreliable.
Audit Log with Timestamp Manipulation
const fs = require('fs');
function auditLog(action, user, details) {
const timestamp = new Date().toISOString();
// VULNERABLE - User-controlled details in audit log
const logEntry = `[${timestamp}] ${action} by ${user}: ${details}\n`;
fs.appendFileSync('audit.log', logEntry);
}
app.post('/admin/delete-user', express.json(), (req, res) => {
const targetUser = req.body.targetUser;
const reason = req.body.reason;
auditLog('DELETE_USER', req.user.username, `Target: ${targetUser}, Reason: ${reason}`);
// Delete user...
res.json({ success: true });
});
// Attack: reason = "cleanup\n[2025-01-01T00:00:00.000Z] RESTORE_USER by admin: Target: admin"
// Log shows deletion followed by fake restoration entry
Why this is vulnerable:
- File-based logs rely on line boundaries for entries.
- Newlines can inject fake timestamps and entries.
- Attackers can manipulate audit trails or create false alibis.
- Simple concatenation provides no structure or encoding.
Structured Logging with Object Injection
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.json(),
transports: [new winston.transports.File({ filename: 'structured.log' })]
});
app.post('/api/event', express.json(), (req, res) => {
// VULNERABLE - user-controlled keys spread into the top level of the record
logger.info('Event received', { type: req.body.eventType, ...req.body.metadata });
// Equally vulnerable, and the shape that shows up most often in real code:
// logger.info('Event received', req.body);
res.json({ success: true });
});
// Attack: metadata = { "timestamp": "1999-01-01T00:00:00.000Z",
// "message": "PWNED", "severity": "critical", "admin": true }
//
// Actual output (winston 3, timestamp() + json()):
// {"level":"info","message":"Event received PWNED",
// "timestamp":"1999-01-01T00:00:00.000Z","severity":"critical","admin":true,...}
//
// timestamp is REPLACED, message has attacker text APPENDED, and severity and
// admin are now real indexed fields. level is the one the logger keeps.
Why this is vulnerable: JSON encoding is doing its job here - no value breaks out of its field, and this is not line injection. The weakness is that the attacker chooses the keys, and winston merges them into the record alongside the ones the logger sets. Which fields that actually reaches is worth knowing exactly, because the three behave differently:
timestampis replaced outright. A suppliedtimestampwins over the onewinston.format.timestamp()generates, so an attacker can date their own entry into last year and move it out of the window an investigator is looking at. This is the most damaging of the three and the least obvious.messageis appended to, not replaced. The logged message becomesEvent received PWNED- enough to put attacker-controlled text into the field that alerting rules match on.levelis not spoofable. winston keeps its own level, so alevel: "error"in the body is discarded. Guidance claiming otherwise is wrong, and it matters: fixing the wrong field leavestimestampopen.- Everything else lands as a new top-level field.
severityandadminbecome real indexed keys in Elasticsearch or Splunk, which pollutes dashboards and can collide with fields your own schema means something by.
Note that nesting the object one level down - { data: req.body.metadata } - avoids all of this, because the attacker keys end up inside data rather than beside level and timestamp. That is a mitigation, not the fix: the values are still unbounded and unvalidated. Allowlist the keys you intend to log.
Key Security Functions
General Log Encoding
Encodes the full control range so the payload stays readable in the log.
Defined once here; the Secure Patterns section below requires this same function as ./log-encoding.
function encodeForSingleLineTextLog(input, maxLength = 1000) {
if (input === null || input === undefined) {
return '[null]';
}
const encodeControl = (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
// Truncate BEFORE encoding. Cutting afterwards can land in the middle of
// an emitted escape sequence and leave a half-written one in the log.
return String(input)
.substring(0, maxLength)
.replace(/\\/g, '\\\\') // Backslashes FIRST - see note below
.replace(/\r/g, '\\r') // Encode carriage returns
.replace(/\n/g, '\\n') // Encode newlines (shows attack attempts)
.replace(/\t/g, '\\t') // Encode tabs
.replace(/[\x00-\x1F\x7F-\x9F]/g, encodeControl) // ASCII + C1 (covers NEL)
.replace(/\u2028/g, '\\u2028') // Unicode line separator
.replace(/\u2029/g, '\\u2029'); // Unicode paragraph separator
// Preserves evidence: "test\nFAKE LOG" instead of "testFAKE LOG"
}
// The Secure Patterns blocks below require this file as './log-encoding'
module.exports = { encodeForSingleLineTextLog };
The backslash replacement has to run first. If \r and \n were encoded before
it, the backslashes this function just wrote would be escaped a second time, and
an attacker typing a literal \n would produce output identical to a real
newline - which removes the reason for encoding rather than stripping.
Validate Email for Logging
function validateEmailForLog(email) {
const emailRegex = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(email)) {
return '[invalid-email]';
}
return email.substring(0, 100);
}
Winston Encoding Format
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
const winston = require('winston');
const encodeFormat = winston.format((info) => {
// Encode the main message
if (info.message) {
info.message = encodeForSingleLineTextLog(info.message);
}
// Encode metadata fields
Object.keys(info).forEach(key => {
if (typeof info[key] === 'string' && !['level', 'timestamp'].includes(key)) {
info[key] = encodeForSingleLineTextLog(info[key]);
}
});
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(
encodeFormat(),
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
Framework-Specific Log Injection Patterns
Express.js with winston
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
const express = require('express');
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Middleware: Add request ID
app.use((req, res, next) => {
req.id = require('crypto').randomBytes(16).toString('hex');
next();
});
// Secure logging middleware
app.use((req, res, next) => {
logger.info('Request received', {
requestId: req.id,
method: req.method,
path: req.path,
ip: req.ip
});
next();
});
app.post('/api/action', express.json(), (req, res) => {
const action = encodeForSingleLineTextLog(req.body.action);
logger.info('Action performed', {
requestId: req.id,
userId: req.user?.id,
action: action
});
res.json({ success: true });
});
NestJS with pino
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => {
return { level: label };
}
}
});
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Values go in fields rather than the message, and pino JSON-encodes
// each one - so no per-call encoding helper is needed here.
logger.info({
method: req.method,
path: req.path,
userId: (req as any).user?.id,
ip: req.ip
}, 'Request received');
next();
}
}
Secure Patterns
Encode newlines and control characters (Encoding Recommended)
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
app.post('/login', express.json(), (req, res) => {
const username = encodeForSingleLineTextLog(req.body.username);
console.log(`Login attempt for user: ${username}`);
// Attack "admin\nFAKE" logs as: "admin\\nFAKE" (attack visible but safe)
res.json({ success: true });
});
Why this works:
- Encodes the full control range rather than removing it, so the forensic evidence survives: shows "test\nFAKE" instead of "testFAKE".
- Converts inputs to strings to avoid logging errors.
- Length caps reduce log-based DoS and disk bloat.
- Sanitization at the boundary protects all loggers consistently.
- Encoding control chars blocks ANSI escape injection and log forging.
Structured logging with winston (JSON/ECS)
const winston = require('winston');
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
app.get('/search', (req, res) => {
logger.info('Search performed', {
query: encodeForSingleLineTextLog(req.query.q),
userId: req.user?.id,
requestId: req.id
});
res.json({ results: [] });
});
Why this works:
- JSON/ECS output isolates data from log structure: each entry is one object, and a value lands in a named field rather than in the line itself.
winston.format.json()serializes throughJSON.stringify, which escapes the ASCII control range - a CR or LF inreq.query.qbecomes\ninside the field and cannot start a new record.JSON.stringifydoes not escape U+2028, U+2029 or U+0085. All three are emitted as raw UTF-8 inside the quoted field. Verified against winston 3 andJSON.stringifydirectly. A JSON parser still reads one record, so this forges nothing on its own; it matters only where something splits lines before parsing, and Node itself does not. The explicitencodeForSingleLineTextLogcall above is what closes the gap when a Python or Java stage does the splitting downstream.- Encoding at the field level rather than stripping means the log still shows what the attacker sent.
- Request-scoped metadata (
requestId) makes the forged-looking entry traceable to a real request.
Pino with length and character limits (JSON)
const pino = require('pino');
const logger = pino();
function safeValue(value) {
if (typeof value !== 'string') return value;
return value.replace(/[\n\r\t]/g, ' ').substring(0, 300);
}
app.post('/api/action', express.json(), (req, res) => {
logger.info({
action: safeValue(req.body.action),
userId: safeValue(req.body.userId),
ts: Date.now()
}, 'User action');
res.json({ success: true });
});
Why this works:
- Structured JSON keeps attacker input as data, not formatting: pino escapes the ASCII control range when it serializes each field, so the line break is already handled before
safeValueruns. safeValuetherefore earns its place for a different reason - the length cap. An unboundedreq.body.actionis a log-flooding vector that JSON encoding does nothing about.- Do not read its
\n\r\treplacement as the control-character defence. It handles three characters and leaves NUL, ESC, DEL and the Unicode separators untouched. If you want the value neutralised rather than merely bounded, callencodeForSingleLineTextLoginstead, which covers the full range and keeps the payload readable. - Passing values as fields rather than interpolating them into the message is what keeps the encoder in the path at all.
Bunyan with validation and allowlists
const bunyan = require('bunyan');
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const log = bunyan.createLogger({
name: 'myapp',
serializers: bunyan.stdSerializers
});
app.post('/update-profile', express.json(), (req, res) => {
const email = emailRegex.test(req.body.email) ? req.body.email : '[invalid-email]';
log.info({
event: 'profile_update',
email,
userId: req.user?.id
});
res.json({ success: true });
});
Why this works:
- Validation/normalization prevents arbitrary field injection, including spoofed admin events and metadata overrides.
- Structured logging keeps fields isolated.
- Invalid inputs are replaced with safe placeholders.
Audit log with JSON append
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
const fs = require('fs');
function secureAuditLog(action, user, details) {
const entry = {
timestamp: new Date().toISOString(),
action,
user,
details: encodeForSingleLineTextLog(details)
};
fs.appendFileSync('audit.log', JSON.stringify(entry) + '\n');
}
app.post('/admin/delete-user', express.json(), (req, res) => {
secureAuditLog('DELETE_USER', req.user.username, req.body.reason);
res.json({ success: true });
});
Why this works:
JSON.stringifyplus an explicit trailing newline gives exactly one entry per line, which is what the string-concatenation version could not guarantee.encodeForSingleLineTextLogencodes rather than strips, sodetailskeeps the evidence of what was submitted.- The timestamp is generated server-side and written as its own field, so the caller cannot supply one. In the vulnerable version it was part of a concatenated line and a newline in
reasoncould forge a whole entry including its timestamp. - This gives a parseable, non-forgeable append log. It does not give tamper detection: anyone who can write the file can rewrite it. If the audit trail needs to survive a host compromise, ship it off-box or sign each record - a JSON-lines file on its own proves nothing about what was removed.
Disable ANSI codes in production
// The encoder from Key Security Functions above, as its own module
const { encodeForSingleLineTextLog } = require('./log-encoding');
const isProduction = process.env.NODE_ENV === 'production';
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
isProduction ? winston.format.json() : winston.format.simple()
),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
app.get('/status', (req, res) => {
logger.info('Status check', { component: encodeForSingleLineTextLog(req.query.component) });
res.json({ status: 'ok' });
});
Why this works:
- The encoding call is what stops ANSI injection here, not the format choice.
encodeForSingleLineTextLogturns a user-supplied ESC into\u001b, so it can no longer drive the terminal of whoever tails the log. That protection is identical in both branches. - Choosing
json()oversimple()in production removes a different hazard: it puts every value in a named field, so nothing is decided by where a delimiter falls in a line of text. - Note that neither
simple()norjson()emits colour on its own -winston.format.colorize()does. If a colorizer is in the chain, keep it out of the production branch, because ANSI in a file that getscat-ed is still ANSI. - Keeping the human-readable format to development means the terminal-rendering risk exists only where a developer is watching it live.
Typical Log Injection Findings
-
"Untrusted data written to log without encoding"
- Location:
console.log(\User: ${username}`)` - Fix: Encode username:
encodeForSingleLineTextLog(username)
- Location:
-
"User input in log message may contain newlines"
- Location:
logger.info('Query: ' + query) - Fix: Use structured logging:
logger.info('Query performed', { query })
- Location:
-
"Control characters in log output"
- Location: Direct logging of user input
- Fix: Use JSON/ECS output or encode control characters before logging
-
"Log injection via HTTP headers"
- Location:
console.log(req.headers['user-agent']) - Fix: Encode header values or use structured logging
- Location:
-
"ANSI escape sequences in logs"
- Location: Colorized logging with user input
- Fix: Disable colors in production, encode input
Common Pitfalls
- Fixing the winston/pino call but leaving a bare
console.log/console.errornearby: Error-handling middleware and quick debug statements are easy to leave outside the structured logger's JSON formatter -console.error(err.message)in an Express error handler writes raw, unencoded text even after the main request-logging path has been migrated to JSON output. - A field-level sanitizer that only walks top-level string values:
safeValue()-style helpers applied to individual fields work when logging flat key/value pairs, but skip control characters nested inside an object or array property if the object is passed straight to the logger (logger.info({ event, metadata })) without recursing intometadata's own fields first. - Passing an entire user-controlled object as log metadata:
logger.info('Event received', req.body)JSON-encodes the values safely (newlines can't break the JSON structure), but the attacker still controls which keys appear. Measured on winston 3: atimestampinreq.bodyreplaces the generated one, amessageis appended to the real message, and any other key becomes a new top-level indexed field;levelis the one winston defends. This is a distinct problem from line injection and JSON encoding does not touch it - allowlist the fields you log instead of forwarding the object as-is.