@sproutboat/runtime 0.13.4 → 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,26 @@
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
+
16
+ ## 0.13.5
17
+
18
+ ### Patch Changes
19
+
20
+ - 5dba469: Binary data survives KV and outbound `fetch()` bodies (baronunread/sproutboat#233, and the request half of #232).
21
+ - **KV:** `put()` of an `ArrayBuffer` or any typed array stores its bytes, flagged binary, instead of `String(view)` (`"0,1,2,..."`). `get(key, type | { type })` supports `"text"`, `"json"` and `"arrayBuffer"` as in Workers: a binary value read as text decodes as UTF-8, and a text value read as `arrayBuffer` is its UTF-8 encoding. `"stream"` throws a clear error. Existing stores gain the `binary` column on first use.
22
+ - **`fetch()` and service-binding request bodies:** an `ArrayBuffer` or view is sent as its exact bytes in both transports, instead of `"0,1,2,..."`. The standalone HTTP client also stopped truncating bodies at the first NUL byte.
23
+
3
24
  ## 0.13.4
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.13.4",
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 () {
@@ -646,6 +650,18 @@ function __sbFromUtf8(bytes) {
646
650
  // on every asset request regardless of whether anything used it -- this was
647
651
  // a real, shipped performance regression on sproutboat.com, caught after
648
652
  // the fact by unexpectedly slow page loads.
653
+ // #232 — a fetch() request body for the wire: text as-is, and an ArrayBuffer or
654
+ // any view as its bytes (one char per byte) flagged bodyBinary, instead of
655
+ // String(view), which sent "0,1,2,...".
656
+ function __sbRequestBody(body) {
657
+ if (body == null) return { body: null };
658
+ if (!__sbIsStr(body) && body.byteLength !== undefined) {
659
+ const bytes = body.buffer !== undefined ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : body;
660
+ return { body: __sbToBytes(bytes), bodyBinary: true };
661
+ }
662
+ return { body: String(body) };
663
+ }
664
+
649
665
  function __sbRawBodyResponse(body, init) {
650
666
  const resp = new Response(body == null ? "" : body, init);
651
667
  if (body != null) {
@@ -1364,12 +1380,27 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1364
1380
  for (let i = 0; i < (bindings.kv || []).length; i++) {
1365
1381
  const ns = bindings.kv[i];
1366
1382
  target[ns] = {
1367
- get(key) {
1383
+ // #233 — Workers' get(key, type | { type }): "text" (default), "json",
1384
+ // "arrayBuffer". A value put as bytes is stored one char per byte and
1385
+ // flagged, so text decodes it as UTF-8 and arrayBuffer returns it
1386
+ // exactly; a text value read as arrayBuffer is its UTF-8 encoding.
1387
+ get(key, typeOrOptions) {
1368
1388
  const r = __sbRpc("kv.get", { ns, key: String(key) });
1369
- return r.found ? r.value : null;
1389
+ if (!r.found) return null;
1390
+ const type = typeOrOptions == null ? "text" : __sbIsStr(typeOrOptions) ? typeOrOptions : typeOrOptions.type || "text";
1391
+ if (type === "arrayBuffer") return __sbBufFrom(r.binary ? r.value : __sbToBytes(r.value));
1392
+ if (type === "stream") throw new TypeError('KV get type "stream" is not supported (no ReadableStream); use "arrayBuffer"');
1393
+ const text = r.binary ? __sbFromUtf8(r.value) : r.value;
1394
+ return type === "json" ? JSON.parse(text) : text;
1370
1395
  },
1371
1396
  put(key, value, options) {
1372
- const msg = { ns, key: String(key), value: String(value) };
1397
+ const msg = { ns, key: String(key), value: "" };
1398
+ if (value != null && !__sbIsStr(value) && value.byteLength !== undefined) {
1399
+ // ArrayBuffer or any ArrayBufferView: its bytes, not String(view) ("0,1,2,...").
1400
+ const bytes = value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : value;
1401
+ msg.value = __sbToBytes(bytes);
1402
+ msg.binary = true;
1403
+ } else msg.value = String(value);
1373
1404
  if (options && options.expirationTtl != null) msg.expirationTtl = Number(options.expirationTtl);
1374
1405
  else if (options && options.expiration != null) msg.expiration = Number(options.expiration);
1375
1406
  __sbRpc("kv.put", msg);
@@ -1578,7 +1609,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1578
1609
 
1579
1610
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
1580
1611
  // resolves the target itself and forwards it internally, so this is not
1581
- // egress and is not subject to the outbound allowlist.
1612
+ // egress and is not subject to the private-address block.
1582
1613
  for (let i = 0; i < (bindings.services || []).length; i++) {
1583
1614
  const binding = bindings.services[i].binding;
1584
1615
  target[binding] = {
@@ -1595,7 +1626,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1595
1626
  url,
1596
1627
  method: opts.method || "GET",
1597
1628
  headers,
1598
- body: opts.body == null ? null : String(opts.body),
1629
+ ...__sbRequestBody(opts.body),
1599
1630
  });
1600
1631
  const respHeaders = new Headers();
1601
1632
  for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
@@ -1608,30 +1639,35 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1608
1639
  };
1609
1640
  }
1610
1641
 
1611
- if ((bindings.outbound || []).length > 0) {
1612
- globalThis.fetch = function (input, init) {
1613
- const url = __sbIsStr(input) ? input : String(input.url);
1614
- const opts = init || {};
1615
- const headers = [];
1616
- if (opts.headers) {
1617
- if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
1618
- else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
1619
- }
1620
- const r = globalThis.__sbFetchUpstream("fetch", {
1621
- url,
1622
- method: opts.method || "GET",
1623
- headers,
1624
- body: opts.body == null ? null : String(opts.body),
1625
- });
1626
- const respHeaders = new Headers();
1627
- for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1628
- // #176: bytes from an outbound HTTP response, not a string this handler
1629
- // built -- must not be re-encoded if the handler proxies it straight
1630
- // through (see the assets binding for the same reasoning).
1631
- if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1632
- return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
1633
- };
1634
- }
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
+ };
1635
1671
  };
1636
1672
 
1637
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;
@@ -1042,14 +1150,15 @@ static int sb_tcp_connect(const char* host, int port) {
1042
1150
  return fd;
1043
1151
  }
1044
1152
 
1153
+ // body_len, not strlen(body): a binary body can contain NUL bytes (#232).
1045
1154
  static void sb_build_request(sb_buf* req, const char* host, const char* path, const char* method,
1046
- const char* headers, const char* body) {
1155
+ const char* headers, const char* body, size_t body_len) {
1047
1156
  sb_puts(req, method); sb_puts(req, " "); sb_puts(req, path); sb_puts(req, " HTTP/1.1\r\n");
1048
1157
  sb_puts(req, "Host: "); sb_puts(req, host); sb_puts(req, "\r\n");
1049
1158
  sb_puts(req, "Connection: close\r\n");
1050
1159
  sb_puts(req, "Accept-Encoding: identity\r\n");
1051
1160
  if (headers && *headers) sb_puts(req, headers);
1052
- size_t blen = body ? strlen(body) : 0;
1161
+ size_t blen = body ? body_len : 0;
1053
1162
  if (blen) {
1054
1163
  char cl[64];
1055
1164
  int k = snprintf(cl, sizeof(cl), "Content-Length: %zu\r\n", blen);
@@ -1153,12 +1262,13 @@ static char* sb_error_json(const char* prefix, const char* detail) {
1153
1262
  }
1154
1263
 
1155
1264
  static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
1156
- const char* headers, const char* body) {
1157
- int fd = sb_tcp_connect(host, port);
1158
- if (fd < 0) return sb_error_json("could not connect to ", host);
1265
+ const char* headers, const char* body, size_t body_len) {
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);
1159
1269
 
1160
1270
  sb_buf req = { 0, 0, 0 };
1161
- sb_build_request(&req, host, path, method, headers, body);
1271
+ sb_build_request(&req, host, path, method, headers, body, body_len);
1162
1272
  size_t sent = 0;
1163
1273
  while (sent < req.len) {
1164
1274
  long n = write(fd, req.p + sent, req.len - sent);
@@ -1343,9 +1453,10 @@ static int sb_sock_write(void* ctx, const unsigned char* buf, size_t len) {
1343
1453
  }
1344
1454
 
1345
1455
  static char* sb_https(const char* host, int port, const char* path, const char* method,
1346
- const char* headers, const char* body) {
1347
- int fd = sb_tcp_connect(host, port);
1348
- if (fd < 0) return sb_error_json("could not connect to ", host);
1456
+ const char* headers, const char* body, size_t body_len) {
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);
1349
1460
 
1350
1461
  br_ssl_client_context* sc = (br_ssl_client_context*)malloc(sizeof(br_ssl_client_context));
1351
1462
  br_x509_minimal_context* xc = (br_x509_minimal_context*)malloc(sizeof(br_x509_minimal_context));
@@ -1362,7 +1473,7 @@ static char* sb_https(const char* host, int port, const char* path, const char*
1362
1473
  br_sslio_init(&ioc, &sc->eng, sb_sock_read, &fd, sb_sock_write, &fd);
1363
1474
 
1364
1475
  sb_buf req = { 0, 0, 0 };
1365
- sb_build_request(&req, host, path, method, headers, body);
1476
+ sb_build_request(&req, host, path, method, headers, body, body_len);
1366
1477
  int wrote = br_sslio_write_all(&ioc, req.p, req.len);
1367
1478
  free(req.p);
1368
1479
  if (wrote != 0) {
@@ -1412,9 +1523,9 @@ static char* sb_https(const char* host, int port, const char* path, const char*
1412
1523
  }
1413
1524
 
1414
1525
  static char* sb_http_request(const char* host, int port, const char* path, const char* method,
1415
- const char* headers, const char* body, int tls) {
1416
- return tls ? sb_https(host, port, path, method, headers, body)
1417
- : sb_http_plain(host, port, path, method, headers, body);
1526
+ const char* headers, const char* body, size_t body_len, int tls) {
1527
+ return tls ? sb_https(host, port, path, method, headers, body, body_len)
1528
+ : sb_http_plain(host, port, path, method, headers, body, body_len);
1418
1529
  }
1419
1530
  `;
1420
1531
 
@@ -1697,7 +1808,7 @@ function __sbD1BackupRaw(src, dest) {
1697
1808
  }
1698
1809
 
1699
1810
  // Outbound HTTP. String params so C can read each directly; the JS side has
1700
- // 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" —
1701
1812
  // a string like the rest, so the marshalling stays uniform.
1702
1813
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1703
1814
  function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
@@ -1730,17 +1841,33 @@ function __sbHttpRaw(host, portStr, path, method, headersText, body, tlsFlag) {
1730
1841
  char* __hdrs = (char*)malloc(__hdl + 1); memcpy(__hdrs, __hd, __hdl); __hdrs[__hdl] = 0;
1731
1842
  if (__hdo) free(__hdo);
1732
1843
 
1733
- const char* __b; size_t __bl; char* __bo = 0;
1734
- porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
1735
- char* __body = (char*)malloc(__bl + 1); memcpy(__body, __b, __bl); __body[__bl] = 0;
1736
- if (__bo) free(__bo);
1737
-
1844
+ // tlsFlag is "0"/"1", plus "b" when the body is raw bytes (#233-style flag
1845
+ // for #232's request half): one char per byte, sent as-is, not UTF-8 encoded.
1738
1846
  const char* __t; size_t __tl; char* __to = 0;
1739
1847
  porf_native_fetch_read_value(tlsFlag, &__t, &__tl, &__to);
1740
1848
  int __tls = (__tl > 0 && __t[0] == '1') ? 1 : 0;
1849
+ int __rawBody = (__tl > 1 && __t[1] == 'b') ? 1 : 0;
1741
1850
  if (__to) free(__to);
1742
1851
 
1743
- char* __out = sb_http_request(__host, atoi(__portbuf), __path, __method, __hdrs, __body, __tls);
1852
+ const char* __b; size_t __bl; char* __bo = 0;
1853
+ if (__rawBody && porf_native_fetch_read_raw_bytes(body, &__b, &__bl) == 0) {
1854
+ // a bytestring is already one byte per char
1855
+ } else if (__rawBody && body.type == 67) {
1856
+ // A UTF-16 string (type 67: u32 length, then u16 units) still holds one
1857
+ // byte per unit here; narrow each unit instead of UTF-8 encoding it.
1858
+ const u32 __bp = (u32)body.val;
1859
+ __bl = (size_t)*(u32*)(MEM + __bp);
1860
+ __bo = (char*)malloc(__bl + 1);
1861
+ const u16* __bs = (const u16*)(MEM + __bp + 4);
1862
+ for (size_t __i = 0; __i < __bl; __i++) __bo[__i] = (char)(__bs[__i] & 0xff);
1863
+ __b = __bo;
1864
+ } else {
1865
+ porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
1866
+ }
1867
+ char* __body = (char*)malloc(__bl + 1); memcpy(__body, __b, __bl); __body[__bl] = 0;
1868
+ if (__bo) free(__bo);
1869
+
1870
+ char* __out = sb_http_request(__host, atoi(__portbuf), __path, __method, __hdrs, __body, __bl, __tls);
1744
1871
  free(__host); free(__path); free(__method); free(__hdrs); free(__body);
1745
1872
  if (__out) {
1746
1873
  res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
@@ -1788,12 +1915,6 @@ function __sbSqlRaw(path, sql, paramsJson) {
1788
1915
  // Deliberately the same SQL and the same partition keys as broker.ts. When one
1789
1916
  // changes the other has to, and the conformance suite is what says so.
1790
1917
 
1791
- // The allowlist is baked into the module by wrap.ts; read it lazily so the
1792
- // dispatch has no import-order dependency on __sbInstallBindings.
1793
- function bindingsOutbound() {
1794
- return globalThis.__sbOutbound || [];
1795
- }
1796
-
1797
1918
  var __sbDataDir = "";
1798
1919
  function __sbDir() {
1799
1920
  // SB_DATA_DIR, then SPROUTBOAT_DATA, then <name>.data relative to the working
@@ -1835,6 +1956,10 @@ function __sbEnsureSchema() {
1835
1956
  let hasKvExpiresAt = false;
1836
1957
  for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "expires_at") hasKvExpiresAt = true;
1837
1958
  if (!hasKvExpiresAt) __sbSql(s, "ALTER TABLE kv ADD COLUMN expires_at INTEGER", []);
1959
+ // #233 — 1 when the value was put as bytes, same column as the broker's.
1960
+ let hasKvBinary = false;
1961
+ for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "binary") hasKvBinary = true;
1962
+ if (!hasKvBinary) __sbSql(s, "ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0", []);
1838
1963
  // #56 — no `body` column: an object's bytes live in their own file under
1839
1964
  // "<data-dir>/r2-blobs/" (sb_r2_put_c/sb_r2_get_c), never in the sqlite row.
1840
1965
  __sbSql(
@@ -1928,14 +2053,14 @@ function __sbEmbeddedDispatch(msg) {
1928
2053
  if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
1929
2054
 
1930
2055
  if (op === "kv.get") {
1931
- const r = __sbSql(store, "SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
2056
+ const r = __sbSql(store, "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1932
2057
  if (!r.rows.length) return { ok: true, found: false, value: null };
1933
2058
  const expiresAt = r.rows[0][1];
1934
2059
  if (expiresAt != null && expiresAt <= Date.now()) {
1935
2060
  __sbSql(store, "DELETE FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1936
2061
  return { ok: true, found: false, value: null };
1937
2062
  }
1938
- return { ok: true, found: true, value: r.rows[0][0] };
2063
+ return { ok: true, found: true, value: r.rows[0][0], binary: r.rows[0][2] === 1 };
1939
2064
  }
1940
2065
  if (op === "kv.put") {
1941
2066
  // #58 — CF rejects expirationTtl under 60 seconds; matched here.
@@ -1949,8 +2074,8 @@ function __sbEmbeddedDispatch(msg) {
1949
2074
  }
1950
2075
  __sbSql(
1951
2076
  store,
1952
- "INSERT INTO kv (ns, key, value, expires_at) VALUES (?1,?2,?3,?4) ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
1953
- [msg.ns, msg.key, msg.value, expiresAt],
2077
+ "INSERT INTO kv (ns, key, value, expires_at, binary) VALUES (?1,?2,?3,?4,?5) ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5",
2078
+ [msg.ns, msg.key, msg.value, expiresAt, msg.binary === true ? 1 : 0],
1954
2079
  );
1955
2080
  return { ok: true };
1956
2081
  }
@@ -2027,9 +2152,6 @@ function __sbEmbeddedDispatch(msg) {
2027
2152
  const url = new URL(String(msg.url));
2028
2153
  const tls = url.protocol === "https:";
2029
2154
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
2030
- const allow = bindingsOutbound();
2031
- if (allow.indexOf(url.host) === -1)
2032
- throw new Error("host not in outbound allowlist: " + url.host);
2033
2155
  let headerText = "";
2034
2156
  const pairs = msg.headers || [];
2035
2157
  for (let i = 0; i < pairs.length; i++) {
@@ -2047,7 +2169,7 @@ function __sbEmbeddedDispatch(msg) {
2047
2169
  String(msg.method || "GET").toUpperCase(),
2048
2170
  headerText,
2049
2171
  msg.body == null ? "" : String(msg.body),
2050
- tls ? "1" : "0",
2172
+ (tls ? "1" : "0") + (msg.bodyBinary === true ? "b" : ""),
2051
2173
  ),
2052
2174
  );
2053
2175
  if (reply.ok === false) throw new Error(reply.error);
@@ -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),