Skip to content

Commit 36c4a11

Browse files
committed
http: avoid stream listeners on idle agent sockets
Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 21310cb commit 36c4a11

3 files changed

Lines changed: 52 additions & 13 deletions

File tree

lib/_http_agent.js

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,22 @@ const {
4343
getGlobalAgent,
4444
} = require('internal/http');
4545
const { AsyncResource } = require('async_hooks');
46-
const { async_id_symbol } = require('internal/async_hooks').symbols;
46+
const {
47+
async_id_symbol,
48+
owner_symbol,
49+
} = require('internal/async_hooks').symbols;
4750
const {
4851
getLazy,
4952
kEmptyObject,
5053
once,
5154
} = require('internal/util');
55+
const {
56+
onStreamRead,
57+
} = require('internal/stream_base_commons');
58+
const {
59+
kReadBytesOrError,
60+
streamBaseState,
61+
} = internalBinding('stream_wrap');
5262
const {
5363
validateNumber,
5464
validateOneOf,
@@ -92,9 +102,37 @@ function freeSocketErrorListener(err) {
92102
// in the TCP buffer and be silently consumed as the response for the
93103
// *next* request that reuses the socket (response-queue poisoning).
94104
// See: https://hackerone.com/reports/3582376
95-
function freeSocketDataGuard() {
96-
debug('DATA on FREE socket - destroying poisoned socket');
97-
this.destroy();
105+
function freeSocketOnReadGuard() {
106+
const nread = streamBaseState[kReadBytesOrError];
107+
if (nread === 0) return;
108+
109+
debug('READ on FREE socket - destroying poisoned socket');
110+
this[owner_symbol].destroy();
111+
}
112+
113+
function installFreeSocketDataGuard(socket) {
114+
if (socket.readableLength > 0) {
115+
debug('BUFFERED DATA on FREE socket - destroying poisoned socket');
116+
socket.destroy();
117+
return;
118+
}
119+
120+
const handle = socket._handle;
121+
if (handle) {
122+
handle.onread = freeSocketOnReadGuard;
123+
if (!handle.reading) {
124+
handle.reading = true;
125+
const err = handle.readStart();
126+
if (err) socket.destroy();
127+
}
128+
}
129+
}
130+
131+
function removeFreeSocketDataGuard(socket) {
132+
const handle = socket._handle;
133+
if (handle?.onread === freeSocketOnReadGuard) {
134+
handle.onread = onStreamRead;
135+
}
98136
}
99137

100138
function Agent(options) {
@@ -207,8 +245,7 @@ function Agent(options) {
207245
this.removeSocket(socket, options);
208246

209247
socket.once('error', freeSocketErrorListener);
210-
socket.on('data', freeSocketDataGuard);
211-
socket.resume();
248+
installFreeSocketDataGuard(socket);
212249
freeSockets.push(socket);
213250
});
214251

@@ -600,7 +637,7 @@ Agent.prototype.keepSocketAlive = function keepSocketAlive(socket) {
600637
Agent.prototype.reuseSocket = function reuseSocket(socket, req) {
601638
debug('have free socket');
602639
socket.removeListener('error', freeSocketErrorListener);
603-
socket.removeListener('data', freeSocketDataGuard);
640+
removeFreeSocketDataGuard(socket);
604641
req.reusedSocket = true;
605642
socket.ref();
606643
};

test/parallel/test-http-agent-free-socket-data-guard.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
// writes a full HTTP response during this window, it is consumed as the
99
// response for the *next* request — poisoning the response queue.
1010
//
11-
// The fix attaches a data guard listener + resume() on idle sockets so
12-
// that unsolicited data causes the socket to be destroyed.
11+
// The fix installs a read guard on idle sockets so that unsolicited data
12+
// causes the socket to be destroyed without adding public stream listeners.
1313

1414
const common = require('../common');
1515
const assert = require('assert');
@@ -48,8 +48,9 @@ server.listen(0, common.mustCall(() => {
4848
assert.strictEqual(agent.freeSockets[name]?.length, 1);
4949
const freeSocket = agent.freeSockets[name][0];
5050
assert.strictEqual(freeSocket.parser, null);
51-
// With the fix, a data guard listener is attached
52-
assert.strictEqual(freeSocket.listenerCount('data'), 1);
51+
// With the fix, no public stream listeners are added.
52+
assert.strictEqual(freeSocket.listenerCount('data'), 0);
53+
assert.strictEqual(freeSocket.listenerCount('readable'), 0);
5354

5455
// Step 2: Server injects a poisoned response while socket is idle
5556
serverSocket.write(

test/parallel/test-http-agent-keepalive.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,9 @@ server.listen(0, common.mustCall(() => {
149149
function checkListeners(socket) {
150150
const callback = common.mustCall(() => {
151151
if (!socket.destroyed) {
152-
// Sockets have freeSocketDataGuard while in the free pool.
153-
assert.strictEqual(socket.listenerCount('data'), 1);
152+
// Sockets have no public stream guard listeners while in the free pool.
153+
assert.strictEqual(socket.listenerCount('readable'), 0);
154+
assert.strictEqual(socket.listenerCount('data'), 0);
154155
assert.strictEqual(socket.listenerCount('drain'), 0);
155156
// Sockets have freeSocketErrorListener.
156157
assert.strictEqual(socket.listenerCount('error'), 1);

0 commit comments

Comments
 (0)