@frockbot/plugin-web 0.0.0 → 0.1.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/src/ssrf.ts ADDED
@@ -0,0 +1,318 @@
1
+ // The outbound trust boundary for every URL a Package fetches on a Bot's
2
+ // behalf: `web_fetch`'s target, and the remote MCP endpoint a User names.
3
+ //
4
+ // The Bot's Durable Object can reach anything workerd can reach, including
5
+ // hosts that only exist inside the platform's own network. Every URL a model
6
+ // hands `web_fetch` — and every `Location` a redirect hands it afterwards —
7
+ // passes through {@link classifyWebFetchUrlV1} first. The classifier is pure:
8
+ // it takes a string and returns a verdict, so it is exhaustively testable and
9
+ // holds no I/O, no cache, and no clock.
10
+ //
11
+ // LIMITATION — DNS rebinding. workerd exposes no resolve-then-connect hook, so
12
+ // a hostname cannot be pinned to the address the request will actually reach.
13
+ // A name that resolves to a public address at classification time and to
14
+ // `127.0.0.1` at connection time defeats every check below. Classification is
15
+ // therefore exact for IP literals and for the known-internal name shapes, and
16
+ // best-effort for everything else. Narrowing it further needs a platform
17
+ // primitive FrockBot does not have; the gap is recorded here rather than
18
+ // papered over.
19
+
20
+ /** The stable refusal codes a classification can produce. */
21
+ export type SsrfRefusalReasonV1 =
22
+ | "ssrf-invalid-url"
23
+ | "ssrf-blocked-scheme"
24
+ | "ssrf-blocked-port"
25
+ | "ssrf-blocked-credentials"
26
+ | "ssrf-blocked-host"
27
+ | "ssrf-blocked-private-address";
28
+
29
+ export type WebUrlClassificationV1 =
30
+ | { allowed: true; url: string; hostname: string }
31
+ | { allowed: false; reason: SsrfRefusalReasonV1; message: string };
32
+
33
+ /** Rule 1: the only scheme a Bot may fetch. */
34
+ const ALLOWED_PROTOCOL = "https:";
35
+
36
+ /** Rule 2: the default port, or the same port stated explicitly. */
37
+ const ALLOWED_PORT = "443";
38
+
39
+ export interface OutboundUrlPolicyV1 {
40
+ /**
41
+ * Allow a port other than 443. `web_fetch` does not: a Bot reading the
42
+ * public web has no business on an arbitrary port, and the rule closes the
43
+ * "public hostname, internal service port" shape. A User-named MCP endpoint
44
+ * does, because a self-hosted server on its own port is an ordinary
45
+ * deployment rather than an attack.
46
+ */
47
+ allowNonDefaultPort?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Rule 3: host names that name the platform rather than the public internet.
52
+ * `localhost` and any label under it; anything under `.internal`, `.local`
53
+ * (mDNS) or `.home.arpa`; and a bare label with no dot at all (`metadata`,
54
+ * `redis`, a Kubernetes service name).
55
+ */
56
+ const BLOCKED_HOST_SUFFIXES = [
57
+ ".localhost",
58
+ ".internal",
59
+ ".local",
60
+ ".home.arpa",
61
+ ] as const;
62
+
63
+ function isBlockedHostName(hostname: string): boolean {
64
+ if (hostname.length === 0) return true;
65
+ if (hostname === "localhost" || hostname === "internal") return true;
66
+ if (hostname === "local" || hostname === "home.arpa") return true;
67
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) {
68
+ return true;
69
+ }
70
+ return !hostname.includes(".");
71
+ }
72
+
73
+ function decimalOrRadix(part: string): number | undefined {
74
+ if (part.length === 0) return undefined;
75
+ if (/^0[xX][0-9a-fA-F]+$/.test(part))
76
+ return Number.parseInt(part.slice(2), 16);
77
+ if (/^0[0-7]+$/.test(part)) return Number.parseInt(part.slice(1), 8);
78
+ if (/^[0-9]+$/.test(part)) return Number.parseInt(part, 10);
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Parse an IPv4 host in every encoding the WHATWG host parser accepts:
84
+ * dotted quad, dotted-decimal short forms, octal (`0177.0.0.1`), hexadecimal
85
+ * (`0x7f000001`), and a bare 32-bit integer (`2130706433`). Returns the four
86
+ * octets, most significant first, or `undefined` when the host is not an IPv4
87
+ * literal at all.
88
+ *
89
+ * Normalizing here rather than trusting `URL` is deliberate: the check must
90
+ * hold for a `Location` header this code resolves itself, and for any future
91
+ * caller that classifies a host string without building a `URL` first.
92
+ */
93
+ export function parseIpv4LiteralV1(host: string): number[] | undefined {
94
+ const trimmed = host.endsWith(".") ? host.slice(0, -1) : host;
95
+ if (trimmed.length === 0) return undefined;
96
+ const parts = trimmed.split(".");
97
+ if (parts.length > 4) return undefined;
98
+ const numbers: number[] = [];
99
+ for (const part of parts) {
100
+ const value = decimalOrRadix(part);
101
+ if (value === undefined || !Number.isSafeInteger(value) || value < 0) {
102
+ return undefined;
103
+ }
104
+ numbers.push(value);
105
+ }
106
+ // Every part but the last is one octet; the last fills the remaining bytes.
107
+ const last = numbers[numbers.length - 1] as number;
108
+ if (last >= 256 ** (5 - numbers.length)) return undefined;
109
+ if (numbers.slice(0, -1).some((value) => value > 255)) return undefined;
110
+ let address = last;
111
+ for (let index = 0; index < numbers.length - 1; index += 1) {
112
+ address += (numbers[index] as number) * 256 ** (3 - index);
113
+ }
114
+ return [
115
+ Math.floor(address / 16777216) % 256,
116
+ Math.floor(address / 65536) % 256,
117
+ Math.floor(address / 256) % 256,
118
+ address % 256,
119
+ ];
120
+ }
121
+
122
+ /**
123
+ * Parse an IPv6 literal, with or without surrounding brackets, including `::`
124
+ * compression and a trailing embedded IPv4 (`::ffff:127.0.0.1`). Returns the
125
+ * sixteen bytes, or `undefined` when the host is not an IPv6 literal.
126
+ */
127
+ export function parseIpv6LiteralV1(host: string): number[] | undefined {
128
+ let text = host;
129
+ if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1);
130
+ const zone = text.indexOf("%");
131
+ if (zone >= 0) text = text.slice(0, zone);
132
+ if (!text.includes(":")) return undefined;
133
+ const halves = text.split("::");
134
+ if (halves.length > 2) return undefined;
135
+ const expand = (segment: string): number[][] | undefined => {
136
+ if (segment.length === 0) return [];
137
+ const groups: number[][] = [];
138
+ const pieces = segment.split(":");
139
+ for (let index = 0; index < pieces.length; index += 1) {
140
+ const piece = pieces[index] as string;
141
+ if (index === pieces.length - 1 && piece.includes(".")) {
142
+ const embedded = parseIpv4LiteralV1(piece);
143
+ if (!embedded) return undefined;
144
+ groups.push([embedded[0] as number, embedded[1] as number]);
145
+ groups.push([embedded[2] as number, embedded[3] as number]);
146
+ continue;
147
+ }
148
+ if (!/^[0-9a-fA-F]{1,4}$/.test(piece)) return undefined;
149
+ const value = Number.parseInt(piece, 16);
150
+ groups.push([Math.floor(value / 256), value % 256]);
151
+ }
152
+ return groups;
153
+ };
154
+ const head = expand(halves[0] as string);
155
+ const tail = halves.length === 2 ? expand(halves[1] as string) : [];
156
+ if (!head || !tail) return undefined;
157
+ if (halves.length === 1) {
158
+ if (head.length !== 8) return undefined;
159
+ return head.flat();
160
+ }
161
+ if (head.length + tail.length > 7) return undefined;
162
+ const middle = Array.from({ length: 8 - head.length - tail.length }, () => [
163
+ 0, 0,
164
+ ]);
165
+ return [...head, ...middle, ...tail].flat();
166
+ }
167
+
168
+ interface Ipv4Block {
169
+ octets: [number, number, number, number];
170
+ prefix: number;
171
+ }
172
+
173
+ /**
174
+ * Rule 4, IPv4 half. Everything here is unreachable from the public internet
175
+ * or reachable only from inside a platform, and `169.254.0.0/16` carries the
176
+ * cloud metadata service at `169.254.169.254`.
177
+ */
178
+ const BLOCKED_IPV4: readonly Ipv4Block[] = [
179
+ { octets: [0, 0, 0, 0], prefix: 8 }, // "this network"
180
+ { octets: [10, 0, 0, 0], prefix: 8 }, // RFC 1918
181
+ { octets: [100, 64, 0, 0], prefix: 10 }, // carrier-grade NAT
182
+ { octets: [127, 0, 0, 0], prefix: 8 }, // loopback
183
+ { octets: [169, 254, 0, 0], prefix: 16 }, // link-local, incl. metadata
184
+ { octets: [172, 16, 0, 0], prefix: 12 }, // RFC 1918
185
+ { octets: [192, 0, 0, 0], prefix: 24 }, // IETF protocol assignments
186
+ { octets: [192, 168, 0, 0], prefix: 16 }, // RFC 1918
187
+ { octets: [198, 18, 0, 0], prefix: 15 }, // benchmarking
188
+ { octets: [224, 0, 0, 0], prefix: 4 }, // multicast
189
+ { octets: [240, 0, 0, 0], prefix: 4 }, // reserved, incl. broadcast
190
+ ];
191
+
192
+ function inIpv4Block(address: readonly number[], block: Ipv4Block): boolean {
193
+ let remaining = block.prefix;
194
+ for (let index = 0; index < 4 && remaining > 0; index += 1) {
195
+ const bits = Math.min(8, remaining);
196
+ const mask = (0xff << (8 - bits)) & 0xff;
197
+ if (((address[index] as number) & mask) !== (block.octets[index] & mask)) {
198
+ return false;
199
+ }
200
+ remaining -= bits;
201
+ }
202
+ return true;
203
+ }
204
+
205
+ export function isBlockedIpv4V1(address: readonly number[]): boolean {
206
+ return BLOCKED_IPV4.some((block) => inIpv4Block(address, block));
207
+ }
208
+
209
+ /**
210
+ * Rule 4, IPv6 half: the unspecified address, loopback, unique-local
211
+ * (`fc00::/7`), link-local (`fe80::/10`), multicast (`ff00::/8`), and both
212
+ * embedded-IPv4 ranges. `::ffff:0:0/96` is refused outright rather than
213
+ * unwrapped: a v4-mapped literal is never a legitimate way to name a public
214
+ * host, and unwrapping it would reintroduce the encoding tricks rule 4 exists
215
+ * to close.
216
+ */
217
+ export function isBlockedIpv6V1(address: readonly number[]): boolean {
218
+ const zeroPrefix = address.slice(0, 10).every((byte) => byte === 0);
219
+ if (zeroPrefix && address.slice(10).every((byte) => byte === 0)) return true;
220
+ if (zeroPrefix && address[10] === 0 && address[11] === 0) {
221
+ // `::` followed by anything: unspecified, loopback, v4-compatible.
222
+ return true;
223
+ }
224
+ if (zeroPrefix && address[10] === 0xff && address[11] === 0xff) return true;
225
+ const first = address[0] as number;
226
+ if ((first & 0xfe) === 0xfc) return true; // fc00::/7
227
+ if (first === 0xfe && ((address[1] as number) & 0xc0) === 0x80) return true; // fe80::/10
228
+ if (first === 0xff) return true; // ff00::/8
229
+ return false;
230
+ }
231
+
232
+ function refuse(
233
+ reason: SsrfRefusalReasonV1,
234
+ message: string,
235
+ ): WebUrlClassificationV1 {
236
+ return { allowed: false, reason, message };
237
+ }
238
+
239
+ /**
240
+ * Classify one candidate URL against SSRF rules 1–5. Redirects re-enter here
241
+ * with each resolved `Location`, so a public URL that redirects to a private
242
+ * one is refused on the hop that names the private address.
243
+ *
244
+ * A refusal never names a resolved address or any other detail the caller did
245
+ * not already supply: the message restates the candidate's own host at most.
246
+ */
247
+ export function classifyOutboundUrlV1(
248
+ candidate: unknown,
249
+ policy: OutboundUrlPolicyV1 = {},
250
+ ): WebUrlClassificationV1 {
251
+ if (typeof candidate !== "string" || candidate.trim().length === 0) {
252
+ return refuse("ssrf-invalid-url", "The url must be an absolute https URL.");
253
+ }
254
+ if (candidate.length > 2048) {
255
+ return refuse("ssrf-invalid-url", "The url is too long.");
256
+ }
257
+ let url: URL;
258
+ try {
259
+ url = new URL(candidate.trim());
260
+ } catch {
261
+ return refuse("ssrf-invalid-url", "The url is not an absolute URL.");
262
+ }
263
+ if (url.protocol !== ALLOWED_PROTOCOL) {
264
+ return refuse(
265
+ "ssrf-blocked-scheme",
266
+ `Only https URLs may be fetched, not ${url.protocol.replace(":", "")}.`,
267
+ );
268
+ }
269
+ if (url.username || url.password) {
270
+ return refuse(
271
+ "ssrf-blocked-credentials",
272
+ "A url carrying credentials is refused.",
273
+ );
274
+ }
275
+ if (
276
+ !policy.allowNonDefaultPort &&
277
+ url.port !== "" &&
278
+ url.port !== ALLOWED_PORT
279
+ ) {
280
+ return refuse("ssrf-blocked-port", "Only the default https port is used.");
281
+ }
282
+ const hostname = url.hostname.toLowerCase();
283
+ const ipv6 = parseIpv6LiteralV1(hostname);
284
+ if (ipv6) {
285
+ return isBlockedIpv6V1(ipv6)
286
+ ? refuse(
287
+ "ssrf-blocked-private-address",
288
+ "That address is not on the public internet.",
289
+ )
290
+ : { allowed: true, url: url.toString(), hostname };
291
+ }
292
+ if (hostname.startsWith("[")) {
293
+ return refuse("ssrf-invalid-url", "The url host is not a valid address.");
294
+ }
295
+ const ipv4 = parseIpv4LiteralV1(hostname);
296
+ if (ipv4) {
297
+ return isBlockedIpv4V1(ipv4)
298
+ ? refuse(
299
+ "ssrf-blocked-private-address",
300
+ "That address is not on the public internet.",
301
+ )
302
+ : { allowed: true, url: url.toString(), hostname };
303
+ }
304
+ if (isBlockedHostName(hostname)) {
305
+ return refuse(
306
+ "ssrf-blocked-host",
307
+ "That host names an internal service rather than a public site.",
308
+ );
309
+ }
310
+ return { allowed: true, url: url.toString(), hostname };
311
+ }
312
+
313
+ /** `web_fetch`'s policy: the default port only. */
314
+ export function classifyWebFetchUrlV1(
315
+ candidate: unknown,
316
+ ): WebUrlClassificationV1 {
317
+ return classifyOutboundUrlV1(candidate);
318
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }