Skip to content

Commit 9f3bab3

Browse files
committed
tls: coalesce buffered records into one read
SSL_read() returns at most one TLS record, and ClearOut() emitted a separate read for each one. Peers that use small records, such as nginx with its default 4 KiB ssl_buffer_size or servers doing dynamic record sizing, made every record pay for its own buffer allocation and call into JavaScript. While the next buffered record is application data, keep reading into the same 16 KiB buffer before emitting it, so reads never get larger than one full record already makes them. Batching stops before any other record type, so that e.g. a TLS 1.2 renegotiation is still processed after the data in front of it has been emitted. If SSL_read() fails in the middle of a batch, the error is captured first and reported after the data. test-tls-max-send-fragment relied on 'data' chunks matching records, so it now checks the record sizes on the wire. Signed-off-by: Robert Nagy <ronagy@icloud.com> Assisted-by: claude:opus-5.5
1 parent c883ccf commit 9f3bab3

5 files changed

Lines changed: 290 additions & 29 deletions

File tree

‎benchmark/tls/throughput-s2c.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const bench = common.createBenchmark(main, {
66
sendchunklen: [256, 32 * 1024, 128 * 1024, 16 * 1024 * 1024],
77
recvbuflen: [0, 64 * 1024, 1024 * 1024],
88
recvbufgenfn: ['true', 'false'],
9+
recordlen: [1024, 4096, 16384],
910
});
1011

1112
const fixtures = require('../../test/common/fixtures');
@@ -14,7 +15,8 @@ let recvbuf;
1415
let received = 0;
1516
const tls = require('tls');
1617

17-
function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn }) {
18+
function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn,
19+
recordlen }) {
1820
if (isFinite(recvbuflen) && recvbuflen > 0)
1921
recvbuf = Buffer.alloc(recvbuflen);
2022

@@ -74,6 +76,7 @@ function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn }) {
7476
}
7577

7678
const server = tls.createServer(options, (socket) => {
79+
socket.setMaxSendFragment(recordlen);
7780
socket.on('data', (buf) => {
7881
socket.on('drain', write);
7982
write();

‎src/crypto/crypto_tls.cc‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -880,20 +880,14 @@ void TLSWrap::ClearOut() {
880880
MarkPopErrorOnReturn mark_pop_error_on_return;
881881

882882
char out[kClearOutChunkSize];
883+
int length = 0;
883884
int read;
884-
for (;;) {
885-
{
886-
SSLLibraryCallScope ssl_library_call_scope(this);
887-
read = SSL_read(ssl_.get(), out, sizeof(out));
888-
}
889-
Debug(this, "Read %d bytes of cleartext output", read);
890-
891-
if (read <= 0)
892-
break;
893885

886+
// Emits out[0, length). Returns false if JS destroyed the SSL context.
887+
auto emit = [&]() {
894888
char* current = out;
895-
while (read > 0) {
896-
int avail = read;
889+
while (length > 0) {
890+
int avail = length;
897891

898892
uv_buf_t buf = EmitAlloc(avail);
899893
if (static_cast<int>(buf.len) < avail)
@@ -906,26 +900,54 @@ void TLSWrap::ClearOut() {
906900
// check that ssl_ != nullptr afterwards.
907901
if (ssl_ == nullptr) {
908902
Debug(this, "Returning from read loop, ssl_ == nullptr");
909-
return;
903+
return false;
910904
}
911905

912-
read -= avail;
906+
length -= avail;
913907
current += avail;
914908
}
909+
return true;
910+
};
911+
912+
for (;;) {
913+
{
914+
SSLLibraryCallScope ssl_library_call_scope(this);
915+
read = SSL_read(ssl_.get(), out + length, kClearOutChunkSize - length);
916+
}
917+
Debug(this, "Read %d bytes of cleartext output", read);
918+
919+
if (read <= 0) break;
920+
length += read;
921+
922+
// SSL_read() returns at most one record. If the next record is already
923+
// buffered, decrypt it into the same chunk instead of emitting one read
924+
// per record. Only application data is batched: other record types can
925+
// trigger callbacks, e.g. for renegotiation, that have to run after the
926+
// preceding data has been emitted.
927+
size_t pending;
928+
const char* next = NodeBIO::FromBIO(enc_in_)->Peek(&pending);
929+
if (length < kClearOutChunkSize && pending > 0 &&
930+
static_cast<unsigned char>(next[0]) == SSL3_RT_APPLICATION_DATA) {
931+
continue;
932+
}
933+
934+
if (!emit()) return;
915935
}
916936

917937
// We need to check whether an error occurred or the connection was
918938
// shutdown cleanly (SSL_ERROR_ZERO_RETURN) even when read == 0.
919939
// See node#1642 and SSL_read(3SSL) for details. SSL_get_error must be
920940
// called immediately after SSL_read, without calling into JS, which may
921941
// change OpenSSL's error queue, modify ssl_, or even destroy ssl_
922-
// altogether.
942+
// altogether. Data that was read before the failure is emitted once the
943+
// error has been captured, before it is reported.
923944
if (read <= 0) {
924945
HandleScope handle_scope(env()->isolate());
925946
Local<Value> error;
926947
int err = SSL_get_error(ssl_.get(), read);
927948
switch (err) {
928949
case SSL_ERROR_ZERO_RETURN:
950+
if (!emit()) return;
929951
if (!flags_.eof) {
930952
flags_.eof = true;
931953
EmitRead(UV_EOF);
@@ -975,9 +997,12 @@ void TLSWrap::ClearOut() {
975997
break;
976998

977999
default:
1000+
emit();
9781001
return;
9791002
}
9801003

1004+
if (!emit()) return;
1005+
9811006
Debug(this, "Got SSL error (%d), calling onerror", err);
9821007
// When TLS Alert are stored in wbio,
9831008
// it should be flushed to socket before destroyed.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use strict';
2+
// TLS records that are already buffered are emitted as one chunk rather than
3+
// one chunk per record. The end of the stream, clean or not, must still be
4+
// reported after the data that precedes it.
5+
6+
const common = require('../common');
7+
if (!common.hasCrypto)
8+
common.skip('missing crypto');
9+
10+
const assert = require('assert');
11+
const net = require('net');
12+
const tls = require('tls');
13+
const fixtures = require('../common/fixtures');
14+
15+
const RECORD_SIZE = 512;
16+
const RECORDS = 256;
17+
const payload = Buffer.alloc(RECORD_SIZE * RECORDS);
18+
for (let i = 0; i < RECORDS; i++)
19+
payload.fill(i, i * RECORD_SIZE, (i + 1) * RECORD_SIZE);
20+
21+
const serverOptions = {
22+
key: fixtures.readKey('agent1-key.pem'),
23+
cert: fixtures.readKey('agent1-cert.pem'),
24+
};
25+
const clientOptions = {
26+
ca: fixtures.readKey('ca1-cert.pem'),
27+
servername: 'agent1',
28+
};
29+
30+
function sendPayload(socket) {
31+
assert(socket.setMaxSendFragment(RECORD_SIZE));
32+
socket.end(payload);
33+
}
34+
35+
// Small records are coalesced, and 'end' follows all of the data.
36+
{
37+
const server = tls.createServer(serverOptions, common.mustCall(sendPayload));
38+
server.listen(0, common.mustCall(() => {
39+
const chunks = [];
40+
const client = tls.connect({ ...clientOptions, port: server.address().port });
41+
client.on('data', (chunk) => chunks.push(chunk));
42+
client.on('end', common.mustCall(() => {
43+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
44+
assert(chunks.length < RECORDS, `${chunks.length} chunks`);
45+
assert(chunks.every((chunk) => chunk.length <= 16 * 1024));
46+
server.close();
47+
}));
48+
}));
49+
}
50+
51+
// An onread buffer smaller than a record still receives every byte.
52+
{
53+
const server = tls.createServer(serverOptions, common.mustCall(sendPayload));
54+
server.listen(0, common.mustCall(() => {
55+
const chunks = [];
56+
const client = tls.connect({
57+
...clientOptions,
58+
port: server.address().port,
59+
onread: {
60+
buffer: Buffer.alloc(100),
61+
callback(nread, buf) {
62+
chunks.push(Buffer.from(buf.subarray(0, nread)));
63+
},
64+
},
65+
});
66+
client.on('end', common.mustCall(() => {
67+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
68+
server.close();
69+
}));
70+
}));
71+
}
72+
73+
// A bad record that arrives together with data is reported after the data.
74+
{
75+
const server = tls.createServer(serverOptions, common.mustCall((socket) => {
76+
socket.once('data', common.mustCall(() => sendPayload(socket)));
77+
}));
78+
server.listen(0, common.mustCall(() => {
79+
// Passes the handshake through, then holds the response until the server
80+
// ends and forwards it in one write, with the last record (close_notify)
81+
// corrupted.
82+
let holding = false;
83+
const held = [];
84+
const proxy = net.createServer(common.mustCall((socket) => {
85+
const upstream = net.connect(server.address().port);
86+
socket.pipe(upstream);
87+
socket.on('error', () => {});
88+
upstream.on('data', (chunk) => {
89+
if (holding)
90+
held.push(chunk);
91+
else
92+
socket.write(chunk);
93+
});
94+
upstream.on('end', common.mustCall(() => {
95+
const data = Buffer.concat(held);
96+
data[data.length - 1] ^= 1;
97+
socket.end(data);
98+
}));
99+
}));
100+
proxy.listen(0, common.mustCall(() => {
101+
const chunks = [];
102+
const client = tls.connect({
103+
...clientOptions,
104+
port: proxy.address().port,
105+
}, common.mustCall(() => {
106+
holding = true;
107+
client.write('go');
108+
}));
109+
client.on('data', (chunk) => chunks.push(chunk));
110+
client.on('error', common.mustCall((err) => {
111+
assert.strictEqual(err.code,
112+
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC');
113+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
114+
proxy.close();
115+
server.close();
116+
}));
117+
}));
118+
}));
119+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use strict';
2+
const common = require('../common');
3+
if (!common.hasCrypto)
4+
common.skip('missing crypto');
5+
const { isBoringSSL } = require('../common/crypto');
6+
if (isBoringSSL)
7+
common.skip('BoringSSL does not support renegotiation');
8+
9+
const assert = require('assert');
10+
const net = require('net');
11+
const tls = require('tls');
12+
const fixtures = require('../common/fixtures');
13+
14+
// Deliver application data and a TLS 1.2 ClientHello in one transport write.
15+
// The data handler must run before renegotiation starts, so it can disable it.
16+
const payload = Buffer.from('disable renegotiation');
17+
let holding = false;
18+
let client;
19+
20+
const server = tls.createServer({
21+
key: fixtures.readKey('agent1-key.pem'),
22+
cert: fixtures.readKey('agent1-cert.pem'),
23+
maxVersion: 'TLSv1.2',
24+
}, common.mustCall((socket) => {
25+
socket.on('data', common.mustCall((data) => {
26+
assert.deepStrictEqual(data, payload);
27+
socket.disableRenegotiation();
28+
}));
29+
socket.on('secure', common.mustNotCall('renegotiation completed'));
30+
socket.on('error', common.mustCall((err) => {
31+
assert.strictEqual(err.code, 'ERR_TLS_RENEGOTIATION_DISABLED');
32+
client.destroy();
33+
socket.destroy();
34+
server.close();
35+
proxy.close();
36+
}));
37+
}));
38+
39+
const proxy = net.createServer(common.mustCall((socket) => {
40+
const upstream = net.connect(server.address().port);
41+
upstream.pipe(socket);
42+
socket.on('end', () => upstream.end());
43+
socket.on('close', () => upstream.destroy());
44+
const held = [];
45+
let pending = Buffer.alloc(0);
46+
socket.on('data', (data) => {
47+
if (!holding) {
48+
upstream.write(data);
49+
return;
50+
}
51+
held.push(data);
52+
pending = Buffer.concat([pending, data]);
53+
while (pending.length >= 5) {
54+
const length = pending.readUInt16BE(3);
55+
if (pending.length < 5 + length)
56+
break;
57+
if (pending[0] === 22) { // handshake
58+
holding = false;
59+
upstream.write(Buffer.concat(held));
60+
return;
61+
}
62+
pending = pending.subarray(5 + length);
63+
}
64+
});
65+
}));
66+
67+
server.listen(0, common.mustCall(() => {
68+
proxy.listen(0, common.mustCall(() => {
69+
client = tls.connect({
70+
port: proxy.address().port,
71+
ca: fixtures.readKey('ca1-cert.pem'),
72+
servername: 'agent1',
73+
maxVersion: 'TLSv1.2',
74+
}, common.mustCall(() => {
75+
holding = true;
76+
client.write(payload, common.mustCall(() => {
77+
assert(client.renegotiate({},
78+
common.mustNotCall('renegotiation completed')));
79+
}));
80+
}));
81+
}));
82+
}));

0 commit comments

Comments
 (0)