@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 +19 -0
- package/package.json +3 -2
- package/src/egress.test.ts +155 -0
- package/src/egress.ts +156 -0
- package/src/gen-egress.ts +6 -0
- package/src/index.ts +1 -0
- package/src/native-fetch-prelude.js +63 -36
- package/src/source.ts +1 -14
- package/src/transport-embedded.js +118 -17
- package/src/url-host.test.js +25 -0
- package/src/wrap.ts +4 -9
- package/test/native-iterators.ts +103 -0
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.
|
|
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
|
@@ -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
|
|
184
|
+
return __sbCollectionIterator(this, 0, false);
|
|
169
185
|
}
|
|
170
186
|
values() {
|
|
171
|
-
return this
|
|
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
|
|
341
|
+
return __sbCollectionIterator(this, 0, true);
|
|
322
342
|
}
|
|
323
343
|
values() {
|
|
324
|
-
return this
|
|
344
|
+
return __sbCollectionIterator(this, 1, true);
|
|
325
345
|
}
|
|
326
346
|
entries() {
|
|
327
|
-
return this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
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
|
+
}
|