Skip to content

Commit 6c5b08e

Browse files
mcollinaaduh95
authored andcommitted
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: nodejs-private/node-private#1021 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Tim Perry <pimterry@gmail.com> CVE-ID: CVE-2026-123864
1 parent 22ba585 commit 6c5b08e

4 files changed

Lines changed: 168 additions & 24 deletions

File tree

‎lib/internal/http/client.js‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
124124

125125
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
126126
const kError = Symbol('kError');
127+
const kParserExecuting = Symbol('kParserExecuting');
128+
const kParserDeferred = Symbol('kParserDeferred');
127129
const kPath = Symbol('kPath');
128130
const kAuthority = Symbol('kAuthority');
129131
const kProxyRewrittenToAbsolute = Symbol('kProxyRewrittenToAbsolute');
@@ -837,9 +839,31 @@ function socketOnData(d) {
837839
const req = this._httpMessage;
838840
const parser = this.parser;
839841

842+
// The llhttp parser is not reentrant. JS that runs in a parser callback can
843+
// make the socket emit 'data' synchronously, for example by pushing into a
844+
// Duplex. Put the data back and parse it after the current call.
845+
if (parser?.[kParserExecuting]) {
846+
socket.pause();
847+
socket.unshift(d);
848+
parser[kParserDeferred] = true;
849+
return;
850+
}
851+
840852
assert(parser && parser.socket === socket);
841853

842-
const ret = parser.execute(d);
854+
parser[kParserExecuting] = true;
855+
let ret;
856+
try {
857+
ret = parser.execute(d);
858+
} finally {
859+
parser[kParserExecuting] = false;
860+
}
861+
if (parser[kParserDeferred]) {
862+
parser[kParserDeferred] = false;
863+
// Resume after the code below has handled the result, unless the socket
864+
// was handed over or destroyed in the meantime.
865+
process.nextTick(resumeDeferredSocket, socket);
866+
}
843867
if (ret instanceof Error) {
844868
prepareError(ret, parser, d);
845869
debug('parse error', ret);
@@ -901,6 +925,13 @@ function socketOnData(d) {
901925
}
902926
}
903927

928+
function resumeDeferredSocket(socket) {
929+
if (!socket.destroyed && socket.readableFlowing === false &&
930+
socket.listenerCount('data', socketOnData) > 0) {
931+
socket.resume();
932+
}
933+
}
934+
904935
function statusIsInformational(status) {
905936
// 100 (Continue) RFC7231 Section 6.2.1
906937
// 102 (Processing) RFC2518

‎lib/internal/http/server.js‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ const kUpgradeStream = Symbol('UpgradeStream');
121121

122122
const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption');
123123
const kConnectionState = Symbol('ConnectionState');
124+
const kParserExecuting = Symbol('kParserExecuting');
125+
const kParserDeferred = Symbol('kParserDeferred');
124126
const kResponseOptions = Symbol('ResponseOptions');
125127

126128
const statusLineCache = [];
@@ -977,11 +979,34 @@ function socketOnEnd(server, socket, parser, state) {
977979
}
978980

979981
function socketOnData(server, socket, parser, state, d) {
982+
// The llhttp parser is not reentrant. JS that runs in a parser callback can
983+
// make the socket emit 'data' synchronously, for example by pushing into a
984+
// Duplex. Put the data back and parse it after the current call.
985+
if (parser[kParserExecuting]) {
986+
socket.pause();
987+
socket.unshift(d);
988+
parser[kParserDeferred] = true;
989+
return;
990+
}
980991
assert(!socket._paused);
981992
debug('SERVER socketOnData %d', d.length);
982993

983-
const ret = parser.execute(d);
994+
parser[kParserExecuting] = true;
995+
let ret;
996+
try {
997+
ret = parser.execute(d);
998+
} finally {
999+
parser[kParserExecuting] = false;
1000+
}
1001+
const deferred = parser[kParserDeferred];
1002+
parser[kParserDeferred] = false;
1003+
9841004
onParserExecuteCommon(server, socket, parser, state, ret, d);
1005+
1006+
if (deferred && socket.parser === parser && !socket._paused &&
1007+
!socket.destroyed) {
1008+
socket.resume();
1009+
}
9851010
}
9861011

9871012
function onRequestTimeout(socket) {

‎src/node_http_parser.cc‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -903,9 +903,20 @@ class Parser : public AsyncWrap, public StreamListener {
903903
Local<Value> Execute(const char* data, size_t len) {
904904
EscapableHandleScope scope(env()->isolate());
905905

906-
// A callback can run JS that executes the parser again, for example by
907-
// pushing data into the socket synchronously. Restore the caller's buffer
908-
// afterwards so that its own callbacks are checked against it.
906+
// llhttp is not reentrant: it keeps its position in the input in
907+
// parser_ and only updates it when llhttp_execute() returns. A callback
908+
// can run JS that gets here again, for example by pushing data into the
909+
// socket synchronously. lib/ defers that data, so a nested call with data
910+
// is a bug: do not run llhttp on top of itself, fail the outer call
911+
// instead. A nested finish() comes from tearing down the connection in a
912+
// callback, and the parser is freed right after it, so just skip it.
913+
if (executing_) {
914+
if (data != nullptr) reentered_ = true;
915+
return scope.Escape(Integer::New(env()->isolate(), 0));
916+
}
917+
918+
// The callback that reports the result of a consumed read can also get
919+
// here, outside of llhttp. Restore the caller's buffer afterwards.
909920
const char* const outer_data = current_buffer_data_;
910921
const size_t outer_len = current_buffer_len_;
911922
current_buffer_len_ = len;
@@ -914,12 +925,25 @@ class Parser : public AsyncWrap, public StreamListener {
914925

915926
llhttp_errno_t err;
916927

928+
executing_ = true;
917929
if (data == nullptr) {
918930
err = llhttp_finish(&parser_);
919931
} else {
920932
err = llhttp_execute(&parser_, data, len);
921933
Save();
922934
}
935+
executing_ = false;
936+
937+
if (reentered_) {
938+
reentered_ = false;
939+
if (err == HPE_OK || err == HPE_PAUSED || err == HPE_PAUSED_UPGRADE) {
940+
err = HPE_USER;
941+
parser_.error = err;
942+
parser_.error_pos = data + len;
943+
llhttp_set_error_reason(
944+
&parser_, "HPE_REENTRANT_EXECUTE:Parser was executed re-entrantly");
945+
}
946+
}
923947

924948
// Deliver buffered body spans while `data` is still alive.
925949
FlushBody();
@@ -1166,6 +1190,8 @@ class Parser : public AsyncWrap, public StreamListener {
11661190
size_t header_pairs_ = 0;
11671191
size_t max_header_pairs_ = 0;
11681192
bool pending_pause_ = false;
1193+
bool executing_ = false;
1194+
bool reentered_ = false;
11691195
bool received_data_ = false;
11701196
uint64_t header_nread_ = 0;
11711197
uint64_t chunk_extensions_nread_ = 0;
@@ -1187,11 +1213,6 @@ class Parser : public AsyncWrap, public StreamListener {
11871213
if (parser->is_being_freed_) {
11881214
return 0;
11891215
}
1190-
if (!parser->IsInCurrentBuffer(args...)) {
1191-
llhttp_set_error_reason(
1192-
p, "HPE_REENTRANT_EXECUTE:Parser re-entered in the middle of data");
1193-
return HPE_USER;
1194-
}
11951216
int rv = (parser->*Member)(std::forward<Args>(args)...);
11961217
if (rv == 0) {
11971218
rv = parser->MaybePause();
@@ -1200,20 +1221,6 @@ class Parser : public AsyncWrap, public StreamListener {
12001221
}
12011222
};
12021223

1203-
// A nested Execute() that stops in the middle of a span (e.g. a chunk of the
1204-
// body) leaves llhttp pointing into the nested call's buffer, which can be
1205-
// freed by the time the outer call reports the span. Data callbacks must
1206-
// only ever see the buffer that is being executed.
1207-
bool IsInCurrentBuffer() const {
1208-
return true;
1209-
}
1210-
bool IsInCurrentBuffer(const char* at, size_t length) const {
1211-
const uintptr_t begin = reinterpret_cast<uintptr_t>(current_buffer_data_);
1212-
const uintptr_t pos = reinterpret_cast<uintptr_t>(at);
1213-
return pos >= begin && pos - begin <= current_buffer_len_ &&
1214-
length <= current_buffer_len_ - (pos - begin);
1215-
}
1216-
12171224
typedef int (Parser::*Call)();
12181225
typedef int (Parser::*DataCall)(const char* at, size_t length);
12191226

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
// Data that a socket emits synchronously from inside a parser callback, for
5+
// example by pushing into a Duplex, must be parsed after the current call
6+
// to the parser returns, not from inside it.
7+
8+
const common = require('../common');
9+
const assert = require('assert');
10+
const http = require('http');
11+
const { Duplex } = require('stream');
12+
const { internalBinding } = require('internal/test/binding');
13+
const { HTTPParser } = internalBinding('http_parser');
14+
15+
const execute = HTTPParser.prototype.execute;
16+
let depth = 0;
17+
HTTPParser.prototype.execute = common.mustCallAtLeast(function(...args) {
18+
assert.strictEqual(depth, 0);
19+
depth++;
20+
try {
21+
return execute.apply(this, args);
22+
} finally {
23+
depth--;
24+
}
25+
}, 4);
26+
27+
{
28+
// Server: the request handler of the first request pushes the second one.
29+
const urls = [];
30+
let output = '';
31+
const connection = new Duplex({
32+
read() {},
33+
write(chunk, encoding, callback) {
34+
output += chunk;
35+
callback();
36+
},
37+
});
38+
const server = http.createServer(common.mustCall((req, res) => {
39+
urls.push(req.url);
40+
if (req.url === '/a')
41+
connection.push('GET /b HTTP/1.1\r\nHost: x\r\n\r\n');
42+
res.end('ok');
43+
}, 2));
44+
server.emit('connection', connection);
45+
connection.push('GET /a HTTP/1.1\r\nHost: x\r\n\r\n');
46+
setImmediate(common.mustCall(() => {
47+
assert.deepStrictEqual(urls, ['/a', '/b']);
48+
assert.strictEqual(output.split('HTTP/1.1 200 OK\r\n').length, 3);
49+
}));
50+
}
51+
52+
{
53+
// Client: the 'information' handler pushes the final response.
54+
let socket;
55+
const req = http.request('http://localhost', {
56+
createConnection() {
57+
socket = new Duplex({
58+
read() {},
59+
write(chunk, encoding, callback) {
60+
if (chunk.toString().includes('\r\n\r\n'))
61+
this.push('HTTP/1.1 100 Continue\r\n\r\n');
62+
callback();
63+
},
64+
});
65+
return socket;
66+
},
67+
});
68+
req.on('information', common.mustCall(({ statusCode }) => {
69+
assert.strictEqual(statusCode, 100);
70+
socket.push('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok');
71+
socket.push(null);
72+
}));
73+
req.on('response', common.mustCall((res) => {
74+
assert.strictEqual(res.statusCode, 200);
75+
res.setEncoding('utf8');
76+
let body = '';
77+
res.on('data', (chunk) => body += chunk);
78+
res.on('end', common.mustCall(() => assert.strictEqual(body, 'ok')));
79+
}));
80+
req.end();
81+
}

0 commit comments

Comments
 (0)