@sproutboat/runtime 0.13.5 → 0.14.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,18 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - `@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`.
13
+ - `@sproutboat/wire`: `Bindings` loses `outbound`.
14
+ - `@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.
15
+
3
16
  ## 0.13.5
4
17
 
5
18
  ### 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.14.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";
@@ -411,14 +411,18 @@ __sbDefineURLAccessor("host", function () {
411
411
  const i = this.origin.indexOf("://");
412
412
  return i === -1 ? "" : this.origin.slice(i + 3);
413
413
  });
414
+ // The port colon is the one after an IPv6 literal's closing bracket: `[::1]:9`.
415
+ function __sbPortColon(host) {
416
+ return host.indexOf(":", host.charAt(0) === "[" ? host.indexOf("]") : 0);
417
+ }
414
418
  __sbDefineURLAccessor("hostname", function () {
415
419
  const h = this.host;
416
- const c = h.indexOf(":");
420
+ const c = __sbPortColon(h);
417
421
  return c === -1 ? h : h.slice(0, c);
418
422
  });
419
423
  __sbDefineURLAccessor("port", function () {
420
424
  const h = this.host;
421
- const c = h.indexOf(":");
425
+ const c = __sbPortColon(h);
422
426
  return c === -1 ? "" : h.slice(c + 1);
423
427
  });
424
428
  __sbDefineURLAccessor("hash", function () {
@@ -1605,7 +1609,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1605
1609
 
1606
1610
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
1607
1611
  // resolves the target itself and forwards it internally, so this is not
1608
- // egress and is not subject to the outbound allowlist.
1612
+ // egress and is not subject to the private-address block.
1609
1613
  for (let i = 0; i < (bindings.services || []).length; i++) {
1610
1614
  const binding = bindings.services[i].binding;
1611
1615
  target[binding] = {
@@ -1635,30 +1639,35 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1635
1639
  };
1636
1640
  }
1637
1641
 
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
- }
1642
+ };
1643
+
1644
+ // fetch() reaches any public address; the transport refuses private and
1645
+ // reserved ones at connect (baronunread/sproutboat#174). No config, like a
1646
+ // Worker's fetch. Installed by the generated module (wrap.ts), not here: the
1647
+ // prelude must not replace fetch just by being evaluated.
1648
+ globalThis.__sbInstallFetch = function () {
1649
+ globalThis.fetch = function (input, init) {
1650
+ const url = __sbIsStr(input) ? input : String(input.url);
1651
+ const opts = init || {};
1652
+ const headers = [];
1653
+ if (opts.headers) {
1654
+ if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
1655
+ else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
1656
+ }
1657
+ const r = globalThis.__sbFetchUpstream("fetch", {
1658
+ url,
1659
+ method: opts.method || "GET",
1660
+ headers,
1661
+ ...__sbRequestBody(opts.body),
1662
+ });
1663
+ const respHeaders = new Headers();
1664
+ for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1665
+ // #176: bytes from an outbound HTTP response, not a string this handler
1666
+ // built -- must not be re-encoded if the handler proxies it straight
1667
+ // through (see the assets binding for the same reasoning).
1668
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1669
+ return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1670
+ };
1662
1671
  };
1663
1672
 
1664
1673
  // ---------------------------------------------------------------------------
package/src/source.ts CHANGED
@@ -35,12 +35,7 @@ const alwaysForbidden: Array<[RegExp, string]> = [
35
35
  ],
36
36
  ];
37
37
 
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
- ];
42
-
43
- export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
38
+ export function validateHttpSyncSource(source: string): SourceValidation {
44
39
  const errors: string[] = [];
45
40
  // The default export must be an object literal with a `fetch` method. A module
46
41
  // may also declare Durable Object classes / helpers before it, so this is not
@@ -52,6 +47,5 @@ export function validateHttpSyncSource(source: string, outboundAllowed = false):
52
47
  errors.push("handler must default-export an object with fetch(request)");
53
48
  }
54
49
  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
50
  return errors.length ? { ok: false, errors } : { ok: true };
57
51
  }
@@ -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),