@sproutboat/runtime 0.42.0 → 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,18 @@
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
+
9
+ ## 0.42.1
10
+
11
+ ### Patch Changes
12
+
13
+ - dbba349: fetch() and connect() now also refuse reserved space that carries no public service: AS112 and the old IPv4 anycast blocks (`192.31.196.0/24`, `192.52.193.0/24`, `192.88.99.0/24`, `192.175.48.0/24`), SIIT (`::ffff:0:0:0/96`), IETF protocol assignments including Teredo (`2001::/23`), `3fff::/20` and SRv6 (`5f00::/16`). Applies to the broker, the egress service and the standalone client alike; `SB_EGRESS_ALLOW` still overrides.
14
+ - cf519dd: Outbound TCP sockets now set `TCP_NODELAY`. Without it, Nagle's algorithm and the peer's delayed ACK stalled every fresh HTTPS `fetch()` by about 44 ms on Linux (each small TLS handshake flight waited for an ACK). This covers `fetch()`, outbound WebSockets and `connect()` from `cloudflare:sockets` on the embedded transport (standalone binaries, including sockets handed over by `SB_EGRESS_FD`), and the egress tunnel's connections to origins in `wire`. No compatibility date: only latency changes, no result, header or error does.
15
+
3
16
  ## 0.42.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.42.0",
3
+ "version": "0.42.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,13 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import {
6
- blockedRange,
7
- egressAllowList,
8
- egressRefusal,
9
- parseAddress,
10
- spliceEgressTable,
11
- } from "./egress";
5
+ import { blockedRange, egressAllowList, egressRefusal, parseAddress, spliceEgressTable } from "./egress";
12
6
  import { validateHttpSyncSource } from "./source";
13
7
  import { wrapNativeFetchHandler } from "./wrap";
14
8
 
@@ -41,6 +35,17 @@ const BLOCKED = [
41
35
  "fd12::1",
42
36
  "fe80::1",
43
37
  "ff02::1",
38
+ "192.31.196.1",
39
+ "192.52.193.1",
40
+ "192.88.99.1",
41
+ "192.175.48.1",
42
+ "::ffff:0:7f00:1",
43
+ "2001::1",
44
+ "2001:0:4136:e378:8000:63bf:3fff:fdd2",
45
+ "2001:1ff::1",
46
+ "3fff::1",
47
+ "3fff:fff:ffff::1",
48
+ "5f00::1",
44
49
  ];
45
50
  const PUBLIC = [
46
51
  "1.1.1.1",
@@ -54,23 +59,25 @@ const PUBLIC = [
54
59
  "2002:808:808::1",
55
60
  "2001:4860:4860::8888",
56
61
  "2606:4700::1111",
62
+ "192.31.195.255",
63
+ "192.88.100.1",
64
+ "2001:200::1",
65
+ "4000::1",
66
+ "3ffe::1",
67
+ "5f01::1",
57
68
  ];
58
69
  const ALLOW = "8.8.4.4, 10.9.9.9";
59
70
 
60
71
  test("private and reserved addresses are blocked, public ones are not", () => {
61
- for (const address of BLOCKED)
62
- expect([address, blockedRange(address)]).not.toEqual([address, null]);
63
- for (const address of PUBLIC)
64
- expect([address, blockedRange(address)]).toEqual([address, null]);
72
+ for (const address of BLOCKED) expect([address, blockedRange(address)]).not.toEqual([address, null]);
73
+ for (const address of PUBLIC) expect([address, blockedRange(address)]).toEqual([address, null]);
65
74
  expect(blockedRange("::ffff:127.0.0.1")).toBe("127.0.0.0/8");
66
75
  expect(blockedRange("64:ff9b::a00:1")).toBe("10.0.0.0/8");
67
76
  });
68
77
 
69
78
  test("the operator allow list lets an exact address or everything through", () => {
70
79
  expect(egressRefusal("db", "10.9.9.9", egressAllowList(ALLOW))).toBeNull();
71
- expect(egressRefusal("db", "10.9.9.8", egressAllowList(ALLOW))).toContain(
72
- "SB_EGRESS_ALLOW=10.9.9.8",
73
- );
80
+ expect(egressRefusal("db", "10.9.9.8", egressAllowList(ALLOW))).toContain("SB_EGRESS_ALLOW=10.9.9.8");
74
81
  expect(egressRefusal("db", "127.0.0.1", ["*"])).toBeNull();
75
82
  expect(egressRefusal("x", "169.254.169.254", [])).toBe(
76
83
  "fetch() refused: x resolves to 169.254.169.254, a private or reserved address (169.254.0.0/16). An operator can allow it with SB_EGRESS_ALLOW=169.254.169.254",
@@ -78,15 +85,7 @@ test("the operator allow list lets an exact address or everything through", () =
78
85
  });
79
86
 
80
87
  test("parseAddress rejects what is not an address", () => {
81
- for (const bad of [
82
- "",
83
- "1.2.3",
84
- "256.1.1.1",
85
- "example.com",
86
- "1::2::3",
87
- "1:2:3:4:5:6:7:8:9",
88
- "12345::",
89
- ])
88
+ for (const bad of ["", "1.2.3", "256.1.1.1", "example.com", "1::2::3", "1:2:3:4:5:6:7:8:9", "12345::"])
90
89
  expect([bad, parseAddress(bad)]).toEqual([bad, null]);
91
90
  });
92
91
 
@@ -99,10 +98,7 @@ test("the C table in transport-embedded.js is generated from this one (bun run g
99
98
 
100
99
  test("the C check gives the same answer and message as the TS one", () => {
101
100
  const source = readFileSync(transportFile, "utf8");
102
- const block = source.slice(
103
- source.indexOf("// EGRESS:BEGIN"),
104
- source.indexOf("// EGRESS:END"),
105
- );
101
+ const block = source.slice(source.indexOf("// EGRESS:BEGIN"), source.indexOf("// EGRESS:END"));
106
102
  const dir = mkdtempSync(join(tmpdir(), "sb-egress-"));
107
103
  try {
108
104
  writeFileSync(
@@ -130,23 +126,14 @@ int main(int argc, char** argv) {
130
126
  }
131
127
  `,
132
128
  );
133
- const cc = Bun.spawnSync([
134
- "cc",
135
- "-Wall",
136
- "-Werror",
137
- "-o",
138
- join(dir, "check"),
139
- join(dir, "check.c"),
140
- ]);
129
+ const cc = Bun.spawnSync(["cc", "-Wall", "-Werror", "-o", join(dir, "check"), join(dir, "check.c")]);
141
130
  expect(cc.stderr.toString()).toBe("");
142
131
  const addresses = [...BLOCKED, ...PUBLIC, "10.9.9.9"];
143
132
  const run = Bun.spawnSync([join(dir, "check"), ...addresses], {
144
133
  env: { ...process.env, SB_EGRESS_ALLOW: ALLOW },
145
134
  });
146
135
  const fromC = run.stdout.toString().trimEnd().split("\n");
147
- const fromTs = addresses.map(
148
- (a) => egressRefusal("h", a, egressAllowList(ALLOW)) ?? "ok",
149
- );
136
+ const fromTs = addresses.map((a) => egressRefusal("h", a, egressAllowList(ALLOW)) ?? "ok");
150
137
  expect(fromC).toEqual(fromTs);
151
138
  } finally {
152
139
  rmSync(dir, { recursive: true, force: true });
@@ -156,21 +143,13 @@ int main(int argc, char** argv) {
156
143
  test("#174: fetch() needs no config, so every sprout gets it", () => {
157
144
  const handler = `export default { async fetch() { return await fetch("https://api.example.com/"); } };`;
158
145
  expect(validateHttpSyncSource(handler)).toEqual({ ok: true });
159
- const prelude = readFileSync(
160
- new URL("./native-fetch-prelude.js", import.meta.url),
161
- "utf8",
162
- );
163
- expect(wrapNativeFetchHandler(handler, prelude)).toContain(
164
- "__sbInstallFetch();",
165
- );
146
+ const prelude = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
147
+ expect(wrapNativeFetchHandler(handler, prelude)).toContain("__sbInstallFetch();");
166
148
  });
167
149
 
168
150
  test("SB_EGRESS_FD: the host connects, the sprout gets the socket", () => {
169
151
  const source = readFileSync(transportFile, "utf8");
170
- const block = source.slice(
171
- source.indexOf("// EGRESS-FD:BEGIN"),
172
- source.indexOf("// EGRESS-FD:END"),
173
- );
152
+ const block = source.slice(source.indexOf("// EGRESS-FD:BEGIN"), source.indexOf("// EGRESS-FD:END"));
174
153
  const dir = mkdtempSync(join(tmpdir(), "sb-egress-fd-"));
175
154
  try {
176
155
  // A fake host on one end of a socketpair: "up" gets a socket whose peer says
@@ -184,6 +163,8 @@ test("SB_EGRESS_FD: the host connects, the sprout gets the socket", () => {
184
163
  #include <string.h>
185
164
  #include <unistd.h>
186
165
  #include <sys/socket.h>
166
+ #include <netinet/in.h>
167
+ #include <netinet/tcp.h>
187
168
  #include <sys/time.h>
188
169
  #include <sys/wait.h>
189
170
  ${block}
@@ -261,18 +242,76 @@ int main(void) {
261
242
  }
262
243
  });
263
244
 
245
+ test("sb_tcp_connect sets TCP_NODELAY on the socket it returns", () => {
246
+ // Nagle plus the peer's delayed ACK stalled every fresh HTTPS fetch about 44 ms on Linux (a TLS
247
+ // handshake writes small flights). Timing is flaky on CI and loopback on macOS does not show
248
+ // it, so ask the kernel: connect to a local listener and read the option back.
249
+ const source = readFileSync(transportFile, "utf8");
250
+ const between = (from: string, to: string) => source.slice(source.indexOf(from), source.indexOf(to));
251
+ const dir = mkdtempSync(join(tmpdir(), "sb-nodelay-"));
252
+ try {
253
+ writeFileSync(
254
+ join(dir, "check.c"),
255
+ `#include <errno.h>
256
+ #include <pthread.h>
257
+ #include <netdb.h>
258
+ #include <stdio.h>
259
+ #include <stdlib.h>
260
+ #include <string.h>
261
+ #include <unistd.h>
262
+ #include <sys/socket.h>
263
+ #include <sys/time.h>
264
+ #include <sys/uio.h>
265
+ #include <netinet/in.h>
266
+ #include <netinet/tcp.h>
267
+ #include <arpa/inet.h>
268
+ ${between("// EGRESS:BEGIN", "// EGRESS:END")}
269
+ ${between("// EGRESS-FD:BEGIN", "// EGRESS-FD:END")}
270
+ ${between("// TCP-CONNECT:BEGIN", "// TCP-CONNECT:END")}
271
+ int main(void) {
272
+ int listener = socket(AF_INET, SOCK_STREAM, 0);
273
+ struct sockaddr_in addr;
274
+ memset(&addr, 0, sizeof(addr));
275
+ addr.sin_family = AF_INET;
276
+ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
277
+ if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) || listen(listener, 1)) return 2;
278
+ socklen_t len = sizeof(addr);
279
+ getsockname(listener, (struct sockaddr*)&addr, &len);
280
+ char refused[512] = "";
281
+ int fd = sb_tcp_connect("127.0.0.1", ntohs(addr.sin_port), refused, sizeof(refused));
282
+ int value = -1;
283
+ socklen_t vlen = sizeof(value);
284
+ if (fd >= 0) getsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &value, &vlen);
285
+ printf("fd %s nodelay %s\\n", fd >= 0 ? "yes" : "no", value > 0 ? "on" : "off");
286
+ return 0;
287
+ }
288
+ `,
289
+ );
290
+ const cc = Bun.spawnSync([
291
+ "cc",
292
+ "-Wall",
293
+ "-Werror",
294
+ "-Wno-unused-function",
295
+ "-Wno-unused-result",
296
+ "-o",
297
+ join(dir, "check"),
298
+ join(dir, "check.c"),
299
+ ]);
300
+ expect(cc.stderr.toString()).toBe("");
301
+ const run = Bun.spawnSync([join(dir, "check")], {
302
+ env: { ...process.env, SB_EGRESS_ALLOW: "127.0.0.1", SB_EGRESS_FD: "" },
303
+ });
304
+ expect(run.stdout.toString().trim()).toBe("fd yes nodelay on");
305
+ } finally {
306
+ rmSync(dir, { recursive: true, force: true });
307
+ }
308
+ });
309
+
264
310
  test("the Host header carries a non-default port", () => {
265
311
  const source = readFileSync(transportFile, "utf8");
266
- const between = (from: string, to: string) =>
267
- source.slice(source.indexOf(from), source.indexOf(to));
268
- const buffer = between(
269
- "typedef struct { char* p; size_t len; size_t cap; } sb_buf;",
270
- "static void sb_putjson(",
271
- );
272
- const builder = between(
273
- "// body_len, not strlen(body)",
274
- "// Turn a whole raw response",
275
- );
312
+ const between = (from: string, to: string) => source.slice(source.indexOf(from), source.indexOf(to));
313
+ const buffer = between("typedef struct { char* p; size_t len; size_t cap; } sb_buf;", "static void sb_putjson(");
314
+ const builder = between("// body_len, not strlen(body)", "// Turn a whole raw response");
276
315
  const dir = mkdtempSync(join(tmpdir(), "sb-host-"));
277
316
  try {
278
317
  writeFileSync(
@@ -318,10 +357,7 @@ int main(void) { show(80, 80); show(8080, 80); show(443, 443); show(8443, 443);
318
357
 
319
358
  test("async fetch: requests run on threads and come back through the host", () => {
320
359
  const source = readFileSync(transportFile, "utf8");
321
- const block = source.slice(
322
- source.indexOf("// ASYNC:BEGIN"),
323
- source.indexOf("// ASYNC:END"),
324
- );
360
+ const block = source.slice(source.indexOf("// ASYNC:BEGIN"), source.indexOf("// ASYNC:END"));
325
361
  const dir = mkdtempSync(join(tmpdir(), "sb-async-"));
326
362
  try {
327
363
  // Stand-ins: a client that takes 200 ms, and a host that records which timers fire.
@@ -385,13 +421,8 @@ int main(void) {
385
421
  .trimEnd()
386
422
  .split("\n");
387
423
  // 16 requests of 200 ms on 8 threads: two rounds, not sixteen.
388
- expect(lines.slice(0, 16).sort()).toEqual(
389
- Array.from({ length: 16 }, (_, i) => `${i + 1} h/${i + 1} body`).sort(),
390
- );
391
- expect(lines.slice(16)).toEqual([
392
- "elapsed overlapped",
393
- "outside a callback nothing",
394
- ]);
424
+ expect(lines.slice(0, 16).sort()).toEqual(Array.from({ length: 16 }, (_, i) => `${i + 1} h/${i + 1} body`).sort());
425
+ expect(lines.slice(16)).toEqual(["elapsed overlapped", "outside a callback nothing"]);
395
426
  } finally {
396
427
  rmSync(dir, { recursive: true, force: true });
397
428
  }
package/src/egress.ts CHANGED
@@ -41,6 +41,18 @@ export const BLOCKED_RANGES = [
41
41
  "fe80::/10",
42
42
  "fec0::/10",
43
43
  "ff00::/8",
44
+ // Reserved space that carries no public service: AS112 and the IETF's old anycast
45
+ // blocks, SIIT (`::ffff:0:0:0/96`, which embeds an IPv4 address the mapped rule
46
+ // above does not see), IETF protocol assignments (includes Teredo, `2001::/32`),
47
+ // the second documentation block and SRv6 SIDs.
48
+ "192.31.196.0/24",
49
+ "192.52.193.0/24",
50
+ "192.88.99.0/24",
51
+ "192.175.48.0/24",
52
+ "::ffff:0:0:0/96",
53
+ "2001::/23",
54
+ "3fff::/20",
55
+ "5f00::/16",
44
56
  ] as const;
45
57
 
46
58
  /** Parse an IPv4 or IPv6 literal to its bytes; `null` when it is neither. */
@@ -123,11 +135,7 @@ export function egressAllowList(raw: string | undefined): string[] {
123
135
 
124
136
  /** Why `fetch()` may not connect to `address`, or `null` when it may. The
125
137
  * message is identical in the C client; keep the two in step. */
126
- export function egressRefusal(
127
- host: string,
128
- address: string,
129
- allow: readonly string[],
130
- ): string | null {
138
+ export function egressRefusal(host: string, address: string, allow: readonly string[]): string | null {
131
139
  if (allow.includes("*") || allow.includes(address)) return null;
132
140
  const range = blockedRange(address);
133
141
  if (range === null) return null;
@@ -149,8 +157,5 @@ const TABLE_BEGIN = /( *\/\/ EGRESS-TABLE:BEGIN[^\n]*\n)[\s\S]*?( *\/\/ EGRESS-T
149
157
  /** `source` with the generated table between its EGRESS-TABLE markers replaced. */
150
158
  export function spliceEgressTable(source: string): string {
151
159
  if (!TABLE_BEGIN.test(source)) throw new Error("EGRESS-TABLE markers not found");
152
- return source.replace(
153
- TABLE_BEGIN,
154
- (_, begin: string, end: string) => `${begin}${egressRangesC()}\n${end}`,
155
- );
160
+ return source.replace(TABLE_BEGIN, (_, begin: string, end: string) => `${begin}${egressRangesC()}\n${end}`);
156
161
  }
@@ -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;
@@ -20,6 +20,8 @@ Porffor.c`
20
20
  #include <stdint.h>
21
21
  #include <sys/stat.h>
22
22
  #include <sys/socket.h>
23
+ #include <netinet/in.h>
24
+ #include <netinet/tcp.h>
23
25
  #include <netdb.h>
24
26
  #include <arpa/inet.h>
25
27
  #include <string.h>
@@ -2594,6 +2596,14 @@ static const char* sb_egress_blocked(const unsigned char* a, int len) {
2594
2596
  { 16, 10, { 254, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fe80::/10" },
2595
2597
  { 16, 10, { 254, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fec0::/10" },
2596
2598
  { 16, 8, { 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "ff00::/8" },
2599
+ { 4, 24, { 192, 31, 196, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.31.196.0/24" },
2600
+ { 4, 24, { 192, 52, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.52.193.0/24" },
2601
+ { 4, 24, { 192, 88, 99, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.88.99.0/24" },
2602
+ { 4, 24, { 192, 175, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.175.48.0/24" },
2603
+ { 16, 96, { 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0 }, "::ffff:0:0:0/96" },
2604
+ { 16, 23, { 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "2001::/23" },
2605
+ { 16, 20, { 63, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "3fff::/20" },
2606
+ { 16, 16, { 95, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "5f00::/16" },
2597
2607
  };
2598
2608
  // EGRESS-TABLE:END
2599
2609
  static const unsigned char mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
@@ -2653,6 +2663,14 @@ static int sb_egress_refused(const char* host, const struct sockaddr* sa, char*
2653
2663
  // EGRESS:END
2654
2664
 
2655
2665
  // EGRESS-FD:BEGIN
2666
+ // TCP_NODELAY on an outbound socket. Without it Nagle holds a small write until the peer ACKs the
2667
+ // last one, and the peer delays that ACK: about 44 ms per TLS handshake flight on Linux. A unix
2668
+ // socket (an fd handed over by sproutd) refuses the option, which is fine: only TCP needs it.
2669
+ static void sb_tcp_nodelay(int fd) {
2670
+ int one = 1;
2671
+ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
2672
+ }
2673
+
2656
2674
  // SB_EGRESS_FD=N: a sandboxed host (sproutd) opens outbound connections for the
2657
2675
  // sprout, which may not open sockets itself. Ask on unix socket N with
2658
2676
  // "HOST PORT\n"; the host resolves, applies its policy and answers "ok\n" with the
@@ -2699,6 +2717,7 @@ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused,
2699
2717
  if (!strcmp(answer, "ok") && fd >= 0) {
2700
2718
  setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
2701
2719
  setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
2720
+ sb_tcp_nodelay(fd);
2702
2721
  return fd;
2703
2722
  }
2704
2723
  if (fd >= 0) close(fd);
@@ -2707,6 +2726,7 @@ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused,
2707
2726
  }
2708
2727
  // EGRESS-FD:END
2709
2728
 
2729
+ // TCP-CONNECT:BEGIN
2710
2730
  // refused gets the first refusal; a caller reports it when nothing connected.
2711
2731
  static int sb_tcp_connect(const char* host, int port, char* refused, size_t refused_len) {
2712
2732
  const char* via = getenv("SB_EGRESS_FD");
@@ -2739,13 +2759,14 @@ static int sb_tcp_connect(const char* host, int port, char* refused, size_t refu
2739
2759
  struct timeval tv; tv.tv_sec = 30; tv.tv_usec = 0;
2740
2760
  setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
2741
2761
  setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
2742
- if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) break;
2762
+ if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) { sb_tcp_nodelay(fd); break; }
2743
2763
  close(fd);
2744
2764
  fd = -1;
2745
2765
  }
2746
2766
  freeaddrinfo(res);
2747
2767
  return fd;
2748
2768
  }
2769
+ // TCP-CONNECT:END
2749
2770
 
2750
2771
  // body_len, not strlen(body): a binary body can contain NUL bytes (#232).
2751
2772
  // Host carries the port unless it is the scheme's default (RFC 9110 7.2).
@@ -3304,6 +3325,7 @@ typedef struct sb_wsc {
3304
3325
  int port, tls;
3305
3326
  // Raw TCP (cloudflare:sockets connect()): no WebSocket framing, bytes both ways.
3306
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
3307
3329
  size_t unsent, window;
3308
3330
  char sni[256];
3309
3331
  } sb_wsc;
@@ -3437,10 +3459,13 @@ static int sb_wsio_raw_write(sb_wsio* io, const unsigned char* buf, size_t len)
3437
3459
  // connection ends (-1). out/outlen is application data to send first. With may_read false (the
3438
3460
  // reader is far behind) it only sends, and waits for the wake pipe.
3439
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.
3440
3464
  if (!io->tls) {
3441
3465
  if (*outlen) {
3442
3466
  if (sb_wsio_raw_write(io, out, *outlen) != 0) return -1;
3443
3467
  *outlen = 0;
3468
+ return 0;
3444
3469
  }
3445
3470
  for (;;) {
3446
3471
  if (may_read) {
@@ -3457,6 +3482,7 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
3457
3482
  }
3458
3483
  }
3459
3484
  br_ssl_engine_context* eng = &io->sc->eng;
3485
+ int wrote = 0;
3460
3486
  for (;;) {
3461
3487
  unsigned st = br_ssl_engine_current_state(eng);
3462
3488
  if (st & BR_SSL_CLOSED) return -1;
@@ -3480,9 +3506,10 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
3480
3506
  br_ssl_engine_sendapp_ack(eng, n);
3481
3507
  memmove(out, out + n, *outlen - n);
3482
3508
  *outlen -= n;
3483
- if (*outlen == 0) br_ssl_engine_flush(eng, 0);
3509
+ if (*outlen == 0) { br_ssl_engine_flush(eng, 0); wrote = 1; }
3484
3510
  continue;
3485
3511
  }
3512
+ if (wrote) return 0;
3486
3513
  // Handshaking, or waiting for the peer: read records while the reader may; else only wake.
3487
3514
  int reading = (st & BR_SSL_RECVREC) && (may_read || !(st & BR_SSL_SENDAPP));
3488
3515
  if (reading) {
@@ -3791,15 +3818,30 @@ static void* sb_tcp_thread(void* arg) {
3791
3818
  int st = c->starttls_req; c->starttls_req = 0;
3792
3819
  int sh = c->shutdown_req;
3793
3820
  char sni[256]; memcpy(sni, c->sni, sizeof(sni));
3794
- if (c->outq_len) {
3795
- 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) {
3796
3828
  size_t cap = wq_cap ? wq_cap : 4096;
3797
- while (cap < wq_len + c->outq_len) cap *= 2;
3829
+ while (cap < wq_len + take) cap *= 2;
3798
3830
  unsigned char* grown = (unsigned char*)realloc(wq, cap);
3799
3831
  if (grown) { wq = grown; wq_cap = cap; }
3800
3832
  }
3801
- 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
+ }
3802
3843
  }
3844
+ if (st) c->starttls_at = 0;
3803
3845
  c->unsent = wq_len;
3804
3846
  pthread_mutex_unlock(&c->lock);
3805
3847
  if (rel) {
@@ -3807,7 +3849,14 @@ static void* sb_tcp_thread(void* arg) {
3807
3849
  goto finish;
3808
3850
  }
3809
3851
  if (st && !io.tls) {
3810
- 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
+ }
3811
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; }
3812
3861
  tls_waiting = 1;
3813
3862
  }
@@ -4002,6 +4051,7 @@ static long long sb_wsc_op(int op, unsigned id, const char* data, size_t len, si
4002
4051
  pthread_mutex_lock(&c->lock);
4003
4052
  snprintf(c->sni, sizeof(c->sni), "%.*s", (int)(len < sizeof(c->sni) - 1 ? len : sizeof(c->sni) - 1), data ? data : "");
4004
4053
  c->starttls_req = 1;
4054
+ c->starttls_at = c->outq_len;
4005
4055
  pthread_mutex_unlock(&c->lock);
4006
4056
  sb_wsc_wake_thread(c);
4007
4057
  return 0;
package/test/bearssl.ts CHANGED
@@ -1,16 +1,14 @@
1
- import { createHash } from "node:crypto";
2
1
  import { existsSync } from "node:fs";
3
2
  import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { BEARSSL_SHA256, BEARSSL_URLS, BEARSSL_VENDORED_PATH, BEARSSL_VERSION } from "../../toolchain/src/deps";
6
+ import { fetchPinned } from "../../toolchain/src/fetch-pinned";
6
7
 
7
8
  // The prelude carries crypto.subtle's native half on every transport, so a test that compiles a
8
9
  // handler using crypto.subtle needs BearSSL on the include path and link line, as the CLI arranges
9
10
  // for a build. Same pinned source as the CLI's src/bearssl.ts; built once into the toolchain cache.
10
- const COMMIT = "7bea48e5e850ab4cafbe68d3765cdaba13a86d6f";
11
- const VERSION = COMMIT.slice(0, 7);
12
- const URL_ = `https://bearssl.org/gitweb/?p=BearSSL;a=snapshot;h=${COMMIT};sf=tgz`;
13
- const SHA256 = "ed397832b1613582737d9c1ce01c1af74ad6a9e9c52a341e24433987cda0f372";
11
+ const VERSION = BEARSSL_VERSION;
14
12
 
15
13
  const walk = async (dir: string): Promise<string[]> =>
16
14
  (
@@ -34,10 +32,14 @@ export async function bearsslEnv(): Promise<{ SB_EXTRA_CFLAGS: string; SB_EXTRA_
34
32
  const marker = join(objects, ".complete");
35
33
  if (!existsSync(marker)) {
36
34
  await mkdir(root, { recursive: true });
37
- const response = await fetch(URL_);
38
- if (!response.ok) throw new Error(`could not download ${URL_}: HTTP ${response.status}`);
39
- const bytes = Buffer.from(await response.arrayBuffer());
40
- if (createHash("sha256").update(bytes).digest("hex") !== SHA256) throw new Error("BearSSL checksum mismatch");
35
+ // Vendored tarball first (vendor/bearssl), then bearssl.org with bounded retry; both sha256-pinned: see
36
+ // toolchain/src/deps.ts. A downloaded copy is cached so a later run needs no network.
37
+ const bytes = await fetchPinned({
38
+ localPath: BEARSSL_VENDORED_PATH,
39
+ urls: BEARSSL_URLS,
40
+ sha256: BEARSSL_SHA256,
41
+ cachePath: join(dirname(root), "deps", `bearssl-src-${BEARSSL_SHA256}.tar.gz`),
42
+ });
41
43
  await writeFile(join(root, "bearssl.tar.gz"), bytes);
42
44
  await rm(tree, { recursive: true, force: true });
43
45
  const untar = Bun.spawnSync(["tar", "xzf", join(root, "bearssl.tar.gz"), "-C", root]);
@@ -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
+ }