Skip to content

CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection') - JavaScript

Overview

CRLF Injection in JavaScript/Node.js applications occurs when untrusted user input containing carriage return (\r, %0D) and line feed (\n, %0A) characters reaches an HTTP header or another line-based field without being checked for those characters. The newline ends the line, so what follows it is read as structure rather than as data: an injected response header, a forged log record, a second email recipient, an extra CSV row.

Primary Defence: Reject - do not strip - user input containing newline characters (\r, \n, and the Unicode line terminators U+0085, U+2028, U+2029) before it reaches an HTTP header, a log line, an email header or a CSV row. Validate header values against a strict allowlist rather than filtering them, let res.redirect() and res.cookie() build their own headers instead of assembling the string by hand, and use a JSON log format so a newline that does get through is escaped inside a field rather than starting a new record.

What the Runtime Already Blocks

Before treating any of the header patterns below as exploitable, know what Node already does. Since the CVE-2016-2216 fix (Node 4.4.4 / 6.2.1), ServerResponse.setHeader() and writeHead() throw ERR_INVALID_CHAR when a header value contains a raw \r or \n. Express's res.set(), Koa's ctx.set(), Fastify's reply.header() and Next.js API routes all end up in that call, so the standard proof-of-concept returns a 500 rather than a split response. res.redirect() goes further: Express passes the target through encodeUrl() first, so a CRLF comes back as the literal text %0D%0A inside the Location value.

That does not make the patterns below safe to leave, for three reasons:

  • The check looks for a literal CR or LF byte at the moment the header is set. A percent-encoded payload passes it untouched and becomes a real CRLF wherever something decodes the value again - a proxy, a second decodeURIComponent(), a downstream service.
  • The Unicode line terminators are covered only as a side effect, and only in part. Node's check rejects every character above U+00FF, so U+2028 and U+2029 raise ERR_INVALID_CHAR like a raw CR does; U+0085 is inside Latin-1 and is accepted, and because Node writes header values as UTF-8 it reaches the wire as the two bytes C2 85 rather than as a bare 85. Measured on Node 24.3. Reject all three at the boundary anyway - the sinks below that are not header calls have no such check.
  • Nothing outside the header call inherits the check. The same untrusted value reaching a log line, a CSV row, an email header or a hand-assembled header string has no runtime guarding it.

So a header finding on a current Node runtime is usually an unhandled-input and availability defect where the scanner reported a response split. Record it accurately, and fix it the same way either way: validate the value instead of relying on the runtime to notice.

Common Vulnerable Patterns

Express Redirect with User Input

// VULNERABLE - Direct user input in redirect location
const express = require('express');
const app = express();

app.get('/redirect', (req, res) => {
    const url = req.query.url || '';

    // VULNERABLE - User input directly in redirect
    res.redirect(url);
});

app.listen(3000);

// Attack: /redirect?url=http://example.com%0d%0aSet-Cookie:%20admin=true
// Express encodes the target, so the split does not land:
// HTTP/1.1 302 Found
// Location: http://example.com%0D%0ASet-Cookie:%20admin=true
// The open redirect does land - any host the attacker names

Why this is vulnerable: The response split is the part that does not happen. Express 5 on Node 24 answers the payload above with a 302 whose Location is http://example.com%0D%0ASet-Cookie:%20admin=true and no Set-Cookie header at all, because res.redirect() encodes the target before setting it.

What survives is the open redirect. url is attacker-chosen and never checked, so the handler will send a user to any host a phishing page cares to name, and that half is untouched by any amount of CRLF filtering. See CWE-601 for the target check.

Custom Response Headers

// VULNERABLE - User input in custom headers
const express = require('express');
const app = express();

app.get('/api/data', (req, res) => {
    const username = req.query.username || '';
    const userAgent = req.get('User-Agent') || '';

    // VULNERABLE - User input in custom headers
    res.set('X-User-Name', username);
    res.set('X-Requested-By', userAgent);
    res.send('User data');
});

app.listen(3000);

// Attack: ?username=admin%0d%0aContent-Length:%200%0d%0a%0d%0a<script>alert('XSS')</script>
// Node throws ERR_INVALID_CHAR: 500, no injected header. The unvalidated
// value is still the defect - see "What the Runtime Already Blocks" above

Why this is vulnerable: Express does not sanitize the value and never claims to - res.set() hands it straight to ServerResponse.setHeader(). On a current runtime that call throws ERR_INVALID_CHAR and the request 500s, so the documented attack turns an unvalidated parameter into a remote way to fault the endpoint rather than into injected headers.

The pattern is still the defect. Both values become part of the response while fully attacker-controlled, the percent-encoded forms are outside what the runtime check covers, and User-Agent is a request header the client sets freely - nothing about it is more trustworthy than the query string.

Koa Response Headers

// VULNERABLE - Koa with user-controlled headers
const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
    const callback = ctx.query.callback || '';

    // VULNERABLE - User input in header
    ctx.set('X-Callback', callback);
    ctx.set('Content-Type', 'application/json');

    ctx.body = { status: 'success' };
});

app.listen(3000);

// Attack: ?callback=test%0d%0aSet-Cookie:%20sessionid=stolen
// Node throws ERR_INVALID_CHAR: 500, no Set-Cookie. The callback is still
// unvalidated, which matters wherever else it is used

Why this is vulnerable: ctx.set() reaches the same ServerResponse.setHeader(), so Koa behaves as Express does: ERR_INVALID_CHAR, a 500, and no Set-Cookie on the response. Neither framework sanitizes the value; the runtime refuses it.

The unvalidated JSONP callback is the real problem, and it is not confined to the header. A callback name is normally echoed into a script body, so it needs an identifier-shaped allowlist whatever the header layer does - and a percent-encoded newline reaches the header untouched.

Email Header Injection

// VULNERABLE - Email headers with user input
const nodemailer = require('nodemailer');

async function sendFeedback(name, email, subject, message) {
    const transporter = nodemailer.createTransport({
        host: 'localhost',
        port: 25
    });

    // VULNERABLE - User input in email headers
    const mailOptions = {
        from: email,
        to: 'admin@example.com',
        subject: subject,
        text: message,
        headers: {
            'X-Sender-Name': name
        }
    };

    await transporter.sendMail(mailOptions);
}

// Attack: email = "attacker@evil.com\nBcc: victim@example.com"
// Attack: subject = "Feedback\nTo: victim2@example.com"
// Nodemailer folds the newline to a space, so no recipient is added.
// Nothing folds it on a raw SMTP or sendmail path

Why this is vulnerable: Email headers are line-delimited, so a newline in subject or from is what adds a Bcc or a second recipient. Current Nodemailer does not let it through - its MIME builder folds CR and LF in a header value to a space before encoding, so the payload above produces Subject: Feedback To: victim2@example.com on one line and no extra recipient.

That is one library's behaviour on one path, not a property of the pattern. The same unvalidated values are routinely handed to a raw SMTP client, an sendmail invocation, or a template that builds the header block as text, and none of those fold anything. Reject control characters and validate the address at the boundary rather than depending on which mailer happens to be wired in.

Log Injection

// VULNERABLE - Logging user input without sanitization
const winston = require('winston');

const logger = winston.createLogger({
    transports: [new winston.transports.Console()]
});

function processLogin(username, password) {
    // VULNERABLE - User input in log message
    logger.info(`Login attempt for user: ${username}`);

    if (authenticate(username, password)) {
        logger.info(`Successful login: ${username}`);
        return true;
    } else {
        logger.warn(`Failed login for: ${username}`);
        return false;
    }
}

// Attack: username = "admin\ninfo: Successful login: attacker\ninfo: Admin access granted"
// Creates fake log entries

Why this is vulnerable: A log record ends at the newline, so a newline in username ends the line the application was writing and starts one it never wrote. The attacker chooses what that next line says - the payload above files a Successful login for themselves alongside their own failed attempt.

What that costs is the audit trail: the file no longer distinguishes what the application did from what someone typed into a form field, and anything reading these lines afterwards - an analyst, a SIEM rule counting failed logins - reads the attacker's version.

Next.js API Route Headers

// VULNERABLE - Next.js API route with custom headers
// pages/api/download.js

export default function handler(req, res) {
    const { filename } = req.query;

    // VULNERABLE - User input in Content-Disposition header
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
    res.send('File content');
}

// Attack: ?filename=file.txt%0d%0aX-Injected:%20malicious
// Node throws ERR_INVALID_CHAR: 500, no injected header. A quote or a path
// component in the filename still breaks the header apart

Why this is vulnerable: res.setHeader() in a Next.js API route is Node's ServerResponse.setHeader(), so a literal CRLF in filename throws ERR_INVALID_CHAR and the route 500s instead of emitting the injected header.

The value is still built by concatenation, which leaves every other way it can be abused: a quote breaks out of the filename= parameter, a path component turns the download into a traversal, and an encoded newline survives to whatever decodes it next. Build the header from a validated filename, not from the query string.

CSV Export with User Data

// VULNERABLE - CSV export with unsanitized data
const express = require('express');
const app = express();

app.get('/export', (req, res) => {
    const name = req.query.name || 'User';

    const users = [
        { name: name, email: 'user@example.com' }
    ];

    // VULNERABLE - User data in CSV without sanitization
    let csv = 'name,email\n';
    users.forEach(user => {
        csv += `${user.name},${user.email}\n`;
    });

    res.set('Content-Type', 'text/csv');
    res.set('Content-Disposition', 'attachment; filename=users.csv');
    res.send(csv);
});

app.listen(3000);

// Attack: ?name=admin%0aadmin2,admin2@evil.com
// Injects additional CSV rows

Why this is vulnerable: CSV rows are newline-delimited, so a newline in name closes the row the export was building and opens one the export never generated. Each row is built as name,email, which leaves the attacker in control of the injected row from its first cell.

That first cell is also where spreadsheet formula injection starts: a cell the receiving spreadsheet evaluates rather than displays is how an injected row becomes data exfiltration or code execution in Excel. Neither the row split nor the formula has a runtime check behind it - res.send() is writing a string.

Fastify with Custom Headers

// VULNERABLE - Fastify with user-controlled headers
const fastify = require('fastify')();

fastify.get('/api/user', async (request, reply) => {
    const { username } = request.query;

    // VULNERABLE - User input in header
    reply.header('X-Username', username);
    reply.send({ status: 'success' });
});

fastify.listen({ port: 3000 });

// Attack: ?username=admin%0d%0aX-Admin:%20true
// Node throws ERR_INVALID_CHAR: Fastify answers 500 with that code in the
// JSON error body, and no X-Admin header is emitted

Why this is vulnerable: reply.header() stores the value and Fastify writes it through the same ServerResponse, so a raw CRLF produces a 500 whose JSON error body carries ERR_INVALID_CHAR - no X-Admin header is emitted.

The parameter is still unvalidated, and the runtime check is narrow: it looks at the value being written to a header and nothing else. A percent-encoded newline passes it, and the moment this value is also logged, forwarded to a proxy, or used to build a header as text, the backstop is gone.

Secure Patterns

Express Redirect with Validation

// SECURE - Express redirect: reject CRLF, then allowlist the destination host
const express = require('express');
const { URL } = require('url');

const app = express();

// The origin this application is served from, used to resolve relative targets
const SELF_ORIGIN = 'https://example.com';
const ALLOWED_HOSTS = new Set(['example.com', 'app.example.com']);
const CRLF = /[\r\n]|%0[da]/i;

function redirectTarget(url) {
    // Reject, do not repair - a stripped value is a different URL
    if (typeof url !== 'string' || !url || CRLF.test(url)) {
        return null;
    }

    let parsed;
    try {
        // Resolving against our own origin puts relative and absolute
        // targets through the same host check
        parsed = new URL(url, SELF_ORIGIN);
    } catch (error) {
        return null;
    }

    if (!['http:', 'https:'].includes(parsed.protocol)) {
        return null;
    }
    if (!ALLOWED_HOSTS.has(parsed.hostname)) {
        return null;
    }

    return parsed.toString();
}

app.get('/redirect', (req, res) => {
    // SECURE - validated target or 400, with no third outcome
    const target = redirectTarget(req.query.url);

    if (!target) {
        return res.status(400).send('Invalid redirect URL');
    }

    res.redirect(target);
});

app.listen(3000);

Why this works: The host allowlist is what closes the open redirect, and it has to be in the code rather than in a comment - a scheme check alone still permits https://evil.example/login, which is exactly the URL a phishing link wants. Everything else here exists to make sure the hostname the check sees is the hostname the browser will use.

Resolving against SELF_ORIGIN is what does that. new URL(url, SELF_ORIGIN) turns a relative /dashboard into https://example.com/dashboard, so relative and absolute targets go through one check instead of two code paths; and it makes the parser, not the string, decide what the authority is. That matters because the authority has more spellings than a prefix test can enumerate: //evil.example/path, /\evil.example and \\evil.example all resolve to host evil.example and are all rejected, while https://example.com\@evil.example/ resolves to our host with /@evil.example/ as the path and is allowed, correctly. Returning parsed.toString() rather than the raw input means what gets redirected to is the URL that was checked.

Rejecting on CR/LF rather than stripping removes an ordering hazard as well as recording the attempt. A strip changes the string the later checks run against, so the sanitizer can manufacture a value that would have failed - the Java page has the worked example, where removing a newline turns a passing local path into //evil.example. A 400 has no such failure mode. The regex also covers the single percent-encoded forms, for a payload that only becomes a newline after one more decode; a double-encoded %250d%250a is deliberately left alone, since it is inert until two decodes and a filter that runs a fixed number of times can always be out-nested. Fix the double decode instead.

Custom Headers with Validation

// SECURE - Custom headers with CRLF removal
const express = require('express');
const app = express();

function sanitizeHeaderValue(value) {
    if (!value || typeof value !== 'string') {
        return '';
    }

    // Remove CRLF characters (including encoded versions)
    let clean = value.replace(/[\r\n\x00-\x1f\x7f]/g, '');
    clean = clean.replace(/%0[dDaA]/gi, '');

    // Limit length
    return clean.substring(0, 200);
}

function validateUsername(username) {
    if (!username) return false;
    return /^[a-zA-Z0-9._-]{3,50}$/.test(username);
}

app.get('/api/data', (req, res) => {
    const username = req.query.username || '';

    // SECURE - Validate input
    if (!validateUsername(username)) {
        return res.status(400).send('Invalid username');
    }

    // SECURE - Sanitize header value
    const cleanUsername = sanitizeHeaderValue(username);

    res.set('X-User-Name', cleanUsername);
    res.send('User data');
});

app.listen(3000);

Why this works: validateUsername() is what closes the hole. The pattern is anchored at both ends and its character class holds no \r, no \n and no %, so a value carrying any of them is answered with a 400 and never reaches res.set() in any form, literal or encoded.

sanitizeHeaderValue() runs behind a value that has already passed that check, and is there for the callers that pass it something unvalidated: it covers the literal CR and LF, the single percent-encoded forms %0D and %0A, and the C0 control range and \x7F, then caps the result at 200 characters so one caller cannot produce an unbounded header.

Koa JSONP Callback with Validation

// SECURE - Koa with proper header handling
const Koa = require('koa');
const app = new Koa();

function validateCallback(callback) {
    // Validate JSONP callback name format
    return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(callback);
}

app.use(async (ctx) => {
    const callback = ctx.query.callback || '';

    // SECURE - Validate callback format
    if (!validateCallback(callback)) {
        ctx.status = 400;
        ctx.body = { error: 'Invalid callback name' };
        return;
    }

    // SECURE - Use validated callback (no sanitization needed after validation)
    ctx.set('X-Callback', callback);
    ctx.set('Content-Type', 'application/json');

    ctx.body = { status: 'success' };
});

app.listen(3000);

Why this works: validateCallback() enforces ^[a-zA-Z_][a-zA-Z0-9_]*$, which is the identifier shape a JSONP callback name has to have anyway. Nothing outside [A-Za-z0-9_] survives it, so CR, LF, % and the control characters are all answered with a 400 before ctx.set() sees the value - and the same check is the one a handler that echoes the callback into a script body needs, which is where an unvalidated callback does its real damage.

The validated name is then used unchanged, as the comment on that line says - there is nothing left for a sanitizer to do on this path. Where a header value has no grammar to validate against, the sanitizeHeaderValue() helper in the Custom Headers example above is the fallback.

Email with Header Validation

// SECURE - Email with header sanitization
const nodemailer = require('nodemailer');

function sanitizeEmailHeader(value) {
    if (!value || typeof value !== 'string') {
        return '';
    }

    // Remove CRLF and control characters
    return value.replace(/[\r\n\x00-\x1f\x7f]/g, '').substring(0, 200);
}

function validateEmail(email) {
    if (!email || email.length > 254) {
        return false;
    }
    const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
    return emailRegex.test(email);
}

async function sendFeedbackSecure(name, email, subject, message) {
    // SECURE - Validate inputs
    if (!validateEmail(email)) {
        throw new Error('Invalid email address');
    }

    if (subject.length > 200 || message.length > 5000) {
        throw new Error('Content too long');
    }

    // SECURE - Sanitize all header values
    const cleanEmail = sanitizeEmailHeader(email);
    const cleanSubject = sanitizeEmailHeader(subject);
    const cleanName = sanitizeEmailHeader(name);

    const transporter = nodemailer.createTransport({
        host: 'localhost',
        port: 25
    });

    const mailOptions = {
        from: cleanEmail,
        to: 'admin@example.com',
        subject: cleanSubject,
        text: message,
        headers: {
            'X-Sender-Name': cleanName
        }
    };

    await transporter.sendMail(mailOptions);
}

Why this works: Email headers are separated by CRLF, so a newline in the sender address, the subject or the sender name is what adds a Bcc or a second recipient. validateEmail() rejects the address outright rather than repairing it: the pattern is anchored, leaving no room for a control character, and the 254-character bound is the RFC 5321 maximum for an address.

sanitizeEmailHeader() covers the fields that have no address grammar to check against - the subject and the sender name - stripping CR, LF and the C0 controls and bounding each at 200 characters before the mailer sees them, and the validated address goes through it as well. That is the part that does not depend on which mailer is wired in - Nodemailer folds a newline in a header value to a space, a raw SMTP conversation or a sendmail invocation does not.

Secure Logging

// SECURE - Logging with sanitization
const winston = require('winston');

const logger = winston.createLogger({
    transports: [new winston.transports.Console()],
    format: winston.format.simple()
});

function sanitizeLogInput(value) {
    if (!value || typeof value !== 'string') {
        return '';
    }

    // Remove newlines and control characters, replace with space
    const clean = value.replace(/[\r\n\x00-\x1f\x7f]/g, ' ');

    // Limit length
    return clean.substring(0, 200);
}

function validateUsername(username) {
    return /^[a-zA-Z0-9._-]{3,50}$/.test(username);
}

function processLoginSecure(username, password) {
    // SECURE - Validate username
    if (!validateUsername(username)) {
        logger.warn('Invalid username format in login attempt');
        return false;
    }

    // SECURE - Sanitize for logging
    const cleanUsername = sanitizeLogInput(username);
    logger.info(`Login attempt for user: ${cleanUsername}`);

    if (authenticate(username, password)) {
        logger.info(`Successful login: ${cleanUsername}`);
        return true;
    } else {
        logger.warn(`Failed login for: ${cleanUsername}`);
        return false;
    }
}

function authenticate(username, password) {
    // Authentication logic
    return true;
}

Why this works: validateUsername() runs first, so a username outside ^[a-zA-Z0-9._-]{3,50}$ never reaches a log call at all - the handler records that the format was rejected and returns, rather than writing the attacker's string into the file.

sanitizeLogInput() covers the calls that do go ahead. It replaces CR, LF, tab and the rest of the C0 controls with a space, so each call writes one line and the forged Successful login from the vulnerable version cannot start a record of its own; the 200-character cap keeps a single field from filling the file.

Next.js with Validation

// SECURE - Next.js API route with validation
// pages/api/download.js

// Anchored allowlist: no CR/LF, no control characters, no path separators
const FILENAME = /^[a-zA-Z0-9._-]{1,100}\.[a-zA-Z0-9]{1,10}$/;

function validFilename(filename) {
    return typeof filename === 'string' && FILENAME.test(filename);
}

export default function handler(req, res) {
    const { filename } = req.query;

    // SECURE - reject, do not repair
    if (!validFilename(filename)) {
        return res.status(400).json({ error: 'Invalid filename' });
    }

    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
    res.send('File content');
}

Why this works: The value is tested as it arrived and either used unchanged or rejected. Because the pattern is anchored at both ends, a name that merely starts legitimately does not pass, which is what an unanchored check gets wrong - report.pdf followed by a newline and an injected header matches an unanchored pattern and fails this one.

The rejection is the point, and it is worth seeing why the strip-then-validate version of this function is worse rather than merely different. Removing the control characters first and matching afterwards accepts report\r\n.pdf, because after the strip it is the perfectly valid report.pdf - so the handler serves a file the caller never asked for and logs nothing unusual. Testing the original string means an attack is answered with a 400 and a legitimate request is answered with the file, with no third outcome where the input was quietly rewritten.

The same pattern covers more than the newline. / and \ are outside the character class, so the value cannot address a parent directory - it is the separators doing that rather than the dot, since ..txt matches quite happily and is harmless without one. Every control character is outside the class too, and {1,100} bounds the header without a separate length check. The value is quoted in the header so that a space or semicolon admitted by a future revision of the pattern cannot break the parameter apart.

If the download set is known in advance, go one better and map an opaque id to a server-side filename, so the query parameter never reaches the header at all.

Common Pitfalls

  • Stripping only literal /[\r\n]/g without also stripping the URL-encoded forms (%0d%0a) - if the value is decoded a second time downstream (by a proxy, or by code that calls decodeURIComponent() again for an unrelated reason), the encoded payload survives the first pass and becomes literal CRLF afterward.
  • Reaching for a generic string-escaping utility built for HTML/XSS contexts (for example, a validation library's escape()) to sanitize a header or redirect value - it encodes <, >, &, and quotes but does nothing to \r/\n, so a value that "looks sanitized" still carries the injection.
  • Validating req.query.url but building the string that actually reaches the response by concatenating the validated value with other, unchecked request data (res.redirect(target + req.query.extra)) - the check covers the variable it was applied to, not the URL the browser is sent.

Additional Resources