Skip to content

CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') - JavaScript

Overview

In Node.js applications, HTTP response splitting occurs when a user-supplied value reaches res.setHeader(), res.writeHead(), res.redirect(), or res.cookie() without the application having decided what that value may contain. Since the CVE-2016-2216 fix (Node 4.4.4/6.2.1), Node's http module rejects a raw \r or \n in a header value passed to setHeader()/writeHead(), throwing ERR_INVALID_CHAR. Express delegates to the same http module, so it inherits this protection for its low-level header calls.

Node also validates header names, separately and more strictly: anything outside the HTTP token grammar raises ERR_INVALID_HTTP_TOKEN. That is not a given - Python's Werkzeug checks values and not names - so a Node codebase that builds a header name from user input fails loudly rather than splitting the response.

That built-in check only blocks a literal newline byte reaching those specific calls. It does not cover:

  • Double-encoded variants (%250d%250a) that survive req.query's own decode as literal %0d%0a text and become a raw CRLF only if something downstream decodes again - a proxy, a gateway, a second decodeURIComponent(). The distinction is the one most write-ups get wrong: a single-encoded %0d%0a in a query parameter has already been decoded by the time your handler runs, so it is a raw CRLF at the sink and Node throws on it.
  • U+0085 (NEL), which Node accepts in a header value. Measured on Node 24.3, res.setHeader('X-Echo', 'a' + String.fromCharCode(0x85) + 'X-Injected: evil') is written to the wire unaltered. Node's check allows the whole \x80-\xff range - it is a "can this be written as latin-1" test, not a line-terminator test - so NEL passes while U+2028 and U+2029 do not, because they are above \xff and raise ERR_INVALID_CHAR like any other non-encodable character. Guidance that lists all three as "line terminators Node does not reject" is right about one of them, and a filter written from that list is doing two-thirds nothing.
  • Values passed through third-party packages that build header or cookie strings themselves instead of calling into Node's http module

The primary defense is to never hand user input to a header-setting call directly: decide what the value is allowed to be, reject with a 400 when it is not, and let res.redirect(), res.cookie() and res.attachment() build the header. Treat Node's built-in rejection as a backstop, not the control you are relying on - where it fires, the result is an unhandled 500 on a request the attacker chose, which is a different bug rather than a fix.

Node raising is also not how the other ecosystems behave, which matters if the same finding spans services: a servlet container silently replaces each CR and LF with a space, PHP discards the header and carries on, and Werkzeug checks header values but not header names. The main CWE-113 page has the runtime-by-runtime table.

Common Vulnerable Patterns

Manual Location Header from User Input

const express = require('express');
const app = express();

app.get('/redirect', (req, res) => {
  const target = req.query.next;
  // VULNERABLE - user input assigned directly to the Location header
  res.setHeader('Location', target);
  res.status(302).end();
});

// GET /redirect?next=%2Fhome%0d%0aSet-Cookie:%20admin=true
//   req.query decodes once, so target is "/home\r\nSet-Cookie: admin=true"
//   -> setHeader throws ERR_INVALID_CHAR -> unhandled 500
//
// GET /redirect?next=%2Fhome%250d%250aSet-Cookie:%20admin=true
//   target is "/home%0d%0aSet-Cookie: admin=true" - literal text, no throw
//   -> 302 Location: /home%0d%0aSet-Cookie: admin=true
//
// GET /redirect?next=https%3A%2F%2Fevil.example%2Flogin
//   -> 302 Location: https://evil.example/login   (open redirect, CWE-601)

Why this is vulnerable: Nothing decides what next may be, so all three outcomes above belong to the client. Measured on Node 24.3 with Express 5.2.1, and worth reading in that order: the middle one is where most write-ups of this weakness go wrong. req.query has already percent-decoded once, so the classic single-encoded payload arrives at setHeader() as a raw CRLF and Node throws ERR_INVALID_CHAR - an unauthenticated 500 on demand, not an injection. The double-encoded payload survives as literal %0d%0a text and is emitted verbatim, which is a real header only if something downstream decodes a second time - a proxy, a gateway, another service reading the Location. And the third needs no encoding at all: an absolute URL is a legal header value, so this line is an open redirect (CWE-601) on every runtime. Deciding what next is allowed to be closes all three; filtering for %0d closes none of them.

app.get('/set-pref', (req, res) => {
  const theme = req.query.theme || 'light';
  // VULNERABLE - hand-built Set-Cookie string, no CRLF or attribute handling
  res.setHeader('Set-Cookie', `theme=${theme}; Path=/`);
  res.sendStatus(204);
});

// GET /set-pref?theme=light%0d%0aSet-Cookie:%20session=hijacked
//   decoded once by req.query -> raw CRLF -> ERR_INVALID_CHAR -> 500
//
// GET /set-pref?theme=light;%20Domain=example.com
//   204 Set-Cookie: theme=light; Domain=example.com; Path=/
//   no newline involved, and the Domain is attacker-chosen

Why this is vulnerable: The CRLF form is a 500 here for the same reason as the redirect above - req.query decoded it before setHeader() saw it. The form that works needs no control character at all: ; is the cookie grammar's own attribute separator, so a theme of light; Domain=example.com extends the attributes of the cookie being set, and the second output above is what came back from Express 5.2.1. Note which attribute is worth injecting - Domain=evil.example is the version usually quoted and a browser discards it, because RFC 6265 requires Domain to domain-match the host that sent the response. Widening inside the site's own registrable domain is the one that works: Domain=example.com turns a cookie scoped to one host into one every subdomain receives. Concatenation also drops everything res.cookie() would have added - HttpOnly, SameSite, Secure, an expiry - so the cookie is readable from JavaScript and sent over plaintext regardless of what is in the value.

Content-Disposition Filename Injection

app.get('/download', (req, res) => {
  const filename = req.query.filename || 'export.csv';
  // VULNERABLE - user-controlled filename interpolated into a header value
  res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  res.send(fileBuffer);
});

// filename=report.csv%0d%0aContent-Type:%20text/html
//   decoded once by req.query -> raw CRLF -> ERR_INVALID_CHAR -> 500
//
// filename=a.csv%22;%20filename*=UTF-8%27%27evil.html
//   200 Content-Disposition: attachment; filename="a.csv"; filename*=UTF-8''evil.html"
//   the quote closes the parameter and a second one takes precedence

Why this is vulnerable: The interesting payload here contains no newline. A " closes the quoted filename parameter and a ; starts another, and RFC 6266 says a filename* takes precedence over filename - so the browser saves the download as evil.html while the header still reads as though it were serving a.csv. That is the second output above, from Express 5.2.1, and no runtime check touches it because every byte in it is legal in a header. The CRLF form is the 500 again. Both come from the same omission: the filename is interpolated into header text with nothing deciding what a filename may contain.

Secure Patterns

Allowlisted Redirect with res.redirect()

const express = require('express');
const app = express();

const LOCAL_PATH_PATTERN = /^\/(?!\/)[a-zA-Z0-9/_-]*$/;

app.get('/redirect', (req, res) => {
  const target = req.query.next;
  // SECURE - only accept a same-origin relative path, then let Express
  // build the Location header
  if (!target || !LOCAL_PATH_PATTERN.test(target)) {
    return res.redirect('/');
  }
  res.redirect(target);
});

Why this works: The allowlist says what a redirect target is allowed to be - a relative path of letters, digits, /, _ and - - so all three payloads from the vulnerable pattern fail it without any of them being named. Verified on Node 24.3: /home, /reports/2026_q1 and /a/b/c_d-e accepted; /home\n, /home\r\nX-Injected, //evil.example, https://evil.example and javascript:alert(1) all rejected. res.redirect() then constructs the Location header through Express and Node's http module rather than from a string you assembled, so nothing after the validation touches the header as free text.

Two things about this pattern are worth knowing before copying it. The ^...$ anchoring is safe here and would not be in Python, .NET or PHP: JavaScript's $ without the m flag matches only at the very end of the string, so /home\n is rejected rather than accepted. And the character class excludes ?, so a redirect target carrying a query string is rejected too - if the application has those, widen the class deliberately and re-run the accept cases above, rather than loosening the anchor.

const ALLOWED_THEMES = new Set(['light', 'dark', 'system']);

app.get('/set-pref', (req, res) => {
  const raw = req.query.theme || 'light';
  // SECURE - the value is enumerated, so membership is the whole check
  if (!ALLOWED_THEMES.has(raw)) {
    return res.sendStatus(400);
  }
  res.cookie('theme', raw, {
    httpOnly: true,
    sameSite: 'strict',
    secure: true,
  });
  res.sendStatus(204);
});

Why this works: theme is an enumerated value, so the set of legal inputs is three strings and set membership settles it - nothing about line terminators, percent-encoding or cookie grammar has to be reasoned about at all. res.cookie() then serializes name, value and attributes through Express's cookie path rather than by concatenation, so HttpOnly, SameSite and Secure are present and correctly positioned. It also percent-encodes the value: measured on Express 5.2.1, a theme of light; Domain=example.com comes out as theme=light%3B%20Domain%3Dexample.com, so the ; attack on the vulnerable pattern above does not survive the API even without the allowlist. The allowlist is still what makes this correct rather than merely not-broken.

Do not replace the allowlist with a list of line terminators to reject. It is the version this pattern is usually written with and it is worse in two directions at once. Measured on Node 24.3, \r, \n, U+2028 and U+2029 in a header value all raise ERR_INVALID_CHAR on their own, so four of the five code points such a list normally carries are already handled - and the fifth, U+0085 (NEL), is the one most lists get wrong by grouping it with U+2028/U+2029 as though all three behaved alike. Node's check is a "can this be written as latin-1" test rather than a line-terminator test, so U+0085 passes and reaches the wire while the two above \xff do not. A %0[aAdD] alternation in the same pattern is worse still: req.query has already decoded once, so a percent sequence still present is literal text, and rejecting it costs legitimate values such as q3%0d-report for no gain. An allowlist has none of these questions, because it does not depend on knowing what to exclude.

Safe Content-Disposition Filename

// SECURE - what a filename is allowed to be inside a quoted parameter:
// no CR, LF, quote, semicolon, backslash or path separator can match
const SAFE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/;

app.get('/download', (req, res) => {
  const raw = req.query.filename || 'export.csv';
  if (!SAFE_FILENAME.test(raw)) {
    return res.sendStatus(400);
  }
  // res.attachment() builds the header, quoting the filename as needed
  res.attachment(raw);
  res.send(fileBuffer);
});

Why this works: The allowlist is defined by what is legal inside a quoted Content-Disposition parameter rather than by a list of characters to remove, so the " and ; that carried the filename* override in the vulnerable pattern above are excluded along with CR and LF, and so is /, which is the job path.basename() would otherwise be doing. res.attachment() then emits the header itself instead of taking a string you interpolated. Anchoring with ^...$ is safe in JavaScript specifically - unlike Python, .NET and PCRE, JS $ without the m flag matches only at the very end of the string, so "report\n" is rejected. Verified on Node 24.3: report.csv and q3 report 2026.csv accepted, report\n, report\r\nX: y, a.csv"; filename*=UTF-8''evil.html and ../../etc/passwd all rejected.

encodeURIComponent() is not the fix here. It does prevent a break-out, but by percent-encoding the whole filename: q3 report 2026.csv becomes q3%20report%202026.csv, and that is the literal name the browser writes to disk. Every malicious-input test passes and the bug is only visible to somebody who downloads a file with a space in its name. Percent-encoding belongs in Content-Disposition only where the grammar asks for it - the RFC 5987 filename*=UTF-8''... form, for names outside the ASCII set - and that is a parameter a header builder should emit, not something to assemble by hand.

Framework-Specific Guidance

Express

  • Prefer res.redirect(url) over res.setHeader('Location', url) or res.writeHead(302, { Location: url }) - the latter two accept whatever string you hand them once it has passed Node's literal-byte check.
  • Prefer res.cookie(name, value, options) over res.setHeader('Set-Cookie', ...) for the same reason, and use the options object (httpOnly, sameSite, secure) instead of appending attribute strings by hand. It also percent-encodes the value, so a ; in it cannot start a cookie attribute.
  • Prefer res.attachment(filename) over interpolating into attachment; filename="...". It quotes the filename where quoting is needed and leaves it alone where it is not - res.attachment('q3 report 2026.csv') emits attachment; filename="q3 report 2026.csv" on Express 5.2.1.
  • If a proxy, gateway, or CDN sits in front of the Express app, confirm it does not re-decode percent-encoded values before forwarding them - a value that was safe when Express received it can become unsafe if decoded a second time downstream.

Testing

Re-running the scanner is not verification here. On Node the header layer raises whether or not your validation ran, so "no injected header appeared" is true of a fixed endpoint and of an unfixed one that answered 500. Assert on the status code and on the emitted header.

  • The accept, first. Follow an accepted redirect to its destination, download a file with an ordinary name and check the name the browser is told to save it as, set a cookie and read it back. GET /download?filename=q3%20report%202026.csv must return 200 with Content-Disposition: attachment; filename="q3 report 2026.csv" - the space intact, not %20. This is the assertion that catches an over-tight allowlist and a stray encodeURIComponent(), and it is the one usually missing.
  • A rejected value is never a 500, and never reaches Location: send next=%2Fhome%0d%0aSet-Cookie:%20admin=true. A 500 means req.query decoded the payload, the raw CRLF reached setHeader() and Node threw - a working denial of service on the endpoint even though nothing was injected. The redirect example above answers 302 with Location: /, discarding the bad value for one the application chose; the header-value examples answer 400, because there is no sensible default for an arbitrary header. Assert the one your endpoint does rather than accepting either, or the test passes against an endpoint that does neither.
  • Double-encoded, at the sink that decodes twice: next=%2Fhome%250d%250aSet-Cookie:%20admin=true reaches the handler as literal %0d%0a text and Node emits it without complaint. Assert 400 from your validation, and separately confirm that nothing downstream - a proxy, a gateway, another service reading the Location - decodes it again.
  • U+0085 specifically: res.setHeader('X-Echo', 'a' + String.fromCharCode(0x85) + 'X-Injected: evil') is written to the wire unaltered on Node 24.3. If any header value on the endpoint is free text rather than an allowlisted class, this is the one input that gets past the runtime check.
  • The header name, if any is derived from input, read off a socket rather than through supertest - a test client shows the framework's parsed header map, not what was serialised.

Common Pitfalls

  • Trusting Node's ERR_INVALID_CHAR check as the whole fix: it only rejects a literal \r/\n byte present at the moment setHeader()/writeHead() is called, and where it does fire the result is an unhandled 500 on an attacker-chosen request rather than a fix. Validate first so the answer is a deliberate 400. Two categories also get past it entirely: U+0085, and a payload that is still percent-encoded here and gets decoded by something downstream.
  • Demonstrating the bypass with a payload the framework already decoded: the single-encoded %0d%0a in a req.query value is not an example of a percent-encoded payload sneaking past Node's check - Express decoded it before setHeader() saw it, so what arrives is a raw CRLF and Node throws. The double-encoded %250d%250a is the one that survives as text, and it is only an injection if a second decode happens later. Getting this backwards is how a page ends up demonstrating an attack against its own runtime that cannot occur.
  • Fixing the redirect call but leaving a second manual header nearby: switching res.setHeader('Location', url) to res.redirect(url) closes that sink, but a res.setHeader('X-Debug-Target', rawUrl) a few lines later in the same handler is a separate, unprotected sink.
  • Copying a "Unicode line terminators Node does not reject" list without checking it: on Node 24.3 only U+0085 is in that category. U+2028 and U+2029 are above \xff and raise ERR_INVALID_CHAR in a header value like any other non-latin-1 character, so a filter written from the three-item list is guarding one live case and two closed ones - and if it was written as the whole fix, the percent-encoded and third-party-serializer cases it was supposed to be paired with are still open.
  • Assuming the value check covers the header name: it is a separate check in Node (ERR_INVALID_HTTP_TOKEN) and it happens to be the stricter of the two, so Node is safe here - but the same code in another ecosystem is not. Werkzeug validates values and not names, which means a Python port of a Node handler that builds a header name from input picks up a defect the original did not have - and whether it is a live injection or a 500 is then decided by the WSGI server rather than the framework.

Additional Resources