Commit a92cdebb08e for nodejs

commit a92cdebb08eaa4470391c00d20669935149fb42b
Author: Matteo Collina <hello@matteocollina.com>
Date:   Wed Oct 28 00:44:26 2026 +0100

    tls,http: prevent nested parser execution from empty writes

    An empty write on a TLS socket calls `ClearOut()` directly, even when
    it comes from JS that runs inside `Cycle()`, for example the HTTP
    parser's `on_body`. `ClearOut()` then decrypts the next record and
    passes it to the same listener while it is still processing the
    current one. For an HTTPS server this runs `llhttp_execute()` inside
    itself. The nested call can stop in the middle of a body chunk and
    leave the open span pointing into its own buffer, which is freed when
    it returns. The outer call then reports that span against its own
    buffer, so the request body contains server heap memory, or the
    process aborts.

    Skip the read on an empty write while a cycle is running. The cycle
    reads the next record once the listener has returned.

    In the HTTP parser, re-entrant `Execute()` calls remain supported, but
    each call now restores the caller's buffer when it returns, and data
    callbacks fail with `HPE_REENTRANT_EXECUTE` when they point outside the
    buffer that is being executed.

    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/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc
index 7f4d7047337..8c2dad08ad6 100644
--- a/src/crypto/crypto_tls.cc
+++ b/src/crypto/crypto_tls.cc
@@ -1175,7 +1175,11 @@ int TLSWrap::DoWrite(WriteWrap* w,
   // WriteWrap was accepted by the stream, or that we call Done() on it.
   if (length == 0) {
     Debug(this, "Empty write");
-    ClearOut();
+    // When the write comes from JS running inside Cycle(), for example from a
+    // stream listener in ClearOut(), the cycle reads and encrypts any pending
+    // data once that JS returns. Reading here would hand the next record to
+    // the listener while it is still processing the current one.
+    if (cycle_depth_ == 0) ClearOut();
     if (BIO_pending(enc_out_) == 0) {
       Debug(this, "No pending encrypted output, writing to underlying stream");
       CHECK(!current_empty_write_);
diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc
index fdfe6c7f637..043f3811de3 100644
--- a/src/node_http_parser.cc
+++ b/src/node_http_parser.cc
@@ -888,19 +888,26 @@ class Parser : public AsyncWrap, public StreamListener {
       return;

     // Hooks for GetCurrentBuffer
+    const char* const outer_data = current_buffer_data_;
+    const size_t outer_len = current_buffer_len_;
     current_buffer_len_ = nread;
     current_buffer_data_ = buf.base;

     MakeCallback(cb.As<Function>(), 1, &ret);

-    current_buffer_len_ = 0;
-    current_buffer_data_ = nullptr;
+    current_buffer_len_ = outer_len;
+    current_buffer_data_ = outer_data;
   }


   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.
+    const char* const outer_data = current_buffer_data_;
+    const size_t outer_len = current_buffer_len_;
     current_buffer_len_ = len;
     current_buffer_data_ = data;
     got_exception_ = false;
@@ -935,8 +942,8 @@ class Parser : public AsyncWrap, public StreamListener {
       llhttp_pause(&parser_);
     }

-    current_buffer_len_ = 0;
-    current_buffer_data_ = nullptr;
+    current_buffer_len_ = outer_len;
+    current_buffer_data_ = outer_data;

     // If there was an exception in one of the callbacks
     if (got_exception_)
@@ -1180,6 +1187,11 @@ 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();
@@ -1188,6 +1200,20 @@ 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-https-empty-write-nested-parser-execute.js b/test/parallel/test-https-empty-write-nested-parser-execute.js
new file mode 100644
index 00000000000..a505f155979
--- /dev/null
+++ b/test/parallel/test-https-empty-write-nested-parser-execute.js
@@ -0,0 +1,128 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+// A zero-length write on a TLS socket used to read the next TLS record
+// synchronously. When that write came from JavaScript running inside the
+// HTTP parser's `on_body` callback, the parser was executed re-entrantly and
+// the request body included memory that the client never sent.
+//
+// Request 1 is a HEAD request whose response headers are flushed early, so
+// `res.end('done')` writes '' to the socket. Its 'end' handler runs from the
+// tick queue while the body of request 2 is being parsed, with the next TLS
+// record already received.
+
+const assert = require('assert');
+const https = require('https');
+const net = require('net');
+const tls = require('tls');
+const { Duplex } = require('stream');
+const fixtures = require('../common/fixtures');
+
+// Holds back writes so that several TLS records reach the server in a single
+// TCP write.
+class Batch extends Duplex {
+  constructor(socket) {
+    super();
+    this.socket = socket;
+    this.hold = false;
+    this.queue = [];
+    socket.on('data', (chunk) => this.push(chunk));
+    socket.on('end', () => this.push(null));
+    socket.on('close', () => this.destroy());
+  }
+  _read() {}
+  _write(chunk, encoding, callback) {
+    if (this.hold)
+      this.queue.push(chunk);
+    else
+      this.socket.write(chunk);
+    callback();
+  }
+  _final(callback) {
+    this.socket.end(callback);
+  }
+  _destroy(err, callback) {
+    this.socket.destroy();
+    callback(err);
+  }
+  flush() {
+    this.hold = false;
+    this.socket.write(Buffer.concat(this.queue));
+    this.queue = [];
+  }
+}
+
+const options = {
+  key: fixtures.readKey('agent1-key.pem'),
+  cert: fixtures.readKey('agent1-cert.pem'),
+};
+
+// Sends request 1, then the rest of it plus the start of request 2 and
+// `record` as two TLS records in one TCP write, then `tail`.
+function test(record, tail, check) {
+  return new Promise((resolve) => {
+    const bodies = [];
+    const server = https.createServer(options, (req, res) => {
+      res.flushHeaders();
+      const chunks = [];
+      bodies.push(chunks);
+      req.on('data', (chunk) => chunks.push(chunk));
+      req.on('error', () => {});
+      req.on('end', () => res.end('done'));
+    });
+
+    server.listen(0, common.mustCall(() => {
+      const batch = new Batch(net.connect(server.address().port));
+      const socket = tls.connect({ socket: batch, rejectUnauthorized: false },
+                                 common.mustCall(() => {
+                                   socket.write('HEAD / HTTP/1.1\r\nHost: x\r\n' +
+                                                'Transfer-Encoding: chunked\r\n\r\n');
+                                 }));
+      let response = '';
+      socket.setEncoding('utf8');
+      socket.on('data', (chunk) => {
+        response += chunk;
+        if (response.endsWith('\r\n4\r\ndone\r\n0\r\n\r\n'))
+          socket.end();
+      });
+      socket.on('error', () => {});
+      socket.once('data', common.mustCall(() => {
+        batch.hold = true;
+        socket.write('0\r\n\r\nPOST / HTTP/1.1\r\nHost: x\r\n' +
+                     'Transfer-Encoding: chunked\r\n\r\n1\r\nZ', common.mustCall(() => {
+          socket.write(record, common.mustCall(() => {
+            batch.flush();
+            if (tail)
+              socket.write(tail);
+          }));
+        }));
+      }));
+      socket.on('close', common.mustCall(() => {
+        server.close();
+        check(bodies.map((chunks) => Buffer.concat(chunks).toString('latin1')),
+              response);
+        resolve();
+      }));
+    }));
+  });
+}
+
+(async () => {
+  // A valid request 2: its body arrives intact and both requests get a
+  // response.
+  await test('\r\n5\r\nHE', 'LLO\r\n0\r\n\r\n', (bodies, response) => {
+    assert.deepStrictEqual(bodies, ['', 'ZHELLO']);
+    assert.strictEqual(response.split('HTTP/1.1 200 OK\r\n').length, 3);
+    assert.strictEqual(response.split('done').length, 2);
+  });
+
+  // The chunk 'Z' is not followed by CRLF, so request 2 is a parse error. The
+  // body must not contain anything but the byte that was sent.
+  await test('5\r\nHE', null, (bodies, response) => {
+    assert.deepStrictEqual(bodies, ['', 'Z']);
+    assert.match(response, /^HTTP\/1\.1 200 OK\r\n/);
+  });
+})().then(common.mustCall());