@truefoundry/trueforge-core 0.2.0-rc.6 → 0.2.1
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/dist/agent-session/SessionHandle.d.ts +1 -0
- package/dist/agent-session/SessionHandle.d.ts.map +1 -1
- package/dist/agent-session/SessionHandle.js +1 -0
- package/dist/agent-session/SessionHandle.js.map +1 -1
- package/dist/agent-session/SessionHandle.mjs +1 -0
- package/dist/agent-session/SessionHandle.mjs.map +1 -1
- package/dist/agent-session/models/TurnRecord.d.ts +1 -0
- package/dist/agent-session/models/TurnRecord.d.ts.map +1 -1
- package/dist/agent-session/models/TurnRecord.js.map +1 -1
- package/dist/agent-session/models/TurnRecord.mjs.map +1 -1
- package/dist/core/capabilities/builtins/OpenUI.d.ts.map +1 -1
- package/dist/core/capabilities/builtins/OpenUI.js +4 -2
- package/dist/core/capabilities/builtins/OpenUI.js.map +1 -1
- package/dist/core/capabilities/builtins/OpenUI.mjs +4 -2
- package/dist/core/capabilities/builtins/OpenUI.mjs.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +4 -0
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/llm/VercelAILLM.d.ts.map +1 -1
- package/dist/core/llm/VercelAILLM.js +7 -0
- package/dist/core/llm/VercelAILLM.js.map +1 -1
- package/dist/core/llm/VercelAILLM.mjs +7 -0
- package/dist/core/llm/VercelAILLM.mjs.map +1 -1
- package/dist/core/mcp/RemoteMCP.d.ts +2 -1
- package/dist/core/mcp/RemoteMCP.d.ts.map +1 -1
- package/dist/core/mcp/RemoteMCP.js +65 -20
- package/dist/core/mcp/RemoteMCP.js.map +1 -1
- package/dist/core/mcp/RemoteMCP.mjs +65 -20
- package/dist/core/mcp/RemoteMCP.mjs.map +1 -1
- package/dist/core/mcp/remoteMcpClient.d.ts.map +1 -1
- package/dist/core/mcp/remoteMcpClient.js +2 -5
- package/dist/core/mcp/remoteMcpClient.js.map +1 -1
- package/dist/core/mcp/remoteMcpClient.mjs +2 -5
- package/dist/core/mcp/remoteMcpClient.mjs.map +1 -1
- package/dist/core/runtime/DeferredTool.d.ts.map +1 -1
- package/dist/core/runtime/DeferredTool.js +3 -1
- package/dist/core/runtime/DeferredTool.js.map +1 -1
- package/dist/core/runtime/DeferredTool.mjs +3 -1
- package/dist/core/runtime/DeferredTool.mjs.map +1 -1
- package/dist/core/sandbox/Sandbox.d.ts.map +1 -1
- package/dist/core/sandbox/Sandbox.js +4 -1
- package/dist/core/sandbox/Sandbox.js.map +1 -1
- package/dist/core/sandbox/Sandbox.mjs +4 -1
- package/dist/core/sandbox/Sandbox.mjs.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.d.ts.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.js +19 -6
- package/dist/core/sandbox/provider/TFYSandboxProvider.js.map +1 -1
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs +19 -7
- package/dist/core/sandbox/provider/TFYSandboxProvider.mjs.map +1 -1
- package/dist/core/util/ssrfGuard.d.ts +10 -0
- package/dist/core/util/ssrfGuard.d.ts.map +1 -0
- package/dist/core/util/ssrfGuard.js +333 -0
- package/dist/core/util/ssrfGuard.js.map +1 -0
- package/dist/core/util/ssrfGuard.mjs +305 -0
- package/dist/core/util/ssrfGuard.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/core/util/ssrfGuard.ts
|
|
21
|
+
var ssrfGuard_exports = {};
|
|
22
|
+
__export(ssrfGuard_exports, {
|
|
23
|
+
assertSafeOutboundUrl: () => assertSafeOutboundUrl,
|
|
24
|
+
configureOutboundUrlGuard: () => configureOutboundUrlGuard,
|
|
25
|
+
mcpSsrfFetch: () => mcpSsrfFetch,
|
|
26
|
+
ssrfFetch: () => ssrfFetch
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(ssrfGuard_exports);
|
|
29
|
+
var import_node_dns = require("dns");
|
|
30
|
+
var import_promises = require("dns/promises");
|
|
31
|
+
var import_node_net = require("net");
|
|
32
|
+
var import_undici = require("undici");
|
|
33
|
+
var allowedHosts = [];
|
|
34
|
+
var blockedHosts = [];
|
|
35
|
+
var guardEnabled = true;
|
|
36
|
+
var URL_VERIFY = {
|
|
37
|
+
allowedProtocols: ["http:", "https:"],
|
|
38
|
+
denyCidrsV4: [
|
|
39
|
+
"0.0.0.0/8",
|
|
40
|
+
// this host
|
|
41
|
+
"10.0.0.0/8",
|
|
42
|
+
// private
|
|
43
|
+
"100.64.0.0/10",
|
|
44
|
+
// CGNAT (EKS secondary pod CIDRs)
|
|
45
|
+
"127.0.0.0/8",
|
|
46
|
+
// loopback
|
|
47
|
+
"169.254.0.0/16",
|
|
48
|
+
// link-local + metadata
|
|
49
|
+
"172.16.0.0/12",
|
|
50
|
+
// private (docker, k8s service CIDRs)
|
|
51
|
+
"192.0.0.0/24",
|
|
52
|
+
// IETF protocol assignments
|
|
53
|
+
"192.0.2.0/24",
|
|
54
|
+
// TEST-NET-1
|
|
55
|
+
"192.88.99.0/24",
|
|
56
|
+
// 6to4 relay anycast
|
|
57
|
+
"192.168.0.0/16",
|
|
58
|
+
// private
|
|
59
|
+
"198.18.0.0/15",
|
|
60
|
+
// benchmarking
|
|
61
|
+
"198.51.100.0/24",
|
|
62
|
+
// TEST-NET-2
|
|
63
|
+
"203.0.113.0/24",
|
|
64
|
+
// TEST-NET-3
|
|
65
|
+
"224.0.0.0/4",
|
|
66
|
+
// multicast
|
|
67
|
+
"240.0.0.0/4"
|
|
68
|
+
// reserved + broadcast
|
|
69
|
+
],
|
|
70
|
+
denyCidrsV6: [
|
|
71
|
+
"::/96",
|
|
72
|
+
// unspecified, ::1, IPv4-compatible
|
|
73
|
+
"64:ff9b::/96",
|
|
74
|
+
// NAT64 well-known
|
|
75
|
+
"64:ff9b:1::/48",
|
|
76
|
+
// NAT64 local-use
|
|
77
|
+
"100::/64",
|
|
78
|
+
// discard-only
|
|
79
|
+
"2001::/32",
|
|
80
|
+
// Teredo
|
|
81
|
+
"2001:10::/28",
|
|
82
|
+
// ORCHID
|
|
83
|
+
"2001:20::/28",
|
|
84
|
+
// ORCHIDv2
|
|
85
|
+
"2001:db8::/32",
|
|
86
|
+
// documentation
|
|
87
|
+
"2002::/16",
|
|
88
|
+
// 6to4
|
|
89
|
+
"fc00::/7",
|
|
90
|
+
// unique-local (IPv6 k8s service CIDRs)
|
|
91
|
+
"fe80::/10",
|
|
92
|
+
// link-local
|
|
93
|
+
"ff00::/8"
|
|
94
|
+
// multicast
|
|
95
|
+
],
|
|
96
|
+
denyHostSuffixes: [
|
|
97
|
+
".local",
|
|
98
|
+
".localhost",
|
|
99
|
+
".localdomain",
|
|
100
|
+
".internal",
|
|
101
|
+
".svc",
|
|
102
|
+
".cluster",
|
|
103
|
+
".arpa",
|
|
104
|
+
".lan",
|
|
105
|
+
".intranet",
|
|
106
|
+
".corp",
|
|
107
|
+
".home",
|
|
108
|
+
".test",
|
|
109
|
+
".invalid",
|
|
110
|
+
".example"
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
var privateNets = new import_node_net.BlockList();
|
|
114
|
+
function addDenyCidrs(cidrs, family) {
|
|
115
|
+
for (const cidr of cidrs) {
|
|
116
|
+
const slash = cidr.lastIndexOf("/");
|
|
117
|
+
privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV4, "ipv4");
|
|
121
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV6, "ipv6");
|
|
122
|
+
var MAX_REDIRECTS = 20;
|
|
123
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
124
|
+
var CROSS_ORIGIN_STRIPPED_HEADERS = ["authorization", "proxy-authorization", "cookie", "host"];
|
|
125
|
+
function configureOutboundUrlGuard(config) {
|
|
126
|
+
guardEnabled = config.enabled ?? true;
|
|
127
|
+
allowedHosts = config.allowedHosts.map(normalizeHost);
|
|
128
|
+
blockedHosts = config.blockedHosts.map(normalizeHost);
|
|
129
|
+
}
|
|
130
|
+
function normalizeHost(hostname) {
|
|
131
|
+
const host = hostname.replace(/\.$/, "").toLowerCase();
|
|
132
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
133
|
+
}
|
|
134
|
+
function isPrivateIp(address) {
|
|
135
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
136
|
+
if ((0, import_node_net.isIP)(ip) === 4) {
|
|
137
|
+
return privateNets.check(ip, "ipv4");
|
|
138
|
+
}
|
|
139
|
+
if ((0, import_node_net.isIP)(ip) === 6) {
|
|
140
|
+
return privateNets.check(ip, "ipv6");
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
function blockedError(host, cause) {
|
|
145
|
+
return new Error(`Outbound URL blocked for host "${host}"`, { cause });
|
|
146
|
+
}
|
|
147
|
+
function deny(host, cause) {
|
|
148
|
+
throw blockedError(host, cause);
|
|
149
|
+
}
|
|
150
|
+
function assertHost(host) {
|
|
151
|
+
if (host === "" || blockedHosts.includes(host)) {
|
|
152
|
+
deny(host);
|
|
153
|
+
}
|
|
154
|
+
if (allowedHosts.includes(host)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if ((0, import_node_net.isIP)(host) === 0) {
|
|
158
|
+
if (!host.includes(".") || URL_VERIFY.denyHostSuffixes.some((suffix) => host.endsWith(suffix))) {
|
|
159
|
+
deny(host);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (isPrivateIp(host)) {
|
|
164
|
+
deny(host);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function parseOutboundUrl(input) {
|
|
168
|
+
let url;
|
|
169
|
+
try {
|
|
170
|
+
url = new URL(input instanceof Request ? input.url : input);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
173
|
+
}
|
|
174
|
+
if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) {
|
|
175
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
176
|
+
}
|
|
177
|
+
return url;
|
|
178
|
+
}
|
|
179
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
180
|
+
if (!guardEnabled) {
|
|
181
|
+
(0, import_node_dns.lookup)(hostname, options, callback);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const host = normalizeHost(hostname);
|
|
185
|
+
try {
|
|
186
|
+
assertHost(host);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
callback(error instanceof Error ? error : blockedError(host, error), "");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (allowedHosts.includes(host) || (0, import_node_net.isIP)(host) !== 0) {
|
|
192
|
+
(0, import_node_dns.lookup)(hostname, options, callback);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const allOptions = { ...options, all: true };
|
|
196
|
+
(0, import_node_dns.lookup)(hostname, allOptions, (err, addresses) => {
|
|
197
|
+
if (err) {
|
|
198
|
+
callback(err, "");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const first = addresses[0];
|
|
202
|
+
if (first === void 0 || addresses.some((record) => isPrivateIp(record.address))) {
|
|
203
|
+
callback(blockedError(host), "");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (options.all) {
|
|
207
|
+
callback(null, addresses);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
callback(null, first.address, first.family);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
var MCP_BODY_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
214
|
+
var outboundAgent = new import_undici.Agent({
|
|
215
|
+
connect: { lookup: guardedLookup }
|
|
216
|
+
});
|
|
217
|
+
var mcpOutboundAgent = new import_undici.Agent({
|
|
218
|
+
// MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it.
|
|
219
|
+
bodyTimeout: MCP_BODY_TIMEOUT_MS,
|
|
220
|
+
connect: { lookup: guardedLookup }
|
|
221
|
+
});
|
|
222
|
+
async function assertSafeOutboundUrl(input) {
|
|
223
|
+
const url = parseOutboundUrl(input);
|
|
224
|
+
if (!guardEnabled) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const host = normalizeHost(url.hostname);
|
|
228
|
+
assertHost(host);
|
|
229
|
+
if (allowedHosts.includes(host) || (0, import_node_net.isIP)(host) !== 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let addresses;
|
|
233
|
+
try {
|
|
234
|
+
addresses = (await (0, import_promises.lookup)(host, { all: true })).map((record) => record.address);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
deny(host, error);
|
|
237
|
+
}
|
|
238
|
+
if (addresses.some(isPrivateIp)) {
|
|
239
|
+
deny(host);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function nextHop(response, location, current, init) {
|
|
243
|
+
let nextUrl;
|
|
244
|
+
try {
|
|
245
|
+
nextUrl = new URL(location, current);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
248
|
+
}
|
|
249
|
+
if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) {
|
|
250
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
251
|
+
}
|
|
252
|
+
const headers = new Headers(init.headers);
|
|
253
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
254
|
+
let body = init.body ?? null;
|
|
255
|
+
const downgradesToGet = (response.status === 301 || response.status === 302) && method === "POST" || response.status === 303 && method !== "GET" && method !== "HEAD";
|
|
256
|
+
if (downgradesToGet) {
|
|
257
|
+
method = "GET";
|
|
258
|
+
body = null;
|
|
259
|
+
headers.delete("content-encoding");
|
|
260
|
+
headers.delete("content-language");
|
|
261
|
+
headers.delete("content-location");
|
|
262
|
+
headers.delete("content-type");
|
|
263
|
+
headers.delete("content-length");
|
|
264
|
+
}
|
|
265
|
+
if (nextUrl.origin !== current.origin) {
|
|
266
|
+
for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) {
|
|
267
|
+
headers.delete(header);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { url: nextUrl, init: { ...init, method, headers, body } };
|
|
271
|
+
}
|
|
272
|
+
function mergeRequestInit(input, init) {
|
|
273
|
+
if (!(input instanceof Request)) {
|
|
274
|
+
return init;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
method: input.method,
|
|
278
|
+
headers: input.headers,
|
|
279
|
+
body: input.body,
|
|
280
|
+
signal: input.signal,
|
|
281
|
+
redirect: input.redirect,
|
|
282
|
+
...init
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async function guardedFetch(input, init, hopsLeft, agent) {
|
|
286
|
+
const url = parseOutboundUrl(input);
|
|
287
|
+
if (guardEnabled) {
|
|
288
|
+
assertHost(normalizeHost(url.hostname));
|
|
289
|
+
}
|
|
290
|
+
const merged = mergeRequestInit(input, init);
|
|
291
|
+
const redirect = merged.redirect ?? "follow";
|
|
292
|
+
const followsRedirects = guardEnabled && redirect === "follow";
|
|
293
|
+
const requestInit = {
|
|
294
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
295
|
+
dispatcher: agent
|
|
296
|
+
};
|
|
297
|
+
Object.assign(requestInit, merged, {
|
|
298
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
299
|
+
dispatcher: agent,
|
|
300
|
+
...merged.body != null && typeof merged.body === "object" && "getReader" in merged.body ? { duplex: "half" } : {}
|
|
301
|
+
});
|
|
302
|
+
const response = await (0, import_undici.fetch)(url.href, requestInit);
|
|
303
|
+
if (!followsRedirects) {
|
|
304
|
+
return response;
|
|
305
|
+
}
|
|
306
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
307
|
+
return response;
|
|
308
|
+
}
|
|
309
|
+
const location = response.headers.get("location");
|
|
310
|
+
if (location === null) {
|
|
311
|
+
return response;
|
|
312
|
+
}
|
|
313
|
+
void response.body?.cancel().catch(() => void 0);
|
|
314
|
+
if (hopsLeft === 0) {
|
|
315
|
+
throw new Error("Outbound URL blocked: too many redirects");
|
|
316
|
+
}
|
|
317
|
+
const hop = nextHop(response, location, url, merged);
|
|
318
|
+
return guardedFetch(hop.url, hop.init, hopsLeft - 1, agent);
|
|
319
|
+
}
|
|
320
|
+
async function ssrfFetch(input, init) {
|
|
321
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, outboundAgent);
|
|
322
|
+
}
|
|
323
|
+
async function mcpSsrfFetch(input, init) {
|
|
324
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, mcpOutboundAgent);
|
|
325
|
+
}
|
|
326
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
327
|
+
0 && (module.exports = {
|
|
328
|
+
assertSafeOutboundUrl,
|
|
329
|
+
configureOutboundUrlGuard,
|
|
330
|
+
mcpSsrfFetch,
|
|
331
|
+
ssrfFetch
|
|
332
|
+
});
|
|
333
|
+
//# sourceMappingURL=ssrfGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/core/util/ssrfGuard.ts"],"sourcesContent":["import type { LookupAllOptions, LookupOptions } from 'node:dns';\nimport { lookup as dnsLookup } from 'node:dns';\nimport { lookup as dnsLookupAsync } from 'node:dns/promises';\nimport { BlockList, isIP, type LookupFunction } from 'node:net';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\nlet allowedHosts: string[] = [];\nlet blockedHosts: string[] = [];\nlet guardEnabled = true;\n\nconst URL_VERIFY = {\n allowedProtocols: ['http:', 'https:'],\n denyCidrsV4: [\n '0.0.0.0/8', // this host\n '10.0.0.0/8', // private\n '100.64.0.0/10', // CGNAT (EKS secondary pod CIDRs)\n '127.0.0.0/8', // loopback\n '169.254.0.0/16', // link-local + metadata\n '172.16.0.0/12', // private (docker, k8s service CIDRs)\n '192.0.0.0/24', // IETF protocol assignments\n '192.0.2.0/24', // TEST-NET-1\n '192.88.99.0/24', // 6to4 relay anycast\n '192.168.0.0/16', // private\n '198.18.0.0/15', // benchmarking\n '198.51.100.0/24', // TEST-NET-2\n '203.0.113.0/24', // TEST-NET-3\n '224.0.0.0/4', // multicast\n '240.0.0.0/4', // reserved + broadcast\n ],\n denyCidrsV6: [\n '::/96', // unspecified, ::1, IPv4-compatible\n '64:ff9b::/96', // NAT64 well-known\n '64:ff9b:1::/48', // NAT64 local-use\n '100::/64', // discard-only\n '2001::/32', // Teredo\n '2001:10::/28', // ORCHID\n '2001:20::/28', // ORCHIDv2\n '2001:db8::/32', // documentation\n '2002::/16', // 6to4\n 'fc00::/7', // unique-local (IPv6 k8s service CIDRs)\n 'fe80::/10', // link-local\n 'ff00::/8', // multicast\n ],\n denyHostSuffixes: [\n '.local',\n '.localhost',\n '.localdomain',\n '.internal',\n '.svc',\n '.cluster',\n '.arpa',\n '.lan',\n '.intranet',\n '.corp',\n '.home',\n '.test',\n '.invalid',\n '.example',\n ],\n};\n\nconst privateNets = new BlockList();\nfunction addDenyCidrs(cidrs: readonly string[], family: 'ipv4' | 'ipv6'): void {\n for (const cidr of cidrs) {\n const slash = cidr.lastIndexOf('/');\n privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family);\n }\n}\naddDenyCidrs(URL_VERIFY.denyCidrsV4, 'ipv4');\naddDenyCidrs(URL_VERIFY.denyCidrsV6, 'ipv6');\n\nconst MAX_REDIRECTS = 20;\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);\nconst CROSS_ORIGIN_STRIPPED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'host'];\n\nexport function configureOutboundUrlGuard(config: {\n enabled?: boolean;\n allowedHosts: readonly string[];\n blockedHosts: readonly string[];\n}): void {\n guardEnabled = config.enabled ?? true;\n allowedHosts = config.allowedHosts.map(normalizeHost);\n blockedHosts = config.blockedHosts.map(normalizeHost);\n}\n\nfunction normalizeHost(hostname: string): string {\n const host = hostname.replace(/\\.$/, '').toLowerCase();\n return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;\n}\n\nfunction isPrivateIp(address: string): boolean {\n const ip = address.replace(/^::ffff:/i, '');\n if (isIP(ip) === 4) {\n return privateNets.check(ip, 'ipv4');\n }\n if (isIP(ip) === 6) {\n return privateNets.check(ip, 'ipv6');\n }\n return true;\n}\n\nfunction blockedError(host: string, cause?: unknown): Error {\n return new Error(`Outbound URL blocked for host \"${host}\"`, { cause });\n}\n\nfunction deny(host: string, cause?: unknown): never {\n throw blockedError(host, cause);\n}\n\nfunction assertHost(host: string): void {\n if (host === '' || blockedHosts.includes(host)) {\n deny(host);\n }\n if (allowedHosts.includes(host)) {\n return;\n }\n if (isIP(host) === 0) {\n if (!host.includes('.') || URL_VERIFY.denyHostSuffixes.some(suffix => host.endsWith(suffix))) {\n deny(host);\n }\n return;\n }\n if (isPrivateIp(host)) {\n deny(host);\n }\n}\n\nfunction parseOutboundUrl(input: string | URL | Request): URL {\n let url: URL;\n try {\n url = new URL(input instanceof Request ? input.url : input);\n } catch (error) {\n throw new Error('Outbound URL blocked', { cause: error });\n }\n if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) {\n throw new Error('Outbound URL blocked: only http and https are allowed');\n }\n return url;\n}\n\n/** undici runs this as the socket lookup, so the addresses we allow are the ones connected to. */\nconst guardedLookup: LookupFunction = (hostname, options: LookupOptions, callback) => {\n if (!guardEnabled) {\n dnsLookup(hostname, options, callback);\n return;\n }\n const host = normalizeHost(hostname);\n try {\n assertHost(host);\n } catch (error) {\n callback(error instanceof Error ? error : blockedError(host, error), '');\n return;\n }\n if (allowedHosts.includes(host) || isIP(host) !== 0) {\n dnsLookup(hostname, options, callback);\n return;\n }\n const allOptions: LookupAllOptions = { ...options, all: true };\n dnsLookup(hostname, allOptions, (err, addresses) => {\n if (err) {\n callback(err, '');\n return;\n }\n const first = addresses[0];\n if (first === undefined || addresses.some(record => isPrivateIp(record.address))) {\n callback(blockedError(host), '');\n return;\n }\n if (options.all) {\n callback(null, addresses);\n return;\n }\n callback(null, first.address, first.family);\n });\n};\n\nconst MCP_BODY_TIMEOUT_MS = 30 * 60 * 1000;\n\nconst outboundAgent = new Agent({\n connect: { lookup: guardedLookup },\n});\n\nconst mcpOutboundAgent = new Agent({\n // MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it.\n bodyTimeout: MCP_BODY_TIMEOUT_MS,\n connect: { lookup: guardedLookup },\n});\n\nexport async function assertSafeOutboundUrl(input: string | URL | Request): Promise<void> {\n const url = parseOutboundUrl(input);\n if (!guardEnabled) {\n return;\n }\n const host = normalizeHost(url.hostname);\n assertHost(host);\n if (allowedHosts.includes(host) || isIP(host) !== 0) {\n return;\n }\n let addresses: string[];\n try {\n addresses = (await dnsLookupAsync(host, { all: true })).map(record => record.address);\n } catch (error) {\n deny(host, error);\n }\n if (addresses.some(isPrivateIp)) {\n deny(host);\n }\n}\n\nfunction nextHop(\n response: Response,\n location: string,\n current: URL,\n init: RequestInit,\n): { url: URL; init: RequestInit } {\n let nextUrl: URL;\n try {\n nextUrl = new URL(location, current);\n } catch (error) {\n throw new Error('Outbound URL blocked', { cause: error });\n }\n if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) {\n throw new Error('Outbound URL blocked: only http and https are allowed');\n }\n\n const headers = new Headers(init.headers);\n let method = (init.method ?? 'GET').toUpperCase();\n let body = init.body ?? null;\n const downgradesToGet =\n ((response.status === 301 || response.status === 302) && method === 'POST') ||\n (response.status === 303 && method !== 'GET' && method !== 'HEAD');\n if (downgradesToGet) {\n method = 'GET';\n body = null;\n headers.delete('content-encoding');\n headers.delete('content-language');\n headers.delete('content-location');\n headers.delete('content-type');\n headers.delete('content-length');\n }\n if (nextUrl.origin !== current.origin) {\n for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) {\n headers.delete(header);\n }\n }\n return { url: nextUrl, init: { ...init, method, headers, body } };\n}\n\nfunction mergeRequestInit(input: string | URL | Request, init: RequestInit): RequestInit {\n if (!(input instanceof Request)) {\n return init;\n }\n return {\n method: input.method,\n headers: input.headers,\n body: input.body,\n signal: input.signal,\n redirect: input.redirect,\n ...init,\n };\n}\n\nasync function guardedFetch(\n input: string | URL | Request,\n init: RequestInit,\n hopsLeft: number,\n agent: Agent,\n): Promise<Response> {\n const url = parseOutboundUrl(input);\n if (guardEnabled) {\n assertHost(normalizeHost(url.hostname));\n }\n const merged = mergeRequestInit(input, init);\n const redirect = merged.redirect ?? 'follow';\n const followsRedirects = guardEnabled && redirect === 'follow';\n const requestInit = {\n redirect: followsRedirects ? 'manual' : redirect,\n dispatcher: agent,\n };\n // npm undici vs @types/node undici-types: FormData/Headers do not line up under exactOptionalPropertyTypes.\n Object.assign(requestInit, merged, {\n redirect: followsRedirects ? 'manual' : redirect,\n dispatcher: agent,\n ...(merged.body != null && typeof merged.body === 'object' && 'getReader' in merged.body\n ? { duplex: 'half' as const }\n : {}),\n });\n const response = await undiciFetch(url.href, requestInit);\n if (!followsRedirects) {\n return response;\n }\n if (!REDIRECT_STATUSES.has(response.status)) {\n return response;\n }\n const location = response.headers.get('location');\n if (location === null) {\n return response;\n }\n void response.body?.cancel().catch(() => undefined);\n if (hopsLeft === 0) {\n throw new Error('Outbound URL blocked: too many redirects');\n }\n const hop = nextHop(response, location, url, merged);\n return guardedFetch(hop.url, hop.init, hopsLeft - 1, agent);\n}\n\nexport async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {\n return guardedFetch(input, init ?? {}, MAX_REDIRECTS, outboundAgent);\n}\n\n/** Same as `ssrfFetch` with a 30m bodyTimeout for idle MCP SSE / streamable-HTTP. */\nexport async function mcpSsrfFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {\n return guardedFetch(input, init ?? {}, MAX_REDIRECTS, mcpOutboundAgent);\n}\n"],"mappings":"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","names":["dnsLookup","dnsLookupAsync","undiciFetch"]}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// src/core/util/ssrfGuard.ts
|
|
2
|
+
import { lookup as dnsLookup } from "dns";
|
|
3
|
+
import { lookup as dnsLookupAsync } from "dns/promises";
|
|
4
|
+
import { BlockList, isIP } from "net";
|
|
5
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
6
|
+
var allowedHosts = [];
|
|
7
|
+
var blockedHosts = [];
|
|
8
|
+
var guardEnabled = true;
|
|
9
|
+
var URL_VERIFY = {
|
|
10
|
+
allowedProtocols: ["http:", "https:"],
|
|
11
|
+
denyCidrsV4: [
|
|
12
|
+
"0.0.0.0/8",
|
|
13
|
+
// this host
|
|
14
|
+
"10.0.0.0/8",
|
|
15
|
+
// private
|
|
16
|
+
"100.64.0.0/10",
|
|
17
|
+
// CGNAT (EKS secondary pod CIDRs)
|
|
18
|
+
"127.0.0.0/8",
|
|
19
|
+
// loopback
|
|
20
|
+
"169.254.0.0/16",
|
|
21
|
+
// link-local + metadata
|
|
22
|
+
"172.16.0.0/12",
|
|
23
|
+
// private (docker, k8s service CIDRs)
|
|
24
|
+
"192.0.0.0/24",
|
|
25
|
+
// IETF protocol assignments
|
|
26
|
+
"192.0.2.0/24",
|
|
27
|
+
// TEST-NET-1
|
|
28
|
+
"192.88.99.0/24",
|
|
29
|
+
// 6to4 relay anycast
|
|
30
|
+
"192.168.0.0/16",
|
|
31
|
+
// private
|
|
32
|
+
"198.18.0.0/15",
|
|
33
|
+
// benchmarking
|
|
34
|
+
"198.51.100.0/24",
|
|
35
|
+
// TEST-NET-2
|
|
36
|
+
"203.0.113.0/24",
|
|
37
|
+
// TEST-NET-3
|
|
38
|
+
"224.0.0.0/4",
|
|
39
|
+
// multicast
|
|
40
|
+
"240.0.0.0/4"
|
|
41
|
+
// reserved + broadcast
|
|
42
|
+
],
|
|
43
|
+
denyCidrsV6: [
|
|
44
|
+
"::/96",
|
|
45
|
+
// unspecified, ::1, IPv4-compatible
|
|
46
|
+
"64:ff9b::/96",
|
|
47
|
+
// NAT64 well-known
|
|
48
|
+
"64:ff9b:1::/48",
|
|
49
|
+
// NAT64 local-use
|
|
50
|
+
"100::/64",
|
|
51
|
+
// discard-only
|
|
52
|
+
"2001::/32",
|
|
53
|
+
// Teredo
|
|
54
|
+
"2001:10::/28",
|
|
55
|
+
// ORCHID
|
|
56
|
+
"2001:20::/28",
|
|
57
|
+
// ORCHIDv2
|
|
58
|
+
"2001:db8::/32",
|
|
59
|
+
// documentation
|
|
60
|
+
"2002::/16",
|
|
61
|
+
// 6to4
|
|
62
|
+
"fc00::/7",
|
|
63
|
+
// unique-local (IPv6 k8s service CIDRs)
|
|
64
|
+
"fe80::/10",
|
|
65
|
+
// link-local
|
|
66
|
+
"ff00::/8"
|
|
67
|
+
// multicast
|
|
68
|
+
],
|
|
69
|
+
denyHostSuffixes: [
|
|
70
|
+
".local",
|
|
71
|
+
".localhost",
|
|
72
|
+
".localdomain",
|
|
73
|
+
".internal",
|
|
74
|
+
".svc",
|
|
75
|
+
".cluster",
|
|
76
|
+
".arpa",
|
|
77
|
+
".lan",
|
|
78
|
+
".intranet",
|
|
79
|
+
".corp",
|
|
80
|
+
".home",
|
|
81
|
+
".test",
|
|
82
|
+
".invalid",
|
|
83
|
+
".example"
|
|
84
|
+
]
|
|
85
|
+
};
|
|
86
|
+
var privateNets = new BlockList();
|
|
87
|
+
function addDenyCidrs(cidrs, family) {
|
|
88
|
+
for (const cidr of cidrs) {
|
|
89
|
+
const slash = cidr.lastIndexOf("/");
|
|
90
|
+
privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV4, "ipv4");
|
|
94
|
+
addDenyCidrs(URL_VERIFY.denyCidrsV6, "ipv6");
|
|
95
|
+
var MAX_REDIRECTS = 20;
|
|
96
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
97
|
+
var CROSS_ORIGIN_STRIPPED_HEADERS = ["authorization", "proxy-authorization", "cookie", "host"];
|
|
98
|
+
function configureOutboundUrlGuard(config) {
|
|
99
|
+
guardEnabled = config.enabled ?? true;
|
|
100
|
+
allowedHosts = config.allowedHosts.map(normalizeHost);
|
|
101
|
+
blockedHosts = config.blockedHosts.map(normalizeHost);
|
|
102
|
+
}
|
|
103
|
+
function normalizeHost(hostname) {
|
|
104
|
+
const host = hostname.replace(/\.$/, "").toLowerCase();
|
|
105
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
106
|
+
}
|
|
107
|
+
function isPrivateIp(address) {
|
|
108
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
109
|
+
if (isIP(ip) === 4) {
|
|
110
|
+
return privateNets.check(ip, "ipv4");
|
|
111
|
+
}
|
|
112
|
+
if (isIP(ip) === 6) {
|
|
113
|
+
return privateNets.check(ip, "ipv6");
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
function blockedError(host, cause) {
|
|
118
|
+
return new Error(`Outbound URL blocked for host "${host}"`, { cause });
|
|
119
|
+
}
|
|
120
|
+
function deny(host, cause) {
|
|
121
|
+
throw blockedError(host, cause);
|
|
122
|
+
}
|
|
123
|
+
function assertHost(host) {
|
|
124
|
+
if (host === "" || blockedHosts.includes(host)) {
|
|
125
|
+
deny(host);
|
|
126
|
+
}
|
|
127
|
+
if (allowedHosts.includes(host)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (isIP(host) === 0) {
|
|
131
|
+
if (!host.includes(".") || URL_VERIFY.denyHostSuffixes.some((suffix) => host.endsWith(suffix))) {
|
|
132
|
+
deny(host);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (isPrivateIp(host)) {
|
|
137
|
+
deny(host);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function parseOutboundUrl(input) {
|
|
141
|
+
let url;
|
|
142
|
+
try {
|
|
143
|
+
url = new URL(input instanceof Request ? input.url : input);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
146
|
+
}
|
|
147
|
+
if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) {
|
|
148
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
149
|
+
}
|
|
150
|
+
return url;
|
|
151
|
+
}
|
|
152
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
153
|
+
if (!guardEnabled) {
|
|
154
|
+
dnsLookup(hostname, options, callback);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const host = normalizeHost(hostname);
|
|
158
|
+
try {
|
|
159
|
+
assertHost(host);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
callback(error instanceof Error ? error : blockedError(host, error), "");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (allowedHosts.includes(host) || isIP(host) !== 0) {
|
|
165
|
+
dnsLookup(hostname, options, callback);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const allOptions = { ...options, all: true };
|
|
169
|
+
dnsLookup(hostname, allOptions, (err, addresses) => {
|
|
170
|
+
if (err) {
|
|
171
|
+
callback(err, "");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const first = addresses[0];
|
|
175
|
+
if (first === void 0 || addresses.some((record) => isPrivateIp(record.address))) {
|
|
176
|
+
callback(blockedError(host), "");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (options.all) {
|
|
180
|
+
callback(null, addresses);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
callback(null, first.address, first.family);
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
var MCP_BODY_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
187
|
+
var outboundAgent = new Agent({
|
|
188
|
+
connect: { lookup: guardedLookup }
|
|
189
|
+
});
|
|
190
|
+
var mcpOutboundAgent = new Agent({
|
|
191
|
+
// MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it.
|
|
192
|
+
bodyTimeout: MCP_BODY_TIMEOUT_MS,
|
|
193
|
+
connect: { lookup: guardedLookup }
|
|
194
|
+
});
|
|
195
|
+
async function assertSafeOutboundUrl(input) {
|
|
196
|
+
const url = parseOutboundUrl(input);
|
|
197
|
+
if (!guardEnabled) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const host = normalizeHost(url.hostname);
|
|
201
|
+
assertHost(host);
|
|
202
|
+
if (allowedHosts.includes(host) || isIP(host) !== 0) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let addresses;
|
|
206
|
+
try {
|
|
207
|
+
addresses = (await dnsLookupAsync(host, { all: true })).map((record) => record.address);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
deny(host, error);
|
|
210
|
+
}
|
|
211
|
+
if (addresses.some(isPrivateIp)) {
|
|
212
|
+
deny(host);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function nextHop(response, location, current, init) {
|
|
216
|
+
let nextUrl;
|
|
217
|
+
try {
|
|
218
|
+
nextUrl = new URL(location, current);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw new Error("Outbound URL blocked", { cause: error });
|
|
221
|
+
}
|
|
222
|
+
if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) {
|
|
223
|
+
throw new Error("Outbound URL blocked: only http and https are allowed");
|
|
224
|
+
}
|
|
225
|
+
const headers = new Headers(init.headers);
|
|
226
|
+
let method = (init.method ?? "GET").toUpperCase();
|
|
227
|
+
let body = init.body ?? null;
|
|
228
|
+
const downgradesToGet = (response.status === 301 || response.status === 302) && method === "POST" || response.status === 303 && method !== "GET" && method !== "HEAD";
|
|
229
|
+
if (downgradesToGet) {
|
|
230
|
+
method = "GET";
|
|
231
|
+
body = null;
|
|
232
|
+
headers.delete("content-encoding");
|
|
233
|
+
headers.delete("content-language");
|
|
234
|
+
headers.delete("content-location");
|
|
235
|
+
headers.delete("content-type");
|
|
236
|
+
headers.delete("content-length");
|
|
237
|
+
}
|
|
238
|
+
if (nextUrl.origin !== current.origin) {
|
|
239
|
+
for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) {
|
|
240
|
+
headers.delete(header);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { url: nextUrl, init: { ...init, method, headers, body } };
|
|
244
|
+
}
|
|
245
|
+
function mergeRequestInit(input, init) {
|
|
246
|
+
if (!(input instanceof Request)) {
|
|
247
|
+
return init;
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
method: input.method,
|
|
251
|
+
headers: input.headers,
|
|
252
|
+
body: input.body,
|
|
253
|
+
signal: input.signal,
|
|
254
|
+
redirect: input.redirect,
|
|
255
|
+
...init
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function guardedFetch(input, init, hopsLeft, agent) {
|
|
259
|
+
const url = parseOutboundUrl(input);
|
|
260
|
+
if (guardEnabled) {
|
|
261
|
+
assertHost(normalizeHost(url.hostname));
|
|
262
|
+
}
|
|
263
|
+
const merged = mergeRequestInit(input, init);
|
|
264
|
+
const redirect = merged.redirect ?? "follow";
|
|
265
|
+
const followsRedirects = guardEnabled && redirect === "follow";
|
|
266
|
+
const requestInit = {
|
|
267
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
268
|
+
dispatcher: agent
|
|
269
|
+
};
|
|
270
|
+
Object.assign(requestInit, merged, {
|
|
271
|
+
redirect: followsRedirects ? "manual" : redirect,
|
|
272
|
+
dispatcher: agent,
|
|
273
|
+
...merged.body != null && typeof merged.body === "object" && "getReader" in merged.body ? { duplex: "half" } : {}
|
|
274
|
+
});
|
|
275
|
+
const response = await undiciFetch(url.href, requestInit);
|
|
276
|
+
if (!followsRedirects) {
|
|
277
|
+
return response;
|
|
278
|
+
}
|
|
279
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
280
|
+
return response;
|
|
281
|
+
}
|
|
282
|
+
const location = response.headers.get("location");
|
|
283
|
+
if (location === null) {
|
|
284
|
+
return response;
|
|
285
|
+
}
|
|
286
|
+
void response.body?.cancel().catch(() => void 0);
|
|
287
|
+
if (hopsLeft === 0) {
|
|
288
|
+
throw new Error("Outbound URL blocked: too many redirects");
|
|
289
|
+
}
|
|
290
|
+
const hop = nextHop(response, location, url, merged);
|
|
291
|
+
return guardedFetch(hop.url, hop.init, hopsLeft - 1, agent);
|
|
292
|
+
}
|
|
293
|
+
async function ssrfFetch(input, init) {
|
|
294
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, outboundAgent);
|
|
295
|
+
}
|
|
296
|
+
async function mcpSsrfFetch(input, init) {
|
|
297
|
+
return guardedFetch(input, init ?? {}, MAX_REDIRECTS, mcpOutboundAgent);
|
|
298
|
+
}
|
|
299
|
+
export {
|
|
300
|
+
assertSafeOutboundUrl,
|
|
301
|
+
configureOutboundUrlGuard,
|
|
302
|
+
mcpSsrfFetch,
|
|
303
|
+
ssrfFetch
|
|
304
|
+
};
|
|
305
|
+
//# sourceMappingURL=ssrfGuard.mjs.map
|