Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions lib/internal/http/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
}
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/tls/wrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
67 changes: 65 additions & 2 deletions src/stream_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
#include "udp_wrap.h"
#include "util-inl.h"

#include <cstring> // memcpy()
#include <cerrno> // errno
#include <climits> // INT_MAX

#include <cstring> // memcpy()

namespace node {

Expand Down Expand Up @@ -137,6 +137,7 @@ void LibuvStreamWrap::RegisterExternalReferences(
registry->Register(IsConstructCallCallback);
registry->Register(GetWriteQueueSize);
registry->Register(SetBlocking);
registry->Register(HasPendingRead);
StreamBase::RegisterExternalReferences(registry);
}

Expand Down Expand Up @@ -175,6 +176,7 @@ Local<FunctionTemplate> LibuvStreamWrap::GetConstructorTemplate(
Local<FunctionTemplate>(),
static_cast<PropertyAttribute>(ReadOnly | DontDelete));
SetProtoMethod(isolate, tmpl, "setBlocking", SetBlocking);
SetProtoMethod(isolate, tmpl, "hasPendingRead", HasPendingRead);
StreamBase::AddMethods(env, tmpl);
env->set_libuv_stream_wrap_ctor_template(tmpl);
}
Expand Down Expand Up @@ -343,6 +345,67 @@ void LibuvStreamWrap::SetBlocking(const FunctionCallbackInfo<Value>& 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<Value>& 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<uv_handle_t*>(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<SOCKET>(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<ReqWrap<uv_shutdown_t>> LibuvShutdownWrap;
typedef SimpleWriteWrap<ReqWrap<uv_write_t>> LibuvWriteWrap;

Expand Down
1 change: 1 addition & 0 deletions src/stream_wrap.h
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class LibuvStreamWrap : public HandleWrap, public StreamBase {
static void GetWriteQueueSize(
const v8::FunctionCallbackInfo<v8::Value>& info);
static void SetBlocking(const v8::FunctionCallbackInfo<v8::Value>& args);
static void HasPendingRead(const v8::FunctionCallbackInfo<v8::Value>& args);

// Callbacks for libuv
void OnUvAlloc(size_t suggested_size, uv_buf_t* buf);
Expand Down
121 changes: 121 additions & 0 deletions test/parallel/test-http-agent-free-socket-pending-read.js
Original file line number Diff line number Diff line change
@@ -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();
}));
}));
}
Loading