@sproutboat/runtime 0.42.1 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +49 -10
- package/src/transport-embedded.js +35 -6
- package/test/native-sockets-broker.ts +62 -0
- package/test/native-sockets-starttls.ts +404 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.42.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5ef3159: Security fix: on the embedded transport, a `connect()` socket opened with `secureTransport: "starttls"` could send application data in plaintext after `startTls()`. The I/O thread moved everything the app had queued, including writes issued after the upgrade call, into its write buffer and flushed it in the clear before switching the socket to TLS, so a write that raced the upgrade (about 7% of connections in a loopback test) left unencrypted. The host now records the queue position at the upgrade and only flushes the bytes queued before it in plaintext; everything after waits for the TLS engine, which only emits application data once the handshake is done. On the JS side, `startTls()` closes the plaintext socket's writable (its queued writes still go out, new writes are refused, as in workerd), and the new socket's writes are held until the upgrade is queued behind them. The I/O thread also reports a drained send queue right after flushing instead of after the next input, so a write past the high-water mark no longer waits on the peer. The broker tunnel, which already orders the upgrade among the data frames, no longer stays paused on the plain socket's drain when the upgrade happens under backpressure. Regression tests on both transports record the raw bytes at the origin and refuse anything but a TLS handshake record after the plaintext phase. No compatibility date: `connect()` ships from `2026-11-17`, and the old behaviour was never correct.
|
|
8
|
+
|
|
3
9
|
## 0.42.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -14704,8 +14704,18 @@ function __sbTcpRelease(st) {
|
|
|
14704
14704
|
__sbTcpOpen--;
|
|
14705
14705
|
}
|
|
14706
14706
|
|
|
14707
|
-
//
|
|
14708
|
-
function
|
|
14707
|
+
// Queues bytes for the host; a write past the high-water mark settles when the queue drains.
|
|
14708
|
+
function __sbTcpQueue(st, bytes) {
|
|
14709
|
+
if (st.id === 0) throw new TypeError("This WritableStream has been closed.");
|
|
14710
|
+
const queued = Number(__sbWsOp("b", st.id, __sbToBytes(bytes), 0));
|
|
14711
|
+
if (queued > __SB_TCP_HWM) return new Promise((resolve) => (st.drain = resolve));
|
|
14712
|
+
return undefined;
|
|
14713
|
+
}
|
|
14714
|
+
|
|
14715
|
+
// One socket facade over a host connection. `st` is shared with the socket startTls() returns;
|
|
14716
|
+
// that socket gets `upgrade`, whose `pending` promise holds its writes back until the "S" op
|
|
14717
|
+
// is queued behind everything the plaintext socket had already accepted.
|
|
14718
|
+
function __sbTcpFacade(st, mode, upgrade) {
|
|
14709
14719
|
const closed = new Promise((resolve, reject) => {
|
|
14710
14720
|
st.closedResolve = resolve;
|
|
14711
14721
|
st.closedReject = reject;
|
|
@@ -14759,12 +14769,15 @@ function __sbTcpFacade(st, mode) {
|
|
|
14759
14769
|
},
|
|
14760
14770
|
write(chunk) {
|
|
14761
14771
|
const bytes = __sbTcpChunk(chunk);
|
|
14762
|
-
if (
|
|
14763
|
-
|
|
14764
|
-
if (queued > __SB_TCP_HWM) return new Promise((resolve) => (st.drain = resolve));
|
|
14765
|
-
return undefined;
|
|
14772
|
+
if (upgrade !== null && upgrade.pending !== null) return upgrade.pending.then(() => __sbTcpQueue(st, bytes));
|
|
14773
|
+
return __sbTcpQueue(st, bytes);
|
|
14766
14774
|
},
|
|
14767
14775
|
close() {
|
|
14776
|
+
if (st.upgrading) {
|
|
14777
|
+
// startTls() closed this stream: its queued writes are out, the write side stays open for TLS
|
|
14778
|
+
st.upgrading = false;
|
|
14779
|
+
return undefined;
|
|
14780
|
+
}
|
|
14768
14781
|
if (st.id !== 0) __sbWsOp("H", st.id, "", 0);
|
|
14769
14782
|
st.writeClosed = true;
|
|
14770
14783
|
return undefined;
|
|
@@ -14804,10 +14817,36 @@ function __sbTcpFacade(st, mode) {
|
|
|
14804
14817
|
st.upgradedOnce = true;
|
|
14805
14818
|
let sni = st.host;
|
|
14806
14819
|
if (options != null && options.expectedServerHostname != null) sni = String(options.expectedServerHostname);
|
|
14807
|
-
|
|
14820
|
+
// This socket's streams end here, as workerd detaches them. Its writable is closed first:
|
|
14821
|
+
// what a writer had already queued still goes out in the clear, then the "S" op marks the
|
|
14822
|
+
// queue position after which only TLS records leave the host; the new socket's writes wait
|
|
14823
|
+
// on that (upgrade.pending), so no write issued after startTls() can precede the handshake.
|
|
14824
|
+
const upgrade = { pending: null };
|
|
14825
|
+
upgrade.pending = new Promise((resolve) => {
|
|
14826
|
+
const request = () => {
|
|
14827
|
+
st.upgrading = false;
|
|
14828
|
+
if (st.id !== 0) __sbWsOp("S", st.id, sni, 0);
|
|
14829
|
+
upgrade.pending = null;
|
|
14830
|
+
resolve(undefined);
|
|
14831
|
+
};
|
|
14832
|
+
if (st.writeClosed) request();
|
|
14833
|
+
else {
|
|
14834
|
+
st.upgrading = true;
|
|
14835
|
+
writable.close().then(request, request);
|
|
14836
|
+
}
|
|
14837
|
+
});
|
|
14838
|
+
st.pullWake = null;
|
|
14839
|
+
const oldReadable = st.readableController;
|
|
14840
|
+
if (oldReadable !== null) {
|
|
14841
|
+
try {
|
|
14842
|
+
oldReadable.close();
|
|
14843
|
+
} catch {
|
|
14844
|
+
// already closed or errored
|
|
14845
|
+
}
|
|
14846
|
+
}
|
|
14808
14847
|
// the old socket ends once the upgrade is under way (workerd resolves its `closed`)
|
|
14809
14848
|
const oldResolve = st.closedResolve;
|
|
14810
|
-
const next = __sbTcpFacade(st, "on");
|
|
14849
|
+
const next = __sbTcpFacade(st, "on", upgrade);
|
|
14811
14850
|
setTimeout(() => oldResolve(undefined), 0);
|
|
14812
14851
|
return next;
|
|
14813
14852
|
},
|
|
@@ -14832,11 +14871,11 @@ function __sbConnect(address, options) {
|
|
|
14832
14871
|
const st = {
|
|
14833
14872
|
id: 0, host: target.host, port: target.port, inbox: [], eof: false, failed: null, settled: false,
|
|
14834
14873
|
allowHalfOpen, pullWake: null, drain: null, timer: undefined, opening: null, upgradedOnce: false,
|
|
14835
|
-
closedByUser: false, writableController: null, readableController: null, reason: "", writeClosed: false, writeEnd: false,
|
|
14874
|
+
closedByUser: false, writableController: null, readableController: null, reason: "", writeClosed: false, writeEnd: false, upgrading: false,
|
|
14836
14875
|
release: null, closedResolve: null, closedReject: null,
|
|
14837
14876
|
};
|
|
14838
14877
|
st.release = () => __sbTcpRelease(st);
|
|
14839
|
-
const socket = __sbTcpFacade(st, mode);
|
|
14878
|
+
const socket = __sbTcpFacade(st, mode, null);
|
|
14840
14879
|
if (target.port === 25) {
|
|
14841
14880
|
st.failed = new Error("proxy request failed, cannot connect to the specified address");
|
|
14842
14881
|
st.eof = true;
|
|
@@ -3325,6 +3325,7 @@ typedef struct sb_wsc {
|
|
|
3325
3325
|
int port, tls;
|
|
3326
3326
|
// Raw TCP (cloudflare:sockets connect()): no WebSocket framing, bytes both ways.
|
|
3327
3327
|
int raw, starttls_req, shutdown_req, want_drain;
|
|
3328
|
+
size_t starttls_at; // outq bytes queued before the "S" op: the last ones allowed out in the clear
|
|
3328
3329
|
size_t unsent, window;
|
|
3329
3330
|
char sni[256];
|
|
3330
3331
|
} sb_wsc;
|
|
@@ -3458,10 +3459,13 @@ static int sb_wsio_raw_write(sb_wsio* io, const unsigned char* buf, size_t len)
|
|
|
3458
3459
|
// connection ends (-1). out/outlen is application data to send first. With may_read false (the
|
|
3459
3460
|
// reader is far behind) it only sends, and waits for the wake pipe.
|
|
3460
3461
|
static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap, unsigned char* out, size_t* outlen, int may_read, int ms) {
|
|
3462
|
+
// Returns right after application bytes went out (0, as for a wake) instead of waiting for
|
|
3463
|
+
// input: the caller reports the drain then, which a write past the high-water mark waits on.
|
|
3461
3464
|
if (!io->tls) {
|
|
3462
3465
|
if (*outlen) {
|
|
3463
3466
|
if (sb_wsio_raw_write(io, out, *outlen) != 0) return -1;
|
|
3464
3467
|
*outlen = 0;
|
|
3468
|
+
return 0;
|
|
3465
3469
|
}
|
|
3466
3470
|
for (;;) {
|
|
3467
3471
|
if (may_read) {
|
|
@@ -3478,6 +3482,7 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
|
|
|
3478
3482
|
}
|
|
3479
3483
|
}
|
|
3480
3484
|
br_ssl_engine_context* eng = &io->sc->eng;
|
|
3485
|
+
int wrote = 0;
|
|
3481
3486
|
for (;;) {
|
|
3482
3487
|
unsigned st = br_ssl_engine_current_state(eng);
|
|
3483
3488
|
if (st & BR_SSL_CLOSED) return -1;
|
|
@@ -3501,9 +3506,10 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
|
|
|
3501
3506
|
br_ssl_engine_sendapp_ack(eng, n);
|
|
3502
3507
|
memmove(out, out + n, *outlen - n);
|
|
3503
3508
|
*outlen -= n;
|
|
3504
|
-
if (*outlen == 0) br_ssl_engine_flush(eng, 0);
|
|
3509
|
+
if (*outlen == 0) { br_ssl_engine_flush(eng, 0); wrote = 1; }
|
|
3505
3510
|
continue;
|
|
3506
3511
|
}
|
|
3512
|
+
if (wrote) return 0;
|
|
3507
3513
|
// Handshaking, or waiting for the peer: read records while the reader may; else only wake.
|
|
3508
3514
|
int reading = (st & BR_SSL_RECVREC) && (may_read || !(st & BR_SSL_SENDAPP));
|
|
3509
3515
|
if (reading) {
|
|
@@ -3812,15 +3818,30 @@ static void* sb_tcp_thread(void* arg) {
|
|
|
3812
3818
|
int st = c->starttls_req; c->starttls_req = 0;
|
|
3813
3819
|
int sh = c->shutdown_req;
|
|
3814
3820
|
char sni[256]; memcpy(sni, c->sni, sizeof(sni));
|
|
3815
|
-
|
|
3816
|
-
|
|
3821
|
+
// A pending startTls() splits the queue: only what the app queued before the "S" op may
|
|
3822
|
+
// leave in the clear. The rest stays here until the TLS engine owns the socket, so a write
|
|
3823
|
+
// that races the upgrade can only ever go out inside a TLS record.
|
|
3824
|
+
size_t take = c->outq_len;
|
|
3825
|
+
if (st && c->starttls_at < take) take = c->starttls_at;
|
|
3826
|
+
if (take) {
|
|
3827
|
+
if (wq_len + take > wq_cap) {
|
|
3817
3828
|
size_t cap = wq_cap ? wq_cap : 4096;
|
|
3818
|
-
while (cap < wq_len +
|
|
3829
|
+
while (cap < wq_len + take) cap *= 2;
|
|
3819
3830
|
unsigned char* grown = (unsigned char*)realloc(wq, cap);
|
|
3820
3831
|
if (grown) { wq = grown; wq_cap = cap; }
|
|
3821
3832
|
}
|
|
3822
|
-
if (wq_len +
|
|
3833
|
+
if (wq_len + take <= wq_cap) {
|
|
3834
|
+
memcpy(wq + wq_len, c->outq, take);
|
|
3835
|
+
wq_len += take;
|
|
3836
|
+
if (take < c->outq_len) memmove(c->outq, c->outq + take, c->outq_len - take);
|
|
3837
|
+
c->outq_len -= take;
|
|
3838
|
+
} else if (st) {
|
|
3839
|
+
// no room to move the plaintext part: retry the upgrade on the next pass
|
|
3840
|
+
c->starttls_req = 1;
|
|
3841
|
+
st = 0;
|
|
3842
|
+
}
|
|
3823
3843
|
}
|
|
3844
|
+
if (st) c->starttls_at = 0;
|
|
3824
3845
|
c->unsent = wq_len;
|
|
3825
3846
|
pthread_mutex_unlock(&c->lock);
|
|
3826
3847
|
if (rel) {
|
|
@@ -3828,7 +3849,14 @@ static void* sb_tcp_thread(void* arg) {
|
|
|
3828
3849
|
goto finish;
|
|
3829
3850
|
}
|
|
3830
3851
|
if (st && !io.tls) {
|
|
3831
|
-
|
|
3852
|
+
// Everything queued before startTls() is written in the clear here (the plain write path
|
|
3853
|
+
// blocks until the kernel took it all); the socket then belongs to the TLS engine, which
|
|
3854
|
+
// only emits application data once the handshake is done (BR_SSL_SENDAPP).
|
|
3855
|
+
if (wq_len && sb_wsio_pump(c, &io, buf, sizeof(buf), wq, &wq_len, 0, 0) < 0) {
|
|
3856
|
+
snprintf(why, sizeof(why), "connection closed before the TLS handshake");
|
|
3857
|
+
sb_wsc_push(c, 3, 0, why, strlen(why));
|
|
3858
|
+
goto finish;
|
|
3859
|
+
}
|
|
3832
3860
|
if (sb_wsio_start_tls(&io, sni[0] ? sni : c->host, why, sizeof(why)) != 0) { sb_wsc_push(c, 3, 0, why, strlen(why)); goto finish; }
|
|
3833
3861
|
tls_waiting = 1;
|
|
3834
3862
|
}
|
|
@@ -4023,6 +4051,7 @@ static long long sb_wsc_op(int op, unsigned id, const char* data, size_t len, si
|
|
|
4023
4051
|
pthread_mutex_lock(&c->lock);
|
|
4024
4052
|
snprintf(c->sni, sizeof(c->sni), "%.*s", (int)(len < sizeof(c->sni) - 1 ? len : sizeof(c->sni) - 1), data ? data : "");
|
|
4025
4053
|
c->starttls_req = 1;
|
|
4054
|
+
c->starttls_at = c->outq_len;
|
|
4026
4055
|
pthread_mutex_unlock(&c->lock);
|
|
4027
4056
|
sb_wsc_wake_thread(c);
|
|
4028
4057
|
return 0;
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
|
+
import { Duplex } from "node:stream";
|
|
6
7
|
import tls from "node:tls";
|
|
7
8
|
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
8
9
|
import { createBroker, listen } from "../../wire/src/broker";
|
|
@@ -82,6 +83,31 @@ export default {
|
|
|
82
83
|
await secure.close();
|
|
83
84
|
return Response.json({ greeting, go, echoed, upgraded: s.upgraded });
|
|
84
85
|
}
|
|
86
|
+
if (url.pathname === "/starttls-race") {
|
|
87
|
+
const s = connect("127.0.0.1:" + port, { secureTransport: "starttls" });
|
|
88
|
+
const w = s.writable.getWriter();
|
|
89
|
+
const r = s.readable.getReader();
|
|
90
|
+
await withTimeout(readN(r, 11), 5000, "greeting");
|
|
91
|
+
await w.write(enc.encode("STARTTLS\\r\\n"));
|
|
92
|
+
await withTimeout(readN(r, 4), 5000, "go");
|
|
93
|
+
r.releaseLock();
|
|
94
|
+
w.releaseLock();
|
|
95
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
96
|
+
const w2 = secure.writable.getWriter();
|
|
97
|
+
const r2 = secure.readable.getReader();
|
|
98
|
+
w2.write(enc.encode("secret-1"));
|
|
99
|
+
w2.write(enc.encode("secret-2"));
|
|
100
|
+
w2.write(enc.encode("secret-3"));
|
|
101
|
+
await withTimeout(secure.opened, 5000, "tls");
|
|
102
|
+
let echoed = "";
|
|
103
|
+
while (!echoed.includes("secret-3")) {
|
|
104
|
+
const chunk = await withTimeout(r2.read(), 5000, "tls echo");
|
|
105
|
+
if (chunk.done) break;
|
|
106
|
+
echoed += dec.decode(chunk.value);
|
|
107
|
+
}
|
|
108
|
+
await secure.close();
|
|
109
|
+
return Response.json({ echoed });
|
|
110
|
+
}
|
|
85
111
|
if (url.pathname === "/tls") {
|
|
86
112
|
const host = q.get("host") || "localhost";
|
|
87
113
|
const s = connect({ hostname: host, port }, { secureTransport: "on" });
|
|
@@ -271,6 +297,34 @@ const starttlsPort = listenOn(
|
|
|
271
297
|
});
|
|
272
298
|
}),
|
|
273
299
|
);
|
|
300
|
+
// Every raw byte the recording origin receives after it sent "go", TLS records included.
|
|
301
|
+
const rawAfterGo: Buffer[] = [];
|
|
302
|
+
const starttlsRecordingPort = listenOn(
|
|
303
|
+
net.createServer((s) => {
|
|
304
|
+
// a TLSSocket over a net.Socket reads the handle directly, so the TLS side runs over a tap
|
|
305
|
+
// that replays the bytes this listener saw
|
|
306
|
+
const tap = new Duplex({
|
|
307
|
+
read() {},
|
|
308
|
+
write(chunk, _enc, done) {
|
|
309
|
+
s.write(chunk, done);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
let afterGo = false;
|
|
313
|
+
s.prependListener("data", (c: Buffer) => {
|
|
314
|
+
if (!afterGo) return;
|
|
315
|
+
rawAfterGo.push(Buffer.from(c));
|
|
316
|
+
tap.push(c);
|
|
317
|
+
});
|
|
318
|
+
s.write("220 hello\r\n");
|
|
319
|
+
s.once("data", () => {
|
|
320
|
+
afterGo = true;
|
|
321
|
+
s.write("go\r\n");
|
|
322
|
+
const secure = new tls.TLSSocket(tap, { isServer: true, key, cert });
|
|
323
|
+
secure.on("data", (c) => secure.write(Buffer.concat([Buffer.from("tls:"), c])));
|
|
324
|
+
secure.on("error", () => {});
|
|
325
|
+
});
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
274
328
|
const tlsPort = listenOn(
|
|
275
329
|
tls.createServer({ key, cert }, (s) => {
|
|
276
330
|
s.on("data", (c) => s.write(c));
|
|
@@ -386,6 +440,14 @@ try {
|
|
|
386
440
|
});
|
|
387
441
|
console.log("startTls upgrades in the broker, verified against the expected host name");
|
|
388
442
|
|
|
443
|
+
const race = await get(`/starttls-race?port=${starttlsRecordingPort}`);
|
|
444
|
+
const echoedRace = String(race.echoed);
|
|
445
|
+
for (const part of ["secret-1", "secret-2", "secret-3"]) assert.ok(echoedRace.includes(part), `echoed ${part}`);
|
|
446
|
+
const rawBytes = Buffer.concat(rawAfterGo);
|
|
447
|
+
assert.equal(rawBytes[0], 0x16, "the first bytes after the upgrade are a TLS handshake record");
|
|
448
|
+
assert.equal(rawBytes.includes("secret"), false, "no application bytes reached the origin in plaintext");
|
|
449
|
+
console.log("startTls: writes issued before the handshake finished only reach the origin inside TLS");
|
|
450
|
+
|
|
389
451
|
assert.deepEqual(await get(`/tls?port=${tlsPort}`), { got: "secret" });
|
|
390
452
|
const wrongName = await get(`/tls?port=${tlsPort}&host=127.0.0.1`);
|
|
391
453
|
assert.match(String(wrongName.error), /proxy request failed, cannot connect/);
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Duplex } from "node:stream";
|
|
7
|
+
import tls from "node:tls";
|
|
8
|
+
|
|
9
|
+
// cloudflare:sockets connect() on the embedded transport (standalone sprouts, sproutd): no
|
|
10
|
+
// application byte written after startTls(), or before `opened` on a secureTransport "on"
|
|
11
|
+
// socket, may ever reach the wire in plaintext. The origins here record every raw byte they
|
|
12
|
+
// receive and refuse anything but a TLS handshake record where the handshake must begin.
|
|
13
|
+
//
|
|
14
|
+
// The deterministic cases hold the sprout's I/O thread in its plaintext flush (a 16 MiB write
|
|
15
|
+
// the origin reads only after a pause) while the handler calls startTls() and writes to the
|
|
16
|
+
// new socket; the I/O thread then sees the upgrade and the new writes in one pass, which is
|
|
17
|
+
// the race the TLS spike observed on 14 of 200 connections. `/burst` repeats the plain
|
|
18
|
+
// immediate-write case many times, as the spike did.
|
|
19
|
+
//
|
|
20
|
+
// Needs SB_CLI, a sproutboat-cli checkout linked to this one (it builds the sprout).
|
|
21
|
+
const BLOB = 16 << 20;
|
|
22
|
+
const handler = `
|
|
23
|
+
import { connect } from "cloudflare:sockets";
|
|
24
|
+
const enc = new TextEncoder();
|
|
25
|
+
const dec = new TextDecoder();
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT " + what)), ms))]);
|
|
28
|
+
async function readN(reader, n) {
|
|
29
|
+
let out = "";
|
|
30
|
+
while (out.length < n) {
|
|
31
|
+
const r = await withTimeout(reader.read(), 5000, "read");
|
|
32
|
+
if (r.done) return out;
|
|
33
|
+
out += dec.decode(r.value);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
async function readUntil(reader, needle) {
|
|
38
|
+
let out = "";
|
|
39
|
+
while (!out.includes(needle)) {
|
|
40
|
+
const r = await withTimeout(reader.read(), 5000, "read " + needle);
|
|
41
|
+
if (r.done) return out;
|
|
42
|
+
out += dec.decode(r.value);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
let blob = null;
|
|
47
|
+
function theBlob() {
|
|
48
|
+
if (blob === null) {
|
|
49
|
+
blob = new Uint8Array(${BLOB});
|
|
50
|
+
for (let i = 0; i < blob.length; i++) blob[i] = 120;
|
|
51
|
+
}
|
|
52
|
+
return blob;
|
|
53
|
+
}
|
|
54
|
+
// The plaintext phase every startTls route shares: greeting, STARTTLS, go.
|
|
55
|
+
async function plainPhase(port) {
|
|
56
|
+
const s = connect({ hostname: "localhost", port }, { secureTransport: "starttls" });
|
|
57
|
+
const w = s.writable.getWriter();
|
|
58
|
+
const r = s.readable.getReader();
|
|
59
|
+
await readN(r, 11);
|
|
60
|
+
await w.write(enc.encode("STARTTLS\\r\\n"));
|
|
61
|
+
await readN(r, 4);
|
|
62
|
+
return { s, w, r };
|
|
63
|
+
}
|
|
64
|
+
export default {
|
|
65
|
+
async fetch(request) {
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
const q = url.searchParams;
|
|
68
|
+
const port = Number(q.get("port"));
|
|
69
|
+
try {
|
|
70
|
+
if (url.pathname === "/ping") return new Response("pong");
|
|
71
|
+
if (url.pathname === "/immediate" || url.pathname === "/queued") {
|
|
72
|
+
const { s, w, r } = await plainPhase(port);
|
|
73
|
+
// not awaited: the origin reads it only after a pause, so the host is still flushing it
|
|
74
|
+
// in the clear while everything below runs (the yield lets the host start the flush)
|
|
75
|
+
w.write(theBlob());
|
|
76
|
+
await sleep(100);
|
|
77
|
+
w.releaseLock();
|
|
78
|
+
r.releaseLock();
|
|
79
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
80
|
+
const w2 = secure.writable.getWriter();
|
|
81
|
+
const r2 = secure.readable.getReader();
|
|
82
|
+
const n = url.pathname === "/queued" ? 3 : 1;
|
|
83
|
+
for (let i = 1; i <= n; i++) w2.write(enc.encode("secret-" + i));
|
|
84
|
+
const late = s.writable.getWriter().write(enc.encode("late-plaintext")).then(() => "accepted", (e) => e.name);
|
|
85
|
+
await withTimeout(secure.opened, 15000, "tls");
|
|
86
|
+
const echoed = await readUntil(r2, "secret-" + n);
|
|
87
|
+
await secure.close();
|
|
88
|
+
const oldWrite = await withTimeout(late, 1000, "late write").catch(() => "pending");
|
|
89
|
+
return Response.json({ echoed, oldWrite });
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname === "/inflight") {
|
|
92
|
+
const { s, w, r } = await plainPhase(port);
|
|
93
|
+
w.releaseLock();
|
|
94
|
+
r.releaseLock();
|
|
95
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
96
|
+
// the origin holds the ClientHello for a while, so the handshake is still in flight here
|
|
97
|
+
await sleep(100);
|
|
98
|
+
const w2 = secure.writable.getWriter();
|
|
99
|
+
const r2 = secure.readable.getReader();
|
|
100
|
+
w2.write(enc.encode("secret-1"));
|
|
101
|
+
await withTimeout(secure.opened, 15000, "tls");
|
|
102
|
+
const echoed = await readUntil(r2, "secret-1");
|
|
103
|
+
await secure.close();
|
|
104
|
+
return Response.json({ echoed });
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname === "/on") {
|
|
107
|
+
const s = connect({ hostname: "localhost", port }, { secureTransport: "on" });
|
|
108
|
+
const w = s.writable.getWriter();
|
|
109
|
+
const r = s.readable.getReader();
|
|
110
|
+
// written before \`opened\`: it may only leave inside a TLS record
|
|
111
|
+
w.write(enc.encode("secret-1"));
|
|
112
|
+
await withTimeout(s.opened, 15000, "opened");
|
|
113
|
+
const echoed = await readUntil(r, "secret-1");
|
|
114
|
+
await s.close();
|
|
115
|
+
return Response.json({ echoed });
|
|
116
|
+
}
|
|
117
|
+
if (url.pathname === "/burst") {
|
|
118
|
+
const n = Number(q.get("n"));
|
|
119
|
+
let ok = 0;
|
|
120
|
+
const errors = [];
|
|
121
|
+
for (let i = 0; i < n; i++) {
|
|
122
|
+
try {
|
|
123
|
+
const { s, w, r } = await plainPhase(port);
|
|
124
|
+
w.releaseLock();
|
|
125
|
+
r.releaseLock();
|
|
126
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
127
|
+
const w2 = secure.writable.getWriter();
|
|
128
|
+
const r2 = secure.readable.getReader();
|
|
129
|
+
w2.write(enc.encode("secret-" + i + "\\n"));
|
|
130
|
+
const echoed = await readUntil(r2, "secret-" + i + "\\n");
|
|
131
|
+
await secure.close();
|
|
132
|
+
if (echoed.includes("secret-" + i)) ok++;
|
|
133
|
+
else errors.push("bad echo " + i);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
errors.push(String(e && e.message));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return Response.json({ ok, errors: errors.slice(0, 3) });
|
|
139
|
+
}
|
|
140
|
+
return new Response("no route", { status: 404 });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return Response.json({ error: String(e && e.message) }, { status: 500 });
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const cli = process.env.SB_CLI;
|
|
149
|
+
if (!cli) {
|
|
150
|
+
console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprout)");
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function freePort(): number {
|
|
155
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
156
|
+
const port = listener.port;
|
|
157
|
+
listener.stop(true);
|
|
158
|
+
return port;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const workdir = mkdtempSync(join(tmpdir(), "sb-native-sockets-starttls-"));
|
|
162
|
+
const servers: net.Server[] = [];
|
|
163
|
+
|
|
164
|
+
// A self-signed certificate for localhost; the sprout is told to trust it through SB_CA_BUNDLE.
|
|
165
|
+
const made = Bun.spawnSync([
|
|
166
|
+
"openssl",
|
|
167
|
+
"req",
|
|
168
|
+
"-x509",
|
|
169
|
+
"-newkey",
|
|
170
|
+
"rsa:2048",
|
|
171
|
+
"-nodes",
|
|
172
|
+
"-keyout",
|
|
173
|
+
join(workdir, "key.pem"),
|
|
174
|
+
"-out",
|
|
175
|
+
join(workdir, "cert.pem"),
|
|
176
|
+
"-days",
|
|
177
|
+
"2",
|
|
178
|
+
"-subj",
|
|
179
|
+
"/CN=localhost",
|
|
180
|
+
"-addext",
|
|
181
|
+
"subjectAltName=DNS:localhost",
|
|
182
|
+
]);
|
|
183
|
+
assert.equal(made.exitCode, 0, `openssl failed: ${made.stderr}`);
|
|
184
|
+
const cert = readFileSync(join(workdir, "cert.pem"), "utf8");
|
|
185
|
+
const key = readFileSync(join(workdir, "key.pem"), "utf8");
|
|
186
|
+
|
|
187
|
+
interface OriginOptions {
|
|
188
|
+
/** Plaintext bytes the client still owes after "go", swallowed before the TLS handshake. */
|
|
189
|
+
readonly plainBytes?: number;
|
|
190
|
+
/** Stop reading for this long after "go", so the client's send side fills up. */
|
|
191
|
+
readonly stallMs?: number;
|
|
192
|
+
/** Hold the first TLS record this long before the server's TLS side sees it. */
|
|
193
|
+
readonly holdHelloMs?: number;
|
|
194
|
+
/** TLS from the first byte (secureTransport "on") instead of a STARTTLS exchange. */
|
|
195
|
+
readonly tlsFromStart?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A recording origin: `records[i]` holds every raw byte connection i sent after the plaintext phase. */
|
|
199
|
+
function origin(options: OriginOptions) {
|
|
200
|
+
const records: Buffer[][] = [];
|
|
201
|
+
const server = net.createServer((s) => {
|
|
202
|
+
const raw: Buffer[] = [];
|
|
203
|
+
records.push(raw);
|
|
204
|
+
s.on("error", () => {});
|
|
205
|
+
// A TLSSocket over a net.Socket reads the handle directly, so the TLS side runs over a tap
|
|
206
|
+
// fed by the data listener that records the bytes.
|
|
207
|
+
const tap = new Duplex({
|
|
208
|
+
read() {},
|
|
209
|
+
write(chunk, _enc, done) {
|
|
210
|
+
s.write(chunk, done);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
let plainLeft = options.plainBytes ?? 0;
|
|
214
|
+
let handshakeSeen = false;
|
|
215
|
+
const held: Buffer[] = [];
|
|
216
|
+
let holding = options.holdHelloMs !== undefined;
|
|
217
|
+
const feed = (c: Buffer) => {
|
|
218
|
+
if (!handshakeSeen) {
|
|
219
|
+
handshakeSeen = true;
|
|
220
|
+
// anything but a TLS handshake record here is a leak: end the connection at once
|
|
221
|
+
if (c[0] !== 0x16) {
|
|
222
|
+
s.destroy();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (holding) {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
holding = false;
|
|
228
|
+
for (const h of held.splice(0)) tap.push(h);
|
|
229
|
+
}, options.holdHelloMs);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (holding) held.push(c);
|
|
233
|
+
else tap.push(c);
|
|
234
|
+
};
|
|
235
|
+
const startTls = () => {
|
|
236
|
+
const secure = new tls.TLSSocket(tap, { isServer: true, key, cert });
|
|
237
|
+
secure.on("data", (c) => secure.write(Buffer.concat([Buffer.from("tls:"), c])));
|
|
238
|
+
secure.on("error", () => {});
|
|
239
|
+
};
|
|
240
|
+
let afterGo = options.tlsFromStart === true;
|
|
241
|
+
s.on("data", (c: Buffer) => {
|
|
242
|
+
if (!afterGo) return;
|
|
243
|
+
raw.push(Buffer.from(c));
|
|
244
|
+
let rest = c;
|
|
245
|
+
if (plainLeft > 0) {
|
|
246
|
+
const k = Math.min(plainLeft, rest.length);
|
|
247
|
+
plainLeft -= k;
|
|
248
|
+
rest = rest.subarray(k);
|
|
249
|
+
}
|
|
250
|
+
if (rest.length > 0) feed(rest);
|
|
251
|
+
});
|
|
252
|
+
if (afterGo) {
|
|
253
|
+
startTls();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
s.write("220 hello\r\n");
|
|
257
|
+
s.once("data", () => {
|
|
258
|
+
afterGo = true;
|
|
259
|
+
s.write("go\r\n");
|
|
260
|
+
if (options.stallMs !== undefined) {
|
|
261
|
+
s.pause();
|
|
262
|
+
setTimeout(() => s.resume(), options.stallMs);
|
|
263
|
+
}
|
|
264
|
+
startTls();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
server.listen(0, "127.0.0.1");
|
|
268
|
+
servers.push(server);
|
|
269
|
+
// SAFETY: a listening TCP server's address() is an AddressInfo.
|
|
270
|
+
return { port: (server.address() as net.AddressInfo).port, records };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const stalled = origin({ plainBytes: BLOB, stallMs: 500 });
|
|
274
|
+
const stalledQueued = origin({ plainBytes: BLOB, stallMs: 500 });
|
|
275
|
+
const slowHello = origin({ holdHelloMs: 300 });
|
|
276
|
+
const burst = origin({});
|
|
277
|
+
const tlsOn = origin({ tlsFromStart: true });
|
|
278
|
+
|
|
279
|
+
const plaintextOnly = (raw: Buffer, plainBytes: number, label: string) => {
|
|
280
|
+
assert.ok(
|
|
281
|
+
raw.length > plainBytes,
|
|
282
|
+
`${label}: the origin saw ${raw.length} bytes, expected the handshake after ${plainBytes}`,
|
|
283
|
+
);
|
|
284
|
+
if (plainBytes > 0)
|
|
285
|
+
assert.ok(
|
|
286
|
+
raw.subarray(0, plainBytes).equals(Buffer.alloc(plainBytes, 0x78)),
|
|
287
|
+
`${label}: the plaintext part is intact`,
|
|
288
|
+
);
|
|
289
|
+
assert.equal(raw[plainBytes], 0x16, `${label}: a TLS handshake record follows the plaintext phase`);
|
|
290
|
+
assert.equal(raw.includes("secret"), false, `${label}: no application bytes reached the origin in plaintext`);
|
|
291
|
+
assert.equal(raw.includes("late-plaintext"), false, `${label}: nothing written to the old socket reached the wire`);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
let sprout: ReturnType<typeof Bun.spawn> | undefined;
|
|
295
|
+
try {
|
|
296
|
+
const slug = "starttls-race";
|
|
297
|
+
const project = join(workdir, slug);
|
|
298
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
299
|
+
writeFileSync(join(project, "src/index.js"), handler);
|
|
300
|
+
writeFileSync(
|
|
301
|
+
join(project, "sproutboat.jsonc"),
|
|
302
|
+
JSON.stringify({ name: slug, main: "src/index.js", compatibility_date: "2026-11-17" }),
|
|
303
|
+
);
|
|
304
|
+
const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
|
|
305
|
+
cwd: project,
|
|
306
|
+
stdout: "inherit",
|
|
307
|
+
stderr: "inherit",
|
|
308
|
+
});
|
|
309
|
+
assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
|
|
310
|
+
const port = freePort();
|
|
311
|
+
sprout = Bun.spawn([join(project, "dist", slug)], {
|
|
312
|
+
cwd: project,
|
|
313
|
+
env: {
|
|
314
|
+
...process.env,
|
|
315
|
+
PORT: String(port),
|
|
316
|
+
SB_EGRESS_ALLOW: "127.0.0.1",
|
|
317
|
+
SB_DATA_DIR: join(project, "data"),
|
|
318
|
+
SB_CA_BUNDLE: join(workdir, "cert.pem"),
|
|
319
|
+
},
|
|
320
|
+
stdout: "ignore",
|
|
321
|
+
stderr: "inherit",
|
|
322
|
+
});
|
|
323
|
+
const base = `http://127.0.0.1:${port}`;
|
|
324
|
+
let ready = false;
|
|
325
|
+
for (let attempt = 0; attempt < 100 && !ready; attempt++) {
|
|
326
|
+
try {
|
|
327
|
+
ready = (await (await fetch(`${base}/ping`, { signal: AbortSignal.timeout(500) })).text()) === "pong";
|
|
328
|
+
} catch {
|
|
329
|
+
if (sprout.exitCode !== null) break;
|
|
330
|
+
await Bun.sleep(100);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
assert.ok(ready, "the sprout did not start");
|
|
334
|
+
// A leak makes the origin drop the connection, so a route may hang on the unfixed runtime:
|
|
335
|
+
// report it as an error and let the recorded bytes speak first.
|
|
336
|
+
const get = async (path: string) => {
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(30_000) });
|
|
339
|
+
const text = await res.text();
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(text);
|
|
342
|
+
} catch {
|
|
343
|
+
return text;
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { error: `no answer from the sprout: ${e instanceof Error ? e.message : String(e)}` };
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const immediate = await get(`/immediate?port=${stalled.port}`);
|
|
351
|
+
assert.equal(stalled.records.length, 1);
|
|
352
|
+
plaintextOnly(Buffer.concat(stalled.records[0]), BLOB, "immediate");
|
|
353
|
+
assert.equal(immediate.error, undefined, `immediate: ${JSON.stringify(immediate)}`);
|
|
354
|
+
assert.ok(String(immediate.echoed).includes("secret-1"), "immediate: the write was echoed over TLS");
|
|
355
|
+
assert.equal(
|
|
356
|
+
immediate.oldWrite,
|
|
357
|
+
"TypeError",
|
|
358
|
+
"immediate: a write on the plaintext socket after startTls() is refused",
|
|
359
|
+
);
|
|
360
|
+
console.log("a write right after startTls(), while the plaintext flush is still running, only leaves inside TLS");
|
|
361
|
+
|
|
362
|
+
const queued = await get(`/queued?port=${stalledQueued.port}`);
|
|
363
|
+
assert.equal(stalledQueued.records.length, 1);
|
|
364
|
+
plaintextOnly(Buffer.concat(stalledQueued.records[0]), BLOB, "queued");
|
|
365
|
+
assert.equal(queued.error, undefined, `queued: ${JSON.stringify(queued)}`);
|
|
366
|
+
for (const part of ["secret-1", "secret-2", "secret-3"])
|
|
367
|
+
assert.ok(String(queued.echoed).includes(part), `queued: ${part} echoed`);
|
|
368
|
+
assert.equal(queued.oldWrite, "TypeError");
|
|
369
|
+
console.log("several writes queued behind startTls() all wait for the handshake");
|
|
370
|
+
|
|
371
|
+
const inflight = await get(`/inflight?port=${slowHello.port}`);
|
|
372
|
+
assert.equal(slowHello.records.length, 1);
|
|
373
|
+
plaintextOnly(Buffer.concat(slowHello.records[0]), 0, "inflight");
|
|
374
|
+
assert.equal(inflight.error, undefined, `inflight: ${JSON.stringify(inflight)}`);
|
|
375
|
+
assert.ok(String(inflight.echoed).includes("secret-1"));
|
|
376
|
+
console.log("a write while the handshake is in flight is held until it completes");
|
|
377
|
+
|
|
378
|
+
const on = await get(`/on?port=${tlsOn.port}`);
|
|
379
|
+
assert.equal(tlsOn.records.length, 1);
|
|
380
|
+
plaintextOnly(Buffer.concat(tlsOn.records[0]), 0, "on");
|
|
381
|
+
assert.equal(on.error, undefined, `on: ${JSON.stringify(on)}`);
|
|
382
|
+
assert.ok(String(on.echoed).includes("secret-1"));
|
|
383
|
+
console.log("secureTransport on: a write before `opened` only leaves inside TLS");
|
|
384
|
+
|
|
385
|
+
const n = 40;
|
|
386
|
+
const many = await get(`/burst?port=${burst.port}&n=${n}`);
|
|
387
|
+
let leaked = 0;
|
|
388
|
+
for (const rec of burst.records) {
|
|
389
|
+
const raw = Buffer.concat(rec);
|
|
390
|
+
if (raw.length === 0 || raw[0] !== 0x16 || raw.includes("secret")) leaked++;
|
|
391
|
+
}
|
|
392
|
+
assert.equal(
|
|
393
|
+
leaked,
|
|
394
|
+
0,
|
|
395
|
+
`burst: ${leaked} of ${burst.records.length} connections sent application bytes in plaintext`,
|
|
396
|
+
);
|
|
397
|
+
assert.equal(many.error, undefined, `burst: ${JSON.stringify(many)}`);
|
|
398
|
+
assert.deepEqual(many, { ok: n, errors: [] });
|
|
399
|
+
console.log(`${n} immediate writes after startTls() in a row, none in plaintext`);
|
|
400
|
+
} finally {
|
|
401
|
+
sprout?.kill();
|
|
402
|
+
for (const server of servers) server.close();
|
|
403
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
404
|
+
}
|