@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 +13 -0
- package/package.json +1 -1
- package/src/egress.test.ts +101 -70
- package/src/egress.ts +14 -9
- package/src/native-fetch-prelude.js +49 -10
- package/src/transport-embedded.js +57 -7
- package/test/bearssl.ts +12 -10
- package/test/native-sockets-broker.ts +62 -0
- package/test/native-sockets-starttls.ts +404 -0
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
package/src/egress.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|
|
@@ -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
|
-
|
|
3795
|
-
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
const bytes =
|
|
40
|
-
|
|
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
|
+
}
|