diff --git a/lib/internal/http/agent.js b/lib/internal/http/agent.js index 3871c5b56a2..e5686012ff2 100644 --- a/lib/internal/http/agent.js +++ b/lib/internal/http/agent.js @@ -139,6 +139,20 @@ function installFreeSocketDataGuard(socket) { } } +// The data guard above only runs when the event loop polls for I/O. Data +// that reached the OS socket buffer after the last poll has not been seen +// by it yet, so check for it directly before handing an idle socket to a +// request. See: https://hackerone.com/reports/4038361 (CVE-2026-105481). +function hasPendingRead(socket) { + // A socket that is still connecting has nothing to read yet, and a + // pending connect error must be reported to the request rather than + // swallowed here. + if (socket.connecting) return false; + const handle = socket._handle; + return typeof handle?.hasPendingRead === 'function' && + handle.hasPendingRead() === true; +} + function removeFreeSocketDataGuard(socket) { if (socket[kFreeSocketDataGuard]) { socket.removeListener('connect', socket[kFreeSocketDataGuard]); @@ -216,6 +230,12 @@ function Agent(options) { const requests = this.requests[name]; if (requests?.length) { + if (hasPendingRead(socket)) { + // onClose() will create a new socket for the pending request. + debug('PENDING DATA on released socket - destroying'); + socket.destroy(); + return; + } const req = requests.shift(); const reqAsyncRes = req[kRequestAsyncResource]; if (reqAsyncRes) { @@ -393,6 +413,13 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */, socket = this.scheduling === 'fifo' ? freeSockets.shift() : freeSockets.pop(); + while (socket && hasPendingRead(socket)) { + debug('PENDING DATA on FREE socket - destroying poisoned socket'); + socket.destroy(); + socket = this.scheduling === 'fifo' ? + freeSockets.shift() : + freeSockets.pop(); + } if (!freeSockets.length) delete this.freeSockets[name]; } diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index 82f343de726..8858510e6de 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -740,7 +740,7 @@ exports.TLSSocket = TLSSocket; const proxiedMethods = [ 'ref', 'unref', 'hasRef', 'open', 'bind', 'listen', 'connect', 'bind6', 'connect6', 'getsockname', 'getpeername', 'setNoDelay', 'setKeepAlive', - 'setSimultaneousAccepts', 'setBlocking', + 'setSimultaneousAccepts', 'setBlocking', 'hasPendingRead', // PipeWrap 'setPendingInstances', diff --git a/src/stream_wrap.cc b/src/stream_wrap.cc index e2ffaa0f4e1..a1416b18d46 100644 --- a/src/stream_wrap.cc +++ b/src/stream_wrap.cc @@ -33,9 +33,9 @@ #include "udp_wrap.h" #include "util-inl.h" -#include // memcpy() +#include // errno #include // INT_MAX - +#include // memcpy() namespace node { @@ -137,6 +137,7 @@ void LibuvStreamWrap::RegisterExternalReferences( registry->Register(IsConstructCallCallback); registry->Register(GetWriteQueueSize); registry->Register(SetBlocking); + registry->Register(HasPendingRead); StreamBase::RegisterExternalReferences(registry); } @@ -175,6 +176,7 @@ Local LibuvStreamWrap::GetConstructorTemplate( Local(), static_cast(ReadOnly | DontDelete)); SetProtoMethod(isolate, tmpl, "setBlocking", SetBlocking); + SetProtoMethod(isolate, tmpl, "hasPendingRead", HasPendingRead); StreamBase::AddMethods(env, tmpl); env->set_libuv_stream_wrap_ctor_template(tmpl); } @@ -343,6 +345,67 @@ void LibuvStreamWrap::SetBlocking(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(uv_stream_set_blocking(wrap->stream(), enable)); } +// Returns true if a read on the underlying socket would complete right now, +// i.e. the OS has data, EOF, or a socket error queued that libuv has not +// delivered yet because the event loop has not polled for I/O since it +// arrived. Nothing is consumed. Returns false when there is nothing pending +// or when the handle type does not support the check. +void LibuvStreamWrap::HasPendingRead(const FunctionCallbackInfo& args) { + LibuvStreamWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + args.GetReturnValue().Set(false); + + if (!wrap->IsAlive() || wrap->IsClosing()) return; + +#ifdef _WIN32 + // Only TCP sockets can be probed with recv() on Windows. + if (!wrap->is_tcp()) return; +#else + if (!wrap->is_tcp() && !wrap->is_named_pipe()) return; +#endif + + uv_os_fd_t fd; + if (uv_fileno(reinterpret_cast(wrap->stream()), &fd) != 0) + return; + + char c; +#ifdef _WIN32 + // libuv puts TCP sockets in non-blocking mode and reads with zero-byte + // overlapped reads, so unread data stays in the kernel buffer. + int r = recv(reinterpret_cast(fd), &c, 1, MSG_PEEK); + if (r == SOCKET_ERROR) { + switch (WSAGetLastError()) { + case WSAEWOULDBLOCK: + case WSAENOTCONN: + case WSAENOTSOCK: + return; + } + } +#else + int flags = MSG_PEEK; +#ifdef MSG_DONTWAIT + flags |= MSG_DONTWAIT; +#endif + ssize_t r; + do { + r = recv(fd, &c, 1, flags); + } while (r == -1 && errno == EINTR); + if (r == -1) { + switch (errno) { + case EAGAIN: +#if EWOULDBLOCK != EAGAIN + case EWOULDBLOCK: +#endif + case ENOTCONN: + case ENOTSOCK: + return; + } + } +#endif + + args.GetReturnValue().Set(true); +} + typedef SimpleShutdownWrap> LibuvShutdownWrap; typedef SimpleWriteWrap> LibuvWriteWrap; diff --git a/src/stream_wrap.h b/src/stream_wrap.h index b06b4be6988..74a5ddb00c2 100644 --- a/src/stream_wrap.h +++ b/src/stream_wrap.h @@ -114,6 +114,7 @@ class LibuvStreamWrap : public HandleWrap, public StreamBase { static void GetWriteQueueSize( const v8::FunctionCallbackInfo& info); static void SetBlocking(const v8::FunctionCallbackInfo& args); + static void HasPendingRead(const v8::FunctionCallbackInfo& args); // Callbacks for libuv void OnUvAlloc(size_t suggested_size, uv_buf_t* buf); diff --git a/test/parallel/test-http-agent-free-socket-pending-read.js b/test/parallel/test-http-agent-free-socket-pending-read.js new file mode 100644 index 00000000000..a8ff4d681d6 --- /dev/null +++ b/test/parallel/test-http-agent-free-socket-pending-read.js @@ -0,0 +1,121 @@ +'use strict'; + +// The free socket data guard in http.Agent only runs when the event loop +// polls for I/O. If data reaches an idle socket's OS buffer and a request +// takes that socket before the loop has polled, the guard never sees the data +// and it would be parsed as the response to that request. The Agent must +// check for pending data before handing a kept-alive socket to a request. + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const poison = + 'HTTP/1.1 200 OK\r\n' + + 'X-Poisoned: true\r\n' + + 'Connection: keep-alive\r\n' + + 'Content-Length: 0\r\n' + + '\r\n'; + +// Keep the event loop from polling until the bytes are in the client's +// socket buffer, so libuv cannot deliver them to the data guard first. +function waitForPendingRead(socket) { + const deadline = Date.now() + common.platformTimeout(5000); + while (!socket._handle.hasPendingRead()) { + assert.ok(Date.now() < deadline, 'timed out waiting for pending data'); + } +} + +function checkResponse(req, expectedBody, cb) { + req.on('response', common.mustCall((res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', common.mustCall(() => { + assert.strictEqual(res.headers['x-poisoned'], undefined); + assert.strictEqual(body, expectedBody); + cb?.(res); + })); + })); +} + +// A socket taken from the free pool. +{ + let serverSocket; + const server = http.createServer(common.mustCall((req, res) => { + serverSocket ||= req.socket; + res.end(req.url); + }, 2)); + + server.listen(0, common.mustCall(() => { + const agent = new http.Agent({ keepAlive: true }); + const options = { host: '127.0.0.1', port: server.address().port, agent }; + const name = agent.getName(options); + + const request1 = http.request({ ...options, path: '/request1' }); + request1.end(); + checkResponse(request1, '/request1'); + + request1.on('close', common.mustCall(() => { + process.nextTick(common.mustCall(() => { + assert.strictEqual(agent.freeSockets[name]?.length, 1); + const freeSocket = agent.freeSockets[name][0]; + + serverSocket.write(poison); + waitForPendingRead(freeSocket); + + // Same tick: the loop has not polled since the data arrived. + const request2 = http.request({ ...options, path: '/request2' }); + request2.end(); + + assert.strictEqual(freeSocket.destroyed, true); + assert.strictEqual(request2.reusedSocket, false); + + checkResponse(request2, '/request2', common.mustCall(() => { + agent.destroy(); + server.close(); + })); + })); + })); + })); +} + +// A released socket handed directly to a queued request. +{ + let serverSocket; + const server = http.createServer(common.mustCall((req, res) => { + serverSocket ||= req.socket; + res.end(req.url); + }, 2)); + + server.listen(0, common.mustCall(() => { + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + const options = { host: '127.0.0.1', port: server.address().port, agent }; + const name = agent.getName(options); + + const request1 = http.request({ ...options, path: '/request1' }); + request1.end(); + const request2 = http.request({ ...options, path: '/request2' }); + request2.end(); + assert.strictEqual(agent.requests[name]?.length, 1); + + let socket1; + request1.on('socket', common.mustCall((socket) => { + socket1 = socket; + })); + + // The socket is released to the queued request on the next tick after + // 'end', so the loop does not poll in between. + checkResponse(request1, '/request1', common.mustCall(() => { + serverSocket.write(poison); + waitForPendingRead(socket1); + })); + + checkResponse(request2, '/request2', common.mustCall(() => { + assert.strictEqual(socket1.destroyed, true); + assert.strictEqual(request2.reusedSocket, false); + agent.destroy(); + server.close(); + })); + })); +}