@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.42.1",
3
+ "version": "0.42.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14704,8 +14704,18 @@ function __sbTcpRelease(st) {
14704
14704
  __sbTcpOpen--;
14705
14705
  }
14706
14706
 
14707
- // One socket facade over a host connection. `st` is shared with the socket startTls() returns.
14708
- function __sbTcpFacade(st, mode) {
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 (st.id === 0) throw new TypeError("This WritableStream has been closed.");
14763
- const queued = Number(__sbWsOp("b", st.id, __sbToBytes(bytes), 0));
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
- __sbWsOp("S", st.id, sni, 0);
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
- if (c->outq_len) {
3816
- if (wq_len + c->outq_len > wq_cap) {
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 + c->outq_len) cap *= 2;
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 + c->outq_len <= wq_cap) { memcpy(wq + wq_len, c->outq, c->outq_len); wq_len += c->outq_len; c->outq_len = 0; }
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
- if (wq_len) sb_wsio_pump(c, &io, buf, sizeof(buf), wq, &wq_len, 0, 0);
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
+ }