Commit 5d6dedb3a69 for nodejs
commit 5d6dedb3a69aaa8778626117aab5cd3df384d560
Author: Matteo Collina <hello@matteocollina.com>
Date: Fri Oct 30 08:32:57 2026 +0100
http: never execute llhttp re-entrantly
llhttp is not reentrant: it keeps its position in the input in the
parser state and only updates it when `llhttp_execute()` returns.
Instead of supporting nested calls, make sure that llhttp never runs
inside itself.
- `socketOnData` on the server and the client puts data that arrives
while the parser is executing back into the socket, and resumes the
socket after the current call.
- `Parser::Execute()` does not run llhttp while it is already running.
A nested call with data fails the outer call with
`HPE_REENTRANT_EXECUTE`. A nested `finish()`, which comes from
tearing down the connection in a callback, is skipped.
This replaces the buffer bounds check on data callbacks.
Refs: https://hackerone.com/reports/4150886
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs-private/node-private/pull/1021
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Tim Perry <pimterry@gmail.com>
CVE-ID: CVE-2026-123864
diff --git a/lib/internal/http/client.js b/lib/internal/http/client.js
index 1952e7b69af..ecc87255311 100644
--- a/lib/internal/http/client.js
+++ b/lib/internal/http/client.js
@@ -124,6 +124,8 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
const kError = Symbol('kError');
+const kParserExecuting = Symbol('kParserExecuting');
+const kParserDeferred = Symbol('kParserDeferred');
const kPath = Symbol('kPath');
const kAuthority = Symbol('kAuthority');
const kProxyRewrittenToAbsolute = Symbol('kProxyRewrittenToAbsolute');
@@ -837,9 +839,31 @@ function socketOnData(d) {
const req = this._httpMessage;
const parser = this.parser;
+ // The llhttp parser is not reentrant. JS that runs in a parser callback can
+ // make the socket emit 'data' synchronously, for example by pushing into a
+ // Duplex. Put the data back and parse it after the current call.
+ if (parser?.[kParserExecuting]) {
+ socket.pause();
+ socket.unshift(d);
+ parser[kParserDeferred] = true;
+ return;
+ }
+
assert(parser && parser.socket === socket);
- const ret = parser.execute(d);
+ parser[kParserExecuting] = true;
+ let ret;
+ try {
+ ret = parser.execute(d);
+ } finally {
+ parser[kParserExecuting] = false;
+ }
+ if (parser[kParserDeferred]) {
+ parser[kParserDeferred] = false;
+ // Resume after the code below has handled the result, unless the socket
+ // was handed over or destroyed in the meantime.
+ process.nextTick(resumeDeferredSocket, socket);
+ }
if (ret instanceof Error) {
prepareError(ret, parser, d);
debug('parse error', ret);
@@ -901,6 +925,13 @@ function socketOnData(d) {
}
}
+function resumeDeferredSocket(socket) {
+ if (!socket.destroyed && socket.readableFlowing === false &&
+ socket.listenerCount('data', socketOnData) > 0) {
+ socket.resume();
+ }
+}
+
function statusIsInformational(status) {
// 100 (Continue) RFC7231 Section 6.2.1
// 102 (Processing) RFC2518
diff --git a/lib/internal/http/server.js b/lib/internal/http/server.js
index 406eede9300..9e13aeba63a 100644
--- a/lib/internal/http/server.js
+++ b/lib/internal/http/server.js
@@ -121,6 +121,8 @@ const kUpgradeStream = Symbol('UpgradeStream');
const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption');
const kConnectionState = Symbol('ConnectionState');
+const kParserExecuting = Symbol('kParserExecuting');
+const kParserDeferred = Symbol('kParserDeferred');
const kResponseOptions = Symbol('ResponseOptions');
const statusLineCache = [];
@@ -977,11 +979,34 @@ function socketOnEnd(server, socket, parser, state) {
}
function socketOnData(server, socket, parser, state, d) {
+ // The llhttp parser is not reentrant. JS that runs in a parser callback can
+ // make the socket emit 'data' synchronously, for example by pushing into a
+ // Duplex. Put the data back and parse it after the current call.
+ if (parser[kParserExecuting]) {
+ socket.pause();
+ socket.unshift(d);
+ parser[kParserDeferred] = true;
+ return;
+ }
assert(!socket._paused);
debug('SERVER socketOnData %d', d.length);
- const ret = parser.execute(d);
+ parser[kParserExecuting] = true;
+ let ret;
+ try {
+ ret = parser.execute(d);
+ } finally {
+ parser[kParserExecuting] = false;
+ }
+ const deferred = parser[kParserDeferred];
+ parser[kParserDeferred] = false;
+
onParserExecuteCommon(server, socket, parser, state, ret, d);
+
+ if (deferred && socket.parser === parser && !socket._paused &&
+ !socket.destroyed) {
+ socket.resume();
+ }
}
function onRequestTimeout(socket) {
diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc
index 043f3811de3..07698328622 100644
--- a/src/node_http_parser.cc
+++ b/src/node_http_parser.cc
@@ -903,9 +903,20 @@ class Parser : public AsyncWrap, public StreamListener {
Local<Value> Execute(const char* data, size_t len) {
EscapableHandleScope scope(env()->isolate());
- // A callback can run JS that executes the parser again, for example by
- // pushing data into the socket synchronously. Restore the caller's buffer
- // afterwards so that its own callbacks are checked against it.
+ // llhttp is not reentrant: it keeps its position in the input in
+ // parser_ and only updates it when llhttp_execute() returns. A callback
+ // can run JS that gets here again, for example by pushing data into the
+ // socket synchronously. lib/ defers that data, so a nested call with data
+ // is a bug: do not run llhttp on top of itself, fail the outer call
+ // instead. A nested finish() comes from tearing down the connection in a
+ // callback, and the parser is freed right after it, so just skip it.
+ if (executing_) {
+ if (data != nullptr) reentered_ = true;
+ return scope.Escape(Integer::New(env()->isolate(), 0));
+ }
+
+ // The callback that reports the result of a consumed read can also get
+ // here, outside of llhttp. Restore the caller's buffer afterwards.
const char* const outer_data = current_buffer_data_;
const size_t outer_len = current_buffer_len_;
current_buffer_len_ = len;
@@ -914,12 +925,25 @@ class Parser : public AsyncWrap, public StreamListener {
llhttp_errno_t err;
+ executing_ = true;
if (data == nullptr) {
err = llhttp_finish(&parser_);
} else {
err = llhttp_execute(&parser_, data, len);
Save();
}
+ executing_ = false;
+
+ if (reentered_) {
+ reentered_ = false;
+ if (err == HPE_OK || err == HPE_PAUSED || err == HPE_PAUSED_UPGRADE) {
+ err = HPE_USER;
+ parser_.error = err;
+ parser_.error_pos = data + len;
+ llhttp_set_error_reason(
+ &parser_, "HPE_REENTRANT_EXECUTE:Parser was executed re-entrantly");
+ }
+ }
// Deliver buffered body spans while `data` is still alive.
FlushBody();
@@ -1166,6 +1190,8 @@ class Parser : public AsyncWrap, public StreamListener {
size_t header_pairs_ = 0;
size_t max_header_pairs_ = 0;
bool pending_pause_ = false;
+ bool executing_ = false;
+ bool reentered_ = false;
bool received_data_ = false;
uint64_t header_nread_ = 0;
uint64_t chunk_extensions_nread_ = 0;
@@ -1187,11 +1213,6 @@ class Parser : public AsyncWrap, public StreamListener {
if (parser->is_being_freed_) {
return 0;
}
- if (!parser->IsInCurrentBuffer(args...)) {
- llhttp_set_error_reason(
- p, "HPE_REENTRANT_EXECUTE:Parser re-entered in the middle of data");
- return HPE_USER;
- }
int rv = (parser->*Member)(std::forward<Args>(args)...);
if (rv == 0) {
rv = parser->MaybePause();
@@ -1200,20 +1221,6 @@ class Parser : public AsyncWrap, public StreamListener {
}
};
- // A nested Execute() that stops in the middle of a span (e.g. a chunk of the
- // body) leaves llhttp pointing into the nested call's buffer, which can be
- // freed by the time the outer call reports the span. Data callbacks must
- // only ever see the buffer that is being executed.
- bool IsInCurrentBuffer() const {
- return true;
- }
- bool IsInCurrentBuffer(const char* at, size_t length) const {
- const uintptr_t begin = reinterpret_cast<uintptr_t>(current_buffer_data_);
- const uintptr_t pos = reinterpret_cast<uintptr_t>(at);
- return pos >= begin && pos - begin <= current_buffer_len_ &&
- length <= current_buffer_len_ - (pos - begin);
- }
-
typedef int (Parser::*Call)();
typedef int (Parser::*DataCall)(const char* at, size_t length);
diff --git a/test/parallel/test-http-parser-reentrant-data.js b/test/parallel/test-http-parser-reentrant-data.js
new file mode 100644
index 00000000000..7daa5625d82
--- /dev/null
+++ b/test/parallel/test-http-parser-reentrant-data.js
@@ -0,0 +1,81 @@
+// Flags: --expose-internals
+'use strict';
+
+// Data that a socket emits synchronously from inside a parser callback, for
+// example by pushing into a Duplex, must be parsed after the current call
+// to the parser returns, not from inside it.
+
+const common = require('../common');
+const assert = require('assert');
+const http = require('http');
+const { Duplex } = require('stream');
+const { internalBinding } = require('internal/test/binding');
+const { HTTPParser } = internalBinding('http_parser');
+
+const execute = HTTPParser.prototype.execute;
+let depth = 0;
+HTTPParser.prototype.execute = common.mustCallAtLeast(function(...args) {
+ assert.strictEqual(depth, 0);
+ depth++;
+ try {
+ return execute.apply(this, args);
+ } finally {
+ depth--;
+ }
+}, 4);
+
+{
+ // Server: the request handler of the first request pushes the second one.
+ const urls = [];
+ let output = '';
+ const connection = new Duplex({
+ read() {},
+ write(chunk, encoding, callback) {
+ output += chunk;
+ callback();
+ },
+ });
+ const server = http.createServer(common.mustCall((req, res) => {
+ urls.push(req.url);
+ if (req.url === '/a')
+ connection.push('GET /b HTTP/1.1\r\nHost: x\r\n\r\n');
+ res.end('ok');
+ }, 2));
+ server.emit('connection', connection);
+ connection.push('GET /a HTTP/1.1\r\nHost: x\r\n\r\n');
+ setImmediate(common.mustCall(() => {
+ assert.deepStrictEqual(urls, ['/a', '/b']);
+ assert.strictEqual(output.split('HTTP/1.1 200 OK\r\n').length, 3);
+ }));
+}
+
+{
+ // Client: the 'information' handler pushes the final response.
+ let socket;
+ const req = http.request('http://localhost', {
+ createConnection() {
+ socket = new Duplex({
+ read() {},
+ write(chunk, encoding, callback) {
+ if (chunk.toString().includes('\r\n\r\n'))
+ this.push('HTTP/1.1 100 Continue\r\n\r\n');
+ callback();
+ },
+ });
+ return socket;
+ },
+ });
+ req.on('information', common.mustCall(({ statusCode }) => {
+ assert.strictEqual(statusCode, 100);
+ socket.push('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok');
+ socket.push(null);
+ }));
+ req.on('response', common.mustCall((res) => {
+ assert.strictEqual(res.statusCode, 200);
+ res.setEncoding('utf8');
+ let body = '';
+ res.on('data', (chunk) => body += chunk);
+ res.on('end', common.mustCall(() => assert.strictEqual(body, 'ok')));
+ }));
+ req.end();
+}