@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/README.md +71 -1
- package/frockbot.json +26 -0
- package/package.json +31 -6
- package/src/agent.test.ts +265 -0
- package/src/agent.ts +504 -0
- package/src/contract.ts +218 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +3 -0
- package/src/ssrf.test.ts +174 -0
- package/src/ssrf.ts +318 -0
- package/tsconfig.json +15 -0
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
|
+
}
|