@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 +21 -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 +67 -31
- package/src/source.ts +1 -7
- package/src/transport-embedded.js +159 -37
- package/src/url-host.test.js +25 -0
- package/src/wrap.ts +4 -9
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.
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -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 ?
|
|
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
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
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
|
|
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
|
-
|
|
1734
|
-
|
|
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*
|
|
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
|
-
|
|
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),
|