@sproutboat/runtime 0.13.5 → 0.15.0

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,24 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Pin and vendor Porffor alpha-16 (43087d4). Enable Proxy and revocable Proxy in handler validation, add native regression coverage for traps and iterators, and drop three typed-array patches now implemented upstream. URLSearchParams and FormData now provide live, self-iterable keys, values and entries iterators. See PORFFOR_ALPHA16.md for the complete change audit and remaining package compatibility limits.
8
+
9
+ ## 0.14.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
14
+
15
+ - Both backends, the broker and the standalone client, check the address they resolved, not the hostname, so DNS rebinding can't get past the check. The broker connects to the vetted address and verifies TLS against the name.
16
+ - Blocked: loopback, RFC 1918, link-local and cloud metadata, CGNAT, benchmarking, documentation, multicast and reserved ranges, IPv6 ULA and link-local, and the IPv4 address inside IPv4-mapped, NAT64 and 6to4 addresses.
17
+ - An operator can let addresses through with `SB_EGRESS_ALLOW` (`*` or exact addresses) in the broker's or the binary's environment. Handler code can't.
18
+ - `@sproutboat/runtime`: `URL.hostname` and `URL.port` handle IPv6 literals (`[::1]:9` used to give hostname `[`). Every sprout gets `fetch()`, with no binding needed. `validateHttpSyncSource` no longer takes an `outboundAllowed` argument, and `Bindings` loses `outbound`.
19
+ - `@sproutboat/wire`: `Bindings` loses `outbound`.
20
+ - `@sproutboat/config`: `outbound` is accepted but ignored, with a warning in the new `warnings` field of a successful parse (`OUTBOUND_IGNORED`). It will be refused in a later minor release.
21
+
3
22
  ## 0.13.5
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.13.5",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,6 +11,7 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "scripts": {
14
- "test": "bun test ./"
14
+ "test": "bun test ./",
15
+ "gen:egress": "bun src/gen-egress.ts"
15
16
  }
16
17
  }
@@ -0,0 +1,155 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ blockedRange,
7
+ egressAllowList,
8
+ egressRefusal,
9
+ parseAddress,
10
+ spliceEgressTable,
11
+ } from "./egress";
12
+ import { validateHttpSyncSource } from "./source";
13
+ import { wrapNativeFetchHandler } from "./wrap";
14
+
15
+ const BLOCKED = [
16
+ "0.0.0.0",
17
+ "10.1.2.3",
18
+ "100.64.0.1",
19
+ "100.127.255.255",
20
+ "127.0.0.1",
21
+ "127.255.255.254",
22
+ "169.254.169.254",
23
+ "172.16.0.1",
24
+ "172.31.255.255",
25
+ "192.0.0.8",
26
+ "192.0.2.1",
27
+ "192.168.1.1",
28
+ "198.18.0.1",
29
+ "198.19.255.255",
30
+ "224.0.0.1",
31
+ "255.255.255.255",
32
+ "::",
33
+ "::1",
34
+ "::ffff:127.0.0.1",
35
+ "64:ff9b::7f00:1",
36
+ "64:ff9b::a00:1",
37
+ "64:ff9b:1::1",
38
+ "2002:7f00:1::1",
39
+ "2001:db8::1",
40
+ "fc00::1",
41
+ "fd12::1",
42
+ "fe80::1",
43
+ "ff02::1",
44
+ ];
45
+ const PUBLIC = [
46
+ "1.1.1.1",
47
+ "8.8.8.8",
48
+ "100.128.0.1",
49
+ "172.15.255.255",
50
+ "172.32.0.1",
51
+ "198.20.0.1",
52
+ "::ffff:8.8.8.8",
53
+ "64:ff9b::808:808",
54
+ "2002:808:808::1",
55
+ "2001:4860:4860::8888",
56
+ "2606:4700::1111",
57
+ ];
58
+ const ALLOW = "8.8.4.4, 10.9.9.9";
59
+
60
+ 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) expect([address, blockedRange(address)]).toEqual([address, null]);
64
+ expect(blockedRange("::ffff:127.0.0.1")).toBe("127.0.0.0/8");
65
+ expect(blockedRange("64:ff9b::a00:1")).toBe("10.0.0.0/8");
66
+ });
67
+
68
+ test("the operator allow list lets an exact address or everything through", () => {
69
+ expect(egressRefusal("db", "10.9.9.9", egressAllowList(ALLOW))).toBeNull();
70
+ expect(egressRefusal("db", "10.9.9.8", egressAllowList(ALLOW))).toContain(
71
+ "SB_EGRESS_ALLOW=10.9.9.8",
72
+ );
73
+ expect(egressRefusal("db", "127.0.0.1", ["*"])).toBeNull();
74
+ expect(egressRefusal("x", "169.254.169.254", [])).toBe(
75
+ "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",
76
+ );
77
+ });
78
+
79
+ test("parseAddress rejects what is not an address", () => {
80
+ for (const bad of [
81
+ "",
82
+ "1.2.3",
83
+ "256.1.1.1",
84
+ "example.com",
85
+ "1::2::3",
86
+ "1:2:3:4:5:6:7:8:9",
87
+ "12345::",
88
+ ])
89
+ expect([bad, parseAddress(bad)]).toEqual([bad, null]);
90
+ });
91
+
92
+ const transportFile = new URL("./transport-embedded.js", import.meta.url);
93
+
94
+ test("the C table in transport-embedded.js is generated from this one (bun run gen:egress)", () => {
95
+ const source = readFileSync(transportFile, "utf8");
96
+ expect(spliceEgressTable(source)).toBe(source);
97
+ });
98
+
99
+ test("the C check gives the same answer and message as the TS one", () => {
100
+ const source = readFileSync(transportFile, "utf8");
101
+ const block = source.slice(source.indexOf("// EGRESS:BEGIN"), source.indexOf("// EGRESS:END"));
102
+ const dir = mkdtempSync(join(tmpdir(), "sb-egress-"));
103
+ try {
104
+ writeFileSync(
105
+ join(dir, "check.c"),
106
+ `#include <stdio.h>
107
+ #include <stdlib.h>
108
+ #include <string.h>
109
+ #include <sys/socket.h>
110
+ #include <netinet/in.h>
111
+ #include <arpa/inet.h>
112
+ ${block}
113
+ int main(int argc, char** argv) {
114
+ for (int i = 1; i < argc; i++) {
115
+ struct sockaddr_storage ss;
116
+ memset(&ss, 0, sizeof(ss));
117
+ struct sockaddr_in* v4 = (struct sockaddr_in*)&ss;
118
+ struct sockaddr_in6* v6 = (struct sockaddr_in6*)&ss;
119
+ if (inet_pton(AF_INET, argv[i], &v4->sin_addr) == 1) v4->sin_family = AF_INET;
120
+ else if (inet_pton(AF_INET6, argv[i], &v6->sin6_addr) == 1) v6->sin6_family = AF_INET6;
121
+ else { printf("unparsed\\n"); continue; }
122
+ char msg[512];
123
+ printf("%s\\n", sb_egress_refused("h", (struct sockaddr*)&ss, msg, sizeof(msg)) ? msg : "ok");
124
+ }
125
+ return 0;
126
+ }
127
+ `,
128
+ );
129
+ const cc = Bun.spawnSync([
130
+ "cc",
131
+ "-Wall",
132
+ "-Werror",
133
+ "-o",
134
+ join(dir, "check"),
135
+ join(dir, "check.c"),
136
+ ]);
137
+ expect(cc.stderr.toString()).toBe("");
138
+ const addresses = [...BLOCKED, ...PUBLIC, "10.9.9.9"];
139
+ const run = Bun.spawnSync([join(dir, "check"), ...addresses], {
140
+ env: { ...process.env, SB_EGRESS_ALLOW: ALLOW },
141
+ });
142
+ const fromC = run.stdout.toString().trimEnd().split("\n");
143
+ const fromTs = addresses.map((a) => egressRefusal("h", a, egressAllowList(ALLOW)) ?? "ok");
144
+ expect(fromC).toEqual(fromTs);
145
+ } finally {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ test("#174: fetch() needs no config, so every sprout gets it", () => {
151
+ const handler = `export default { async fetch() { return await fetch("https://api.example.com/"); } };`;
152
+ expect(validateHttpSyncSource(handler)).toEqual({ ok: true });
153
+ const prelude = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
154
+ expect(wrapNativeFetchHandler(handler, prelude)).toContain("__sbInstallFetch();");
155
+ });
package/src/egress.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * baronunread/sproutboat#174 — `fetch()` never reaches a private or reserved
3
+ * address. A sprout is a real OS process, so without this it could reach the
4
+ * operator's loopback services, their private network, or a cloud metadata
5
+ * endpoint, none of which a Worker can touch.
6
+ *
7
+ * Both backends check the address they are about to connect to, never the
8
+ * hostname: the broker (`@sproutboat/wire`) here in TS, the standalone client
9
+ * in C (`transport-embedded.js`). This table is the one source of truth; the C
10
+ * copy is generated from it (`bun run gen:egress`) and a test fails if the two
11
+ * drift, while another runs the C check against this one on the same vectors.
12
+ *
13
+ * The override belongs to the operator, not the handler: `SB_EGRESS_ALLOW` in
14
+ * the broker's or the binary's environment, either `*` or a comma-separated
15
+ * list of exact addresses.
16
+ * ponytail: exact addresses only, add CIDR entries if an operator needs a subnet.
17
+ */
18
+
19
+ /** Blocked ranges. IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) and
20
+ * 6to4 (`2002::/16`) addresses are checked by the IPv4 address they embed. */
21
+ export const BLOCKED_RANGES = [
22
+ "0.0.0.0/8",
23
+ "10.0.0.0/8",
24
+ "100.64.0.0/10",
25
+ "127.0.0.0/8",
26
+ "169.254.0.0/16",
27
+ "172.16.0.0/12",
28
+ "192.0.0.0/24",
29
+ "192.0.2.0/24",
30
+ "192.168.0.0/16",
31
+ "198.18.0.0/15",
32
+ "198.51.100.0/24",
33
+ "203.0.113.0/24",
34
+ "224.0.0.0/4",
35
+ "240.0.0.0/4",
36
+ "::/96",
37
+ "64:ff9b:1::/48",
38
+ "100::/64",
39
+ "2001:db8::/32",
40
+ "fc00::/7",
41
+ "fe80::/10",
42
+ "fec0::/10",
43
+ "ff00::/8",
44
+ ] as const;
45
+
46
+ /** Parse an IPv4 or IPv6 literal to its bytes; `null` when it is neither. */
47
+ export function parseAddress(text: string): Uint8Array | null {
48
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
49
+ if (v4) {
50
+ const bytes = v4.slice(1).map(Number);
51
+ return bytes.every((b) => b <= 255) ? Uint8Array.from(bytes) : null;
52
+ }
53
+ if (!text.includes(":")) return null;
54
+ let tail: number[] = [];
55
+ let body = text;
56
+ const dotted = /^(.*:)(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
57
+ if (dotted) {
58
+ const embedded = parseAddress(dotted[2]);
59
+ if (!embedded) return null;
60
+ body = `${dotted[1]}0:0`;
61
+ tail = [...embedded];
62
+ }
63
+ const halves = body.split("::");
64
+ if (halves.length > 2) return null;
65
+ const groups = (part: string) => (part === "" ? [] : part.split(":"));
66
+ const head = groups(halves[0]);
67
+ const rest = halves.length === 2 ? groups(halves[1]) : [];
68
+ const missing = 8 - head.length - rest.length;
69
+ if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
70
+ const all = [...head, ...Array<string>(missing).fill("0"), ...rest];
71
+ const out = new Uint8Array(16);
72
+ for (let i = 0; i < 8; i++) {
73
+ if (!/^[0-9a-fA-F]{1,4}$/.test(all[i])) return null;
74
+ const n = parseInt(all[i], 16);
75
+ out[i * 2] = n >> 8;
76
+ out[i * 2 + 1] = n & 255;
77
+ }
78
+ if (tail.length) out.set(tail, 12);
79
+ return out;
80
+ }
81
+
82
+ type Range = { cidr: string; bytes: Uint8Array; bits: number };
83
+ const RANGES: Range[] = BLOCKED_RANGES.map((cidr) => {
84
+ const [addr, bits] = cidr.split("/");
85
+ return { cidr, bytes: parseAddress(addr)!, bits: Number(bits) };
86
+ });
87
+
88
+ function inRange(bytes: Uint8Array, range: Range): boolean {
89
+ if (bytes.length !== range.bytes.length) return false;
90
+ const whole = range.bits >> 3;
91
+ for (let i = 0; i < whole; i++) if (bytes[i] !== range.bytes[i]) return false;
92
+ const rem = range.bits & 7;
93
+ if (rem === 0) return true;
94
+ const mask = (0xff << (8 - rem)) & 0xff;
95
+ return (bytes[whole] & mask) === (range.bytes[whole] & mask);
96
+ }
97
+
98
+ /** The IPv4 address an IPv6 one carries, for the mapped, NAT64 and 6to4 forms. */
99
+ function embeddedV4(bytes: Uint8Array): Uint8Array | null {
100
+ if (bytes.length !== 16) return null;
101
+ const prefix = (p: number[]) => p.every((b, i) => bytes[i] === b);
102
+ if (prefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) return bytes.slice(12);
103
+ if (prefix([0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) return bytes.slice(12);
104
+ if (prefix([0x20, 0x02])) return bytes.slice(2, 6);
105
+ return null;
106
+ }
107
+
108
+ /** The blocked range `address` falls in, or `null` when it may be reached. */
109
+ export function blockedRange(address: string): string | null {
110
+ const parsed = parseAddress(address);
111
+ if (!parsed) return "unparseable address";
112
+ const bytes = embeddedV4(parsed) ?? parsed;
113
+ return RANGES.find((range) => inRange(bytes, range))?.cidr ?? null;
114
+ }
115
+
116
+ /** `SB_EGRESS_ALLOW` split into entries. */
117
+ export function egressAllowList(raw: string | undefined): string[] {
118
+ return (raw ?? "")
119
+ .split(",")
120
+ .map((s) => s.trim())
121
+ .filter(Boolean);
122
+ }
123
+
124
+ /** Why `fetch()` may not connect to `address`, or `null` when it may. The
125
+ * 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 {
131
+ if (allow.includes("*") || allow.includes(address)) return null;
132
+ const range = blockedRange(address);
133
+ if (range === null) return null;
134
+ return `fetch() refused: ${host} resolves to ${address}, a private or reserved address (${range}). An operator can allow it with SB_EGRESS_ALLOW=${address}`;
135
+ }
136
+
137
+ /** The C range table spliced into transport-embedded.js, inside
138
+ * sb_egress_blocked: Porffor's header split mangles a file-scope array. */
139
+ export function egressRangesC(): string {
140
+ const rows = RANGES.map(({ cidr, bytes, bits }) => {
141
+ const padded = [...bytes, ...Array<number>(16 - bytes.length).fill(0)];
142
+ return ` { ${bytes.length}, ${bits}, { ${padded.join(", ")} }, "${cidr}" },`;
143
+ });
144
+ return [" static const sb_egress_range sb_egress_ranges[] = {", ...rows, " };"].join("\n");
145
+ }
146
+
147
+ const TABLE_BEGIN = /( *\/\/ EGRESS-TABLE:BEGIN[^\n]*\n)[\s\S]*?( *\/\/ EGRESS-TABLE:END)/;
148
+
149
+ /** `source` with the generated table between its EGRESS-TABLE markers replaced. */
150
+ export function spliceEgressTable(source: string): string {
151
+ 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
+ );
156
+ }
@@ -0,0 +1,6 @@
1
+ // Regenerates the C range table in transport-embedded.js from egress.ts (#174).
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { spliceEgressTable } from "./egress";
4
+
5
+ const path = new URL("./transport-embedded.js", import.meta.url);
6
+ writeFileSync(path, spliceEgressTable(readFileSync(path, "utf8")));
package/src/index.ts CHANGED
@@ -8,3 +8,4 @@
8
8
  // src/native-fetch-prelude.js.
9
9
  export * from "./wrap";
10
10
  export * from "./source";
11
+ export * from "./egress";
@@ -93,6 +93,22 @@ function __sbTagCpu(res, t0) {
93
93
  return res;
94
94
  }
95
95
 
96
+ // Live iterators use the current collection on each next(), including mutations.
97
+ function __sbCollectionIterator(owner, kind, formData) {
98
+ let index = 0;
99
+ return {
100
+ next() {
101
+ const length = formData ? owner._entries.length : owner._keys.length;
102
+ if (index >= length) return { value: undefined, done: true };
103
+ const key = formData ? owner._entries[index][0] : owner._keys[index];
104
+ const value = formData ? owner._entries[index][1] : owner._vals[index];
105
+ index++;
106
+ return { value: kind === 0 ? key : kind === 1 ? value : [key, value], done: false };
107
+ },
108
+ [Symbol.iterator]() { return this; },
109
+ };
110
+ }
111
+
96
112
  class __SproutboatURLSearchParams {
97
113
  constructor(init) {
98
114
  this._keys = [];
@@ -165,11 +181,15 @@ class __SproutboatURLSearchParams {
165
181
  this._vals = idx.map((i) => this._vals[i]);
166
182
  }
167
183
  keys() {
168
- return this._keys.slice();
184
+ return __sbCollectionIterator(this, 0, false);
169
185
  }
170
186
  values() {
171
- return this._vals.slice();
187
+ return __sbCollectionIterator(this, 1, false);
172
188
  }
189
+ entries() {
190
+ return __sbCollectionIterator(this, 2, false);
191
+ }
192
+ [Symbol.iterator]() { return this.entries(); }
173
193
  get size() {
174
194
  return this._keys.length;
175
195
  }
@@ -318,24 +338,22 @@ if (globalThis.FormData == null) {
318
338
  for (let i = 0; i < this._entries.length; i++) cb(this._entries[i][1], this._entries[i][0], this);
319
339
  }
320
340
  keys() {
321
- return this._entries.map((e) => e[0]);
341
+ return __sbCollectionIterator(this, 0, true);
322
342
  }
323
343
  values() {
324
- return this._entries.map((e) => e[1]);
344
+ return __sbCollectionIterator(this, 1, true);
325
345
  }
326
346
  entries() {
327
- return this._entries.map((e) => [e[0], e[1]]);
347
+ return __sbCollectionIterator(this, 2, true);
328
348
  }
329
349
  }
350
+ __SproutboatFormData.prototype[Symbol.iterator] = __SproutboatFormData.prototype.entries;
330
351
  globalThis.FormData = __SproutboatFormData;
331
352
  }
332
353
 
333
354
  function __sbParseUrlencodedFormData(text) {
334
355
  const fd = new FormData();
335
- // forEach, not keys()/values() — those are spec'd as iterators, not
336
- // arrays, and this shim's own URLSearchParams.keys()/values() return
337
- // arrays as an implementation shortcut, so relying on either shape here
338
- // would break against whichever kind actually ends up installed.
356
+ // forEach handles both the native API and the local shim.
339
357
  new URLSearchParams(text).forEach((value, key) => fd.append(key, value));
340
358
  return fd;
341
359
  }
@@ -411,14 +429,18 @@ __sbDefineURLAccessor("host", function () {
411
429
  const i = this.origin.indexOf("://");
412
430
  return i === -1 ? "" : this.origin.slice(i + 3);
413
431
  });
432
+ // The port colon is the one after an IPv6 literal's closing bracket: `[::1]:9`.
433
+ function __sbPortColon(host) {
434
+ return host.indexOf(":", host.charAt(0) === "[" ? host.indexOf("]") : 0);
435
+ }
414
436
  __sbDefineURLAccessor("hostname", function () {
415
437
  const h = this.host;
416
- const c = h.indexOf(":");
438
+ const c = __sbPortColon(h);
417
439
  return c === -1 ? h : h.slice(0, c);
418
440
  });
419
441
  __sbDefineURLAccessor("port", function () {
420
442
  const h = this.host;
421
- const c = h.indexOf(":");
443
+ const c = __sbPortColon(h);
422
444
  return c === -1 ? "" : h.slice(c + 1);
423
445
  });
424
446
  __sbDefineURLAccessor("hash", function () {
@@ -1605,7 +1627,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1605
1627
 
1606
1628
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
1607
1629
  // resolves the target itself and forwards it internally, so this is not
1608
- // egress and is not subject to the outbound allowlist.
1630
+ // egress and is not subject to the private-address block.
1609
1631
  for (let i = 0; i < (bindings.services || []).length; i++) {
1610
1632
  const binding = bindings.services[i].binding;
1611
1633
  target[binding] = {
@@ -1635,30 +1657,35 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1635
1657
  };
1636
1658
  }
1637
1659
 
1638
- if ((bindings.outbound || []).length > 0) {
1639
- globalThis.fetch = function (input, init) {
1640
- const url = __sbIsStr(input) ? input : String(input.url);
1641
- const opts = init || {};
1642
- const headers = [];
1643
- if (opts.headers) {
1644
- if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
1645
- else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
1646
- }
1647
- const r = globalThis.__sbFetchUpstream("fetch", {
1648
- url,
1649
- method: opts.method || "GET",
1650
- headers,
1651
- ...__sbRequestBody(opts.body),
1652
- });
1653
- const respHeaders = new Headers();
1654
- for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1655
- // #176: bytes from an outbound HTTP response, not a string this handler
1656
- // built -- must not be re-encoded if the handler proxies it straight
1657
- // through (see the assets binding for the same reasoning).
1658
- if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1659
- return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1660
- };
1661
- }
1660
+ };
1661
+
1662
+ // fetch() reaches any public address; the transport refuses private and
1663
+ // reserved ones at connect (baronunread/sproutboat#174). No config, like a
1664
+ // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
1665
+ // prelude must not replace fetch just by being evaluated.
1666
+ globalThis.__sbInstallFetch = function () {
1667
+ globalThis.fetch = function (input, init) {
1668
+ const url = __sbIsStr(input) ? input : String(input.url);
1669
+ const opts = init || {};
1670
+ const headers = [];
1671
+ if (opts.headers) {
1672
+ if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
1673
+ else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
1674
+ }
1675
+ const r = globalThis.__sbFetchUpstream("fetch", {
1676
+ url,
1677
+ method: opts.method || "GET",
1678
+ headers,
1679
+ ...__sbRequestBody(opts.body),
1680
+ });
1681
+ const respHeaders = new Headers();
1682
+ for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1683
+ // #176: bytes from an outbound HTTP response, not a string this handler
1684
+ // built -- must not be re-encoded if the handler proxies it straight
1685
+ // through (see the assets binding for the same reasoning).
1686
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1687
+ return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1688
+ };
1662
1689
  };
1663
1690
 
1664
1691
  // ---------------------------------------------------------------------------
package/src/source.ts CHANGED
@@ -25,22 +25,10 @@ const alwaysForbidden: Array<[RegExp, string]> = [
25
25
  // match would also flag it appearing in an ordinary string a dependency
26
26
  // happens to construct (a doc link, a log message).
27
27
  [/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
28
- // The Porffor pin compiles `new Proxy(...)` and then ignores the handler: a
29
- // trapped property reads back as `undefined`, with no throw. Rejecting it
30
- // here is the difference between a build error and a 502 nobody can explain.
31
- // It is why itty-router and other Proxy-based routers do not work yet.
32
- [
33
- /\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/,
34
- "Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined",
35
- ],
36
- ];
37
28
 
38
- const fetchWithoutAllowlist: [RegExp, string] = [
39
- /(?:\breturn\s+|\bawait\s+|=\s*)fetch\s*\(/,
40
- "outbound networking needs an `outbound` host allowlist in sproutboat.jsonc",
41
29
  ];
42
30
 
43
- export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
31
+ export function validateHttpSyncSource(source: string): SourceValidation {
44
32
  const errors: string[] = [];
45
33
  // The default export must be an object literal with a `fetch` method. A module
46
34
  // may also declare Durable Object classes / helpers before it, so this is not
@@ -52,6 +40,5 @@ export function validateHttpSyncSource(source: string, outboundAllowed = false):
52
40
  errors.push("handler must default-export an object with fetch(request)");
53
41
  }
54
42
  for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
55
- if (!outboundAllowed && fetchWithoutAllowlist[0].test(source)) errors.push(fetchWithoutAllowlist[1]);
56
43
  return errors.length ? { ok: false, errors } : { ok: true };
57
44
  }
@@ -21,6 +21,8 @@ Porffor.c`
21
21
  #include <sys/stat.h>
22
22
  #include <sys/socket.h>
23
23
  #include <netdb.h>
24
+ #include <arpa/inet.h>
25
+ #include <string.h>
24
26
  #include <strings.h>
25
27
  #include <sys/time.h>
26
28
  #include <bearssl.h>
@@ -1006,7 +1008,7 @@ static char* sb_sql_backup(const char* src_path, const char* dest_path) {
1006
1008
  // response parser: the only thing that differs is how bytes move.
1007
1009
 
1008
1010
  // #56 — an outbound response is held whole in memory, and its size is chosen by
1009
- // the remote host, not by us. Without a cap one allowlisted upstream can drive a
1011
+ // the remote host, not by us. Without a cap one upstream can drive a
1010
1012
  // sprout out of memory: a 100 MB body measured at 321 MB resident. 32 MiB by
1011
1013
  // default, raisable for a deployment that knowingly fetches something bigger.
1012
1014
  static size_t sb_fetch_max(void) {
@@ -1019,16 +1021,122 @@ static size_t sb_fetch_max(void) {
1019
1021
  return cached;
1020
1022
  }
1021
1023
 
1022
- static int sb_tcp_connect(const char* host, int port) {
1024
+ // EGRESS:BEGIN
1025
+ // baronunread/sproutboat#174: fetch() never connects to a private or reserved
1026
+ // address. The resolved address is checked, never the hostname, so DNS
1027
+ // rebinding has nothing to race. Same table, algorithm and message as
1028
+ // @sproutboat/runtime's src/egress.ts; egress.test.ts compiles this block and
1029
+ // holds the two to the same answers.
1030
+
1031
+ typedef struct { int len; int bits; unsigned char addr[16]; const char* cidr; } sb_egress_range;
1032
+ static const char* sb_egress_blocked(const unsigned char* a, int len) {
1033
+ // EGRESS-TABLE:BEGIN generated by "bun run gen:egress" from src/egress.ts, do not edit
1034
+ static const sb_egress_range sb_egress_ranges[] = {
1035
+ { 4, 8, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "0.0.0.0/8" },
1036
+ { 4, 8, { 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "10.0.0.0/8" },
1037
+ { 4, 10, { 100, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "100.64.0.0/10" },
1038
+ { 4, 8, { 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "127.0.0.0/8" },
1039
+ { 4, 16, { 169, 254, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "169.254.0.0/16" },
1040
+ { 4, 12, { 172, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "172.16.0.0/12" },
1041
+ { 4, 24, { 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.0.0.0/24" },
1042
+ { 4, 24, { 192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.0.2.0/24" },
1043
+ { 4, 16, { 192, 168, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "192.168.0.0/16" },
1044
+ { 4, 15, { 198, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "198.18.0.0/15" },
1045
+ { 4, 24, { 198, 51, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "198.51.100.0/24" },
1046
+ { 4, 24, { 203, 0, 113, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "203.0.113.0/24" },
1047
+ { 4, 4, { 224, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "224.0.0.0/4" },
1048
+ { 4, 4, { 240, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "240.0.0.0/4" },
1049
+ { 16, 96, { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "::/96" },
1050
+ { 16, 48, { 0, 100, 255, 155, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "64:ff9b:1::/48" },
1051
+ { 16, 64, { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "100::/64" },
1052
+ { 16, 32, { 32, 1, 13, 184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "2001:db8::/32" },
1053
+ { 16, 7, { 252, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fc00::/7" },
1054
+ { 16, 10, { 254, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fe80::/10" },
1055
+ { 16, 10, { 254, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fec0::/10" },
1056
+ { 16, 8, { 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "ff00::/8" },
1057
+ };
1058
+ // EGRESS-TABLE:END
1059
+ static const unsigned char mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
1060
+ static const unsigned char nat64[12] = { 0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0 };
1061
+ if (len == 16 && (memcmp(a, mapped, 12) == 0 || memcmp(a, nat64, 12) == 0)) { a += 12; len = 4; }
1062
+ else if (len == 16 && a[0] == 0x20 && a[1] == 0x02) { a += 2; len = 4; }
1063
+ for (size_t i = 0; i < sizeof(sb_egress_ranges) / sizeof(sb_egress_ranges[0]); i++) {
1064
+ if (sb_egress_ranges[i].len != len) continue;
1065
+ int bits = sb_egress_ranges[i].bits, whole = bits >> 3, rem = bits & 7;
1066
+ if (memcmp(a, sb_egress_ranges[i].addr, (size_t)whole) != 0) continue;
1067
+ if (rem) {
1068
+ unsigned char m = (unsigned char)(0xff << (8 - rem));
1069
+ if ((a[whole] & m) != (sb_egress_ranges[i].addr[whole] & m)) continue;
1070
+ }
1071
+ return sb_egress_ranges[i].cidr;
1072
+ }
1073
+ return 0;
1074
+ }
1075
+
1076
+ // SB_EGRESS_ALLOW: "*" or a comma-separated list of exact addresses.
1077
+ static int sb_egress_allowed(const char* ip) {
1078
+ const char* p = getenv("SB_EGRESS_ALLOW");
1079
+ if (!p) return 0;
1080
+ size_t n = strlen(ip);
1081
+ while (*p) {
1082
+ while (*p == ' ' || *p == ',') p++;
1083
+ const char* start = p;
1084
+ while (*p && *p != ',') p++;
1085
+ const char* end = p;
1086
+ while (end > start && end[-1] == ' ') end--;
1087
+ size_t len = (size_t)(end - start);
1088
+ if ((len == 1 && *start == '*') || (len == n && len > 0 && memcmp(start, ip, n) == 0)) return 1;
1089
+ }
1090
+ return 0;
1091
+ }
1092
+
1093
+ // 1 and the refusal in msg when fetch() may not connect to sa.
1094
+ static int sb_egress_refused(const char* host, const struct sockaddr* sa, char* msg, size_t msglen) {
1095
+ const unsigned char* bytes;
1096
+ int len;
1097
+ if (sa->sa_family == AF_INET) { bytes = (const unsigned char*)&((const struct sockaddr_in*)sa)->sin_addr; len = 4; }
1098
+ else if (sa->sa_family == AF_INET6) { bytes = ((const struct sockaddr_in6*)sa)->sin6_addr.s6_addr; len = 16; }
1099
+ else { snprintf(msg, msglen, "fetch() refused: %s resolves to a non-IP address", host); return 1; }
1100
+ char ip[INET6_ADDRSTRLEN];
1101
+ if (!inet_ntop(sa->sa_family, bytes, ip, sizeof(ip))) {
1102
+ snprintf(msg, msglen, "fetch() refused: %s resolves to an unprintable address", host);
1103
+ return 1;
1104
+ }
1105
+ if (sb_egress_allowed(ip)) return 0;
1106
+ const char* cidr = sb_egress_blocked(bytes, len);
1107
+ if (!cidr) return 0;
1108
+ snprintf(msg, msglen,
1109
+ "fetch() refused: %s resolves to %s, a private or reserved address (%s). An operator can allow it with SB_EGRESS_ALLOW=%s",
1110
+ host, ip, cidr, ip);
1111
+ return 1;
1112
+ }
1113
+ // EGRESS:END
1114
+
1115
+ // refused gets the first refusal; a caller reports it when nothing connected.
1116
+ static int sb_tcp_connect(const char* host, int port, char* refused, size_t refused_len) {
1023
1117
  struct addrinfo hints, *res = 0, *it;
1024
1118
  memset(&hints, 0, sizeof(hints));
1025
1119
  hints.ai_family = AF_UNSPEC;
1026
1120
  hints.ai_socktype = SOCK_STREAM;
1027
1121
  char portstr[16];
1028
1122
  snprintf(portstr, sizeof(portstr), "%d", port);
1029
- if (getaddrinfo(host, portstr, &hints, &res) != 0 || !res) return -1;
1123
+ // URL.hostname keeps an IPv6 literal's brackets; getaddrinfo wants it bare.
1124
+ char bare[256];
1125
+ size_t hl = strlen(host);
1126
+ if (hl > 2 && hl < sizeof(bare) && host[0] == '[' && host[hl - 1] == ']') {
1127
+ memcpy(bare, host + 1, hl - 2);
1128
+ bare[hl - 2] = 0;
1129
+ } else {
1130
+ snprintf(bare, sizeof(bare), "%s", host);
1131
+ }
1132
+ if (getaddrinfo(bare, portstr, &hints, &res) != 0 || !res) return -1;
1030
1133
  int fd = -1;
1031
1134
  for (it = res; it; it = it->ai_next) {
1135
+ char why[512];
1136
+ if (sb_egress_refused(host, it->ai_addr, why, sizeof(why))) {
1137
+ if (!refused[0]) snprintf(refused, refused_len, "%s", why);
1138
+ continue;
1139
+ }
1032
1140
  fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol);
1033
1141
  if (fd < 0) continue;
1034
1142
  struct timeval tv; tv.tv_sec = 30; tv.tv_usec = 0;
@@ -1155,8 +1263,9 @@ static char* sb_error_json(const char* prefix, const char* detail) {
1155
1263
 
1156
1264
  static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
1157
1265
  const char* headers, const char* body, size_t body_len) {
1158
- int fd = sb_tcp_connect(host, port);
1159
- if (fd < 0) return sb_error_json("could not connect to ", host);
1266
+ char refused[512] = "";
1267
+ int fd = sb_tcp_connect(host, port, refused, sizeof(refused));
1268
+ if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
1160
1269
 
1161
1270
  sb_buf req = { 0, 0, 0 };
1162
1271
  sb_build_request(&req, host, path, method, headers, body, body_len);
@@ -1345,8 +1454,9 @@ static int sb_sock_write(void* ctx, const unsigned char* buf, size_t len) {
1345
1454
 
1346
1455
  static char* sb_https(const char* host, int port, const char* path, const char* method,
1347
1456
  const char* headers, const char* body, size_t body_len) {
1348
- int fd = sb_tcp_connect(host, port);
1349
- if (fd < 0) return sb_error_json("could not connect to ", host);
1457
+ char refused[512] = "";
1458
+ int fd = sb_tcp_connect(host, port, refused, sizeof(refused));
1459
+ if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
1350
1460
 
1351
1461
  br_ssl_client_context* sc = (br_ssl_client_context*)malloc(sizeof(br_ssl_client_context));
1352
1462
  br_x509_minimal_context* xc = (br_x509_minimal_context*)malloc(sizeof(br_x509_minimal_context));
@@ -1698,7 +1808,7 @@ function __sbD1BackupRaw(src, dest) {
1698
1808
  }
1699
1809
 
1700
1810
  // Outbound HTTP. String params so C can read each directly; the JS side has
1701
- // already split the URL and enforced the allowlist. `tlsFlag` is "1" or "0" —
1811
+ // already split the URL; the connect refuses private addresses. `tlsFlag` is "1" or "0" —
1702
1812
  // a string like the rest, so the marshalling stays uniform.
1703
1813
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1704
1814
  function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
@@ -1805,12 +1915,6 @@ function __sbSqlRaw(path, sql, paramsJson) {
1805
1915
  // Deliberately the same SQL and the same partition keys as broker.ts. When one
1806
1916
  // changes the other has to, and the conformance suite is what says so.
1807
1917
 
1808
- // The allowlist is baked into the module by wrap.ts; read it lazily so the
1809
- // dispatch has no import-order dependency on __sbInstallBindings.
1810
- function bindingsOutbound() {
1811
- return globalThis.__sbOutbound || [];
1812
- }
1813
-
1814
1918
  var __sbDataDir = "";
1815
1919
  function __sbDir() {
1816
1920
  // SB_DATA_DIR, then SPROUTBOAT_DATA, then <name>.data relative to the working
@@ -2048,9 +2152,6 @@ function __sbEmbeddedDispatch(msg) {
2048
2152
  const url = new URL(String(msg.url));
2049
2153
  const tls = url.protocol === "https:";
2050
2154
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
2051
- const allow = bindingsOutbound();
2052
- if (allow.indexOf(url.host) === -1)
2053
- throw new Error("host not in outbound allowlist: " + url.host);
2054
2155
  let headerText = "";
2055
2156
  const pairs = msg.headers || [];
2056
2157
  for (let i = 0; i < pairs.length; i++) {
@@ -0,0 +1,25 @@
1
+ // #174 — the prelude's URL shim splits `host` into hostname and port. An IPv6
2
+ // literal holds colons of its own, so only the colon after `]` is the port's.
3
+ // Lifted out of the text-only prelude, as client-ip.test.js does.
4
+ import { expect, test } from "bun:test";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
9
+ const start = src.indexOf("function __sbPortColon(");
10
+ const end = src.indexOf("__sbDefineURLAccessor(", start);
11
+ if (start === -1 || end === -1) throw new Error("__sbPortColon not found in prelude");
12
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
13
+ const portColon = new Function(`${src.slice(start, end)}\nreturn __sbPortColon;`)();
14
+ const split = (host) => {
15
+ const c = portColon(host);
16
+ return c === -1 ? [host, ""] : [host.slice(0, c), host.slice(c + 1)];
17
+ };
18
+
19
+ test("hostname and port split at the port colon, never inside an IPv6 literal", () => {
20
+ expect(split("example.com")).toEqual(["example.com", ""]);
21
+ expect(split("example.com:8080")).toEqual(["example.com", "8080"]);
22
+ expect(split("[::1]")).toEqual(["[::1]", ""]);
23
+ expect(split("[::1]:9")).toEqual(["[::1]", "9"]);
24
+ expect(split("[64:ff9b::7f00:2]:443")).toEqual(["[64:ff9b::7f00:2]", "443"]);
25
+ });
package/src/wrap.ts CHANGED
@@ -58,7 +58,6 @@ export const BASELINE_COMPATIBILITY_DATE = "2026-08-26";
58
58
  export type Bindings = {
59
59
  kv: string[];
60
60
  secrets: string[];
61
- outbound: string[];
62
61
  d1: string[];
63
62
  r2: string[];
64
63
  queues: string[];
@@ -78,7 +77,6 @@ export type Bindings = {
78
77
  export const EMPTY_BINDINGS: Bindings = {
79
78
  kv: [],
80
79
  secrets: [],
81
- outbound: [],
82
80
  d1: [],
83
81
  r2: [],
84
82
  queues: [],
@@ -94,7 +92,6 @@ function hasBindings(b: Bindings): boolean {
94
92
  return (
95
93
  b.kv.length > 0 ||
96
94
  b.secrets.length > 0 ||
97
- b.outbound.length > 0 ||
98
95
  b.d1.length > 0 ||
99
96
  b.r2.length > 0 ||
100
97
  b.queues.length > 0 ||
@@ -204,8 +201,6 @@ export function wrapNativeFetchHandler(
204
201
  `globalThis.__sbCompat = ${JSON.stringify(compatibilityDate)};\n` +
205
202
  // #15 — the embedded transport derives its default data directory from this.
206
203
  `globalThis.__sbAppName = ${JSON.stringify(appName)};\n` +
207
- // #15 — and enforces the outbound allowlist itself, with no broker to do it.
208
- `globalThis.__sbOutbound = ${JSON.stringify(bindings.outbound)};\n` +
209
204
  (assets ? `globalThis.__sbAssets = ${JSON.stringify(assets)};\n` : "");
210
205
  // The native transfer hook runs before JavaScript receives its Request. It
211
206
  // has a real file-backed implementation only in embedded builds. The broker
@@ -215,9 +210,10 @@ export function wrapNativeFetchHandler(
215
210
  transport === "embedded"
216
211
  ? `Porffor.c\`const char* sb_standalone_app_name(void) { return ${JSON.stringify(appName)}; }\`;\n`
217
212
  : "Porffor.c`typedef struct sb_r2_transfer_ctx { int unused; } sb_r2_transfer_ctx; int sb_r2_transfer_open(const char* a, const char* b, size_t c, sb_r2_transfer_ctx** d) { if (d) *d = 0; return 404; } int sb_r2_transfer_write(sb_r2_transfer_ctx* a, const char* b, size_t c) { return 404; } int sb_r2_transfer_finish(sb_r2_transfer_ctx* a) { return 404; } void sb_r2_transfer_abort(sb_r2_transfer_ctx* a) {} int sb_r2_transfer_download_open(const char* a, const char* b, sb_r2_transfer_ctx** c) { if (c) *c = 0; return 404; } size_t sb_r2_transfer_download_size(sb_r2_transfer_ctx* a) { return 0; } const char* sb_r2_transfer_download_etag(sb_r2_transfer_ctx* a) { return \"\"; } size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* a, size_t b, char* c, size_t d) { return 0; } void sb_r2_transfer_download_close(sb_r2_transfer_ctx* a) {}`;\n";
218
- const wire = hasBindings(bindings)
219
- ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n`
220
- : "";
213
+ // #174 — fetch() needs no binding: every sprout gets it.
214
+ const wire =
215
+ "__sbInstallFetch();\n" +
216
+ (hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "");
221
217
  const registerDO = bindings.do.length
222
218
  ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
223
219
  : "";
@@ -320,7 +316,6 @@ export function readBindingsFromEnv(): Bindings {
320
316
  return {
321
317
  kv: strings(parsed.kv),
322
318
  secrets: strings(parsed.secrets),
323
- outbound: strings(parsed.outbound),
324
319
  d1: strings(parsed.d1),
325
320
  r2: strings(parsed.r2),
326
321
  queues: strings(parsed.queues),
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { ensurePorffor } from "../../toolchain/src/acquire";
6
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
7
+
8
+ const expected = {
9
+ query: [["a", "1"], ["a", "2"], ["b", "3"]],
10
+ keys: ["a", "a", "b"], values: ["1", "2", "3"],
11
+ live: ["x", "y", true], form: [["x", "1"], ["x", "2"]],
12
+ formLive: ["x", "y", true], self: true,
13
+ };
14
+ const handler = `export default { fetch(request) {
15
+ if (new URL(request.url).pathname === "/null") return new Response(null);
16
+ const params = new URLSearchParams("a=1&a=2&b=3");
17
+ const query = [];
18
+ for (const pair of params) query.push(pair);
19
+ const liveParams = new URLSearchParams("x=1");
20
+ const iterator = liveParams.keys();
21
+ const first = iterator.next().value;
22
+ liveParams.append("y", "2");
23
+ const second = iterator.next().value;
24
+ const done = iterator.next().done;
25
+ const fd = new FormData(); fd.append("x", "1"); fd.append("x", "2");
26
+ const form = []; for (const pair of fd) form.push(pair);
27
+ const liveForm = new FormData(); liveForm.append("x", "1");
28
+ const formIterator = liveForm.keys(); const f1 = formIterator.next().value;
29
+ liveForm.append("y", "2"); const f2 = formIterator.next().value;
30
+ return Response.json({ query, keys: Array.from(params.keys()), values: Array.from(params.values()),
31
+ live: [first, second, done], form, formLive: [f1, f2, formIterator.next().done],
32
+ self: iterator[Symbol.iterator]() === iterator });
33
+ } };`;
34
+
35
+ function freePort(): number {
36
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
37
+ const port = listener.port;
38
+ listener.stop(true);
39
+ return port;
40
+ }
41
+
42
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-iterators-"));
43
+ let server: ReturnType<typeof Bun.spawn> | undefined;
44
+ try {
45
+ const porffor = await ensurePorffor({ cacheRoot: workdir });
46
+ const node = Bun.which("node");
47
+ if (!node) throw new Error("native iterators test needs node on PATH");
48
+
49
+ const [core, broker] = await Promise.all([
50
+ readFile(preludePath, "utf8"),
51
+ readFile(transportPath("broker"), "utf8"),
52
+ ]);
53
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
54
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
55
+ const generated = join(workdir, "iterators.generated.js");
56
+ const binary = join(workdir, "iterators-native");
57
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
58
+ await rm(binary, { force: true });
59
+
60
+ const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
61
+ cwd: workdir,
62
+ env: process.env,
63
+ stdout: "pipe",
64
+ stderr: "pipe",
65
+ });
66
+ const [exitCode, stdout, stderr] = await Promise.all([
67
+ compile.exited,
68
+ new Response(compile.stdout).text(),
69
+ new Response(compile.stderr).text(),
70
+ ]);
71
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
72
+
73
+ const port = freePort();
74
+ server = Bun.spawn([binary], {
75
+ cwd: workdir,
76
+ env: { ...process.env, PORT: String(port) },
77
+ stdout: "ignore",
78
+ stderr: "inherit",
79
+ });
80
+ let response: Response | undefined;
81
+ for (let attempt = 0; attempt < 50; attempt++) {
82
+ try {
83
+ response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
84
+ break;
85
+ } catch {
86
+ if (server.exitCode !== null) break;
87
+ await Bun.sleep(100);
88
+ }
89
+ }
90
+ assert(response, "compiled iterators handler did not start");
91
+ assert.equal(response.status, 200);
92
+ assert.deepEqual(await response.json(), expected);
93
+ const empty = await fetch(`http://127.0.0.1:${port}/null`);
94
+ assert.equal(empty.status, 200);
95
+ assert.equal(await empty.text(), "");
96
+ console.log("Native Web API iterators preserve entries, duplicate keys and live mutations");
97
+ } finally {
98
+ if (server) {
99
+ server.kill(9);
100
+ await server.exited;
101
+ }
102
+ await rm(workdir, { recursive: true, force: true });
103
+ }