@jami-studio/core 0.92.25 → 0.92.27

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.
@@ -1,306 +1,363 @@
1
- const METADATA_HOSTS = [
2
- "metadata.google.internal",
3
- "metadata.google.internal.",
4
- ];
5
-
6
- const DNS_REBIND_SUFFIXES = [
7
- ".nip.io",
8
- ".sslip.io",
9
- ".xip.io",
10
- ".localtest.me",
11
- ".lvh.me",
12
- ];
13
-
14
- function isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {
15
- if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;
16
- if (a === 127) return true;
17
- if (a === 10) return true;
18
- if (a === 172 && b >= 16 && b <= 31) return true;
19
- if (a === 192 && b === 168) return true;
20
- if (a === 169 && b === 254) return true;
21
- if (a === 0) return true;
22
- if (a === 100 && b >= 64 && b <= 127) return true;
23
- if (a === 192 && b === 0) return true;
24
- if (a === 198 && (b === 18 || b === 19)) return true;
25
- if (a === 192 && b === 0 && c === 2) return true;
26
- if (a === 198 && b === 51 && c === 100) return true;
27
- if (a === 203 && b === 0 && c === 113) return true;
28
- if (a >= 224) return true;
29
- return false;
30
- }
31
-
32
- function isPrivateIpv4MappedHex(host: string): boolean {
33
- const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
34
- if (!mapped) return false;
35
- const high = Number.parseInt(mapped[1], 16);
36
- const low = Number.parseInt(mapped[2], 16);
37
- if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;
38
- const a = (high >> 8) & 0xff;
39
- const b = high & 0xff;
40
- const c = (low >> 8) & 0xff;
41
- const d = low & 0xff;
42
- return isPrivateIpv4(a, b, c, d);
43
- }
44
-
45
- function isPrivateHost(hostname: string): boolean {
46
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
47
- if (
48
- host === "localhost" ||
49
- host === "::1" ||
50
- host === "::0" ||
51
- host === "::"
52
- ) {
53
- return true;
54
- }
55
- if (METADATA_HOSTS.includes(host)) return true;
56
-
57
- // IPv6 ULA/link-local/multicast.
58
- if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
59
- if (/^ff/i.test(host)) return true;
60
-
61
- // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes
62
- // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.
63
- const v4mappedDotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
64
- if (v4mappedDotted) {
65
- const [a, b, c, d] = v4mappedDotted[1].split(".").map(Number);
66
- if (isPrivateIpv4(a, b, c, d)) return true;
67
- }
68
- if (isPrivateIpv4MappedHex(host)) return true;
69
-
70
- // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to
71
- // dotted decimal before we reach this point.
72
- const parts = host.split(".");
73
- if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
74
- const [a, b, c, d] = parts.map(Number);
75
- if (isPrivateIpv4(a, b, c, d)) return true;
76
- }
77
-
78
- // Decimal integer IPv4.
79
- if (/^\d+$/.test(host)) {
80
- const num = Number(host);
81
- if (num >= 0 && num <= 0xffffffff) {
82
- const a = (num >>> 24) & 0xff;
83
- const b = (num >>> 16) & 0xff;
84
- const c = (num >>> 8) & 0xff;
85
- const d = num & 0xff;
86
- if (isPrivateIpv4(a, b, c, d)) return true;
87
- }
88
- }
89
-
90
- return false;
91
- }
92
-
93
- export function isBlockedExtensionUrl(url: string): boolean {
94
- try {
95
- const parsed = new URL(url);
96
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
97
- return true;
98
- }
99
- const host = parsed.hostname.toLowerCase();
100
- if (isPrivateHost(host)) return true;
101
- if (
102
- DNS_REBIND_SUFFIXES.some((suffix) => {
103
- const bare = suffix.slice(1);
104
- return host === bare || host.endsWith(suffix);
105
- })
106
- ) {
107
- return true;
108
- }
109
- } catch {
110
- return true;
111
- }
112
- return false;
113
- }
114
-
115
- function isIpLiteralHost(hostname: string): boolean {
116
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
117
- if (host.includes(":")) return true;
118
- const parts = host.split(".");
119
- return parts.length === 4 && parts.every((p) => /^\d+$/.test(p));
120
- }
121
-
122
- /**
123
- * Async SSRF guard for environments that can resolve DNS. The synchronous
124
- * guard catches literals and known rebinding domains; this closes the common
125
- * "public hostname resolves to a private address" gap before dispatch.
126
- */
127
- export async function isBlockedExtensionUrlWithDns(
128
- url: string,
129
- ): Promise<boolean> {
130
- if (isBlockedExtensionUrl(url)) return true;
131
-
132
- let hostname: string;
133
- try {
134
- hostname = new URL(url).hostname.toLowerCase();
135
- } catch {
136
- return true;
137
- }
138
- if (!hostname || isIpLiteralHost(hostname)) return false;
139
-
140
- try {
141
- const { lookup } = await import("node:dns/promises");
142
- const records = await lookup(hostname, { all: true, verbatim: true });
143
- return records.some((record) => isPrivateHost(record.address));
144
- } catch {
145
- // Some edge runtimes do not expose DNS lookup. Keep the deterministic
146
- // parser-based protections instead of failing every outbound request.
147
- return false;
148
- }
149
- }
150
-
151
- /**
152
- * Build an undici Dispatcher whose connect-time DNS lookup runs through a
153
- * private-IP guard. This closes the TOCTOU gap where:
154
- * 1. We resolve hostname public IP and pass.
155
- * 2. Between that lookup and the actual connect, DNS rebinding flips the
156
- * record to a private IP.
157
- * 3. fetch() resolves again and connects to the private IP.
158
- *
159
- * With a custom dispatcher, the same lookup that produces the IP also gates
160
- * the connect: if the IP is in the private set, the connect throws.
161
- *
162
- * Returns `null` if undici / node:dns are not available (e.g. some edge
163
- * runtimes); the caller should fall back to the regular `fetch` path —
164
- * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.
165
- */
166
- export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
167
- // Keep the optional undici import opaque to Vite/Rolldown. A static
168
- // `import("undici")` makes browser builds try to resolve and bundle undici
169
- // even though this dispatcher is only useful in Node server runtimes.
170
- let undici: any;
171
- let dnsModule: any;
172
- try {
173
- const runtimeImport = new Function(
174
- "specifier",
175
- "return import(specifier)",
176
- ) as (specifier: string) => Promise<any>;
177
- undici = await runtimeImport("undici");
178
- dnsModule = await import("node:dns");
179
- } catch {
180
- return null;
181
- }
182
-
183
- const { Agent } = undici;
184
- const { lookup } = dnsModule;
185
- if (!Agent || !lookup) return null;
186
-
187
- return new Agent({
188
- connect: {
189
- // Override DNS lookup at connect time so the IP we hand to undici's
190
- // socket is the one we authorized. Reject any record in the private
191
- // set BEFORE the TCP handshake.
192
- lookup: (
193
- hostname: string,
194
- options: any,
195
- callback: (
196
- err: NodeJS.ErrnoException | null,
197
- address?: string | { address: string; family: number }[],
198
- family?: number,
199
- ) => void,
200
- ) => {
201
- lookup(
202
- hostname,
203
- { all: true, verbatim: true },
204
- (err: NodeJS.ErrnoException | null, addresses: any) => {
205
- if (err) return callback(err);
206
- const list: { address: string; family: number }[] = Array.isArray(
207
- addresses,
208
- )
209
- ? addresses
210
- : [{ address: addresses, family: 4 }];
211
- for (const record of list) {
212
- if (isPrivateHost(record.address)) {
213
- const e = new Error(
214
- `Connect blocked: ${hostname} resolved to private address ${record.address}`,
215
- ) as NodeJS.ErrnoException;
216
- e.code = "EAI_BLOCKED";
217
- return callback(e);
218
- }
219
- }
220
- // Mirror Node's lookup behavior: when `all` is true, return the
221
- // array; otherwise the first entry. undici's connect honors
222
- // `options.all`.
223
- if (options && options.all) {
224
- return callback(null, list as any);
225
- }
226
- const first = list[0];
227
- return callback(null, first.address, first.family);
228
- },
229
- );
230
- },
231
- },
232
- });
233
- }
234
-
235
- /**
236
- * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
237
- *
238
- * Applies the same protections the extension proxy uses, so every call site
239
- * that fetches an untrusted URL gets them without re-implementing the loop:
240
- * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
241
- * on the initial URL and on every redirect hop.
242
- * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
243
- * time (closes the DNS-rebinding TOCTOU) when undici is available.
244
- * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
245
- * private network because each hop is re-validated before it is followed.
246
- *
247
- * Throws an Error whose message starts with "SSRF blocked:" when a target
248
- * (initial or via redirect) resolves to a private/internal address, or when the
249
- * redirect limit is exceeded. Otherwise returns the final Response.
250
- *
251
- * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
252
- * followed only to `https:` targets, so an HTTPS-only caller cannot be
253
- * downgraded to plain HTTP by a 30x from the (untrusted) origin.
254
- */
255
- export async function ssrfSafeFetch(
256
- url: string,
257
- init: RequestInit = {},
258
- options: { maxRedirects?: number; httpsOnly?: boolean } = {},
259
- ): Promise<Response> {
260
- const maxRedirects = options.maxRedirects ?? 3;
261
- const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
262
-
263
- let currentUrl = url;
264
- for (let hop = 0; hop <= maxRedirects; hop++) {
265
- if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
266
- throw new Error(
267
- `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
268
- );
269
- }
270
- if (await isBlockedExtensionUrlWithDns(currentUrl)) {
271
- throw new Error(
272
- `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
273
- );
274
- }
275
- const fetchOpts: RequestInit & { dispatcher?: unknown } = {
276
- ...init,
277
- redirect: "manual",
278
- };
279
- if (dispatcher) fetchOpts.dispatcher = dispatcher;
280
-
281
- const response = await fetch(currentUrl, fetchOpts);
282
- if (response.status >= 300 && response.status < 400) {
283
- const location = response.headers.get("location");
284
- if (!location) return response;
285
- // Drain the redirect body so the hop's connection is released instead
286
- // of being held until GC.
287
- await response.body?.cancel().catch(() => {});
288
- currentUrl = new URL(location, currentUrl).href;
289
- continue;
290
- }
291
- return response;
292
- }
293
- throw new Error(
294
- `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,
295
- );
296
- }
297
-
298
- // ─────────────────────────────────────────────────────────────────────────────
299
- // Legacy aliases — predate the Tools → Extensions rename. Templates import
300
- // these via the legacy `@agent-native/core/tools/url-safety` subpath; keep
301
- // the names exported so they keep resolving until every consumer updates.
302
- // ─────────────────────────────────────────────────────────────────────────────
303
-
304
- export { isBlockedExtensionUrl as isBlockedToolUrl };
305
- export { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };
306
- export { ssrfSafeFetch as ssrfSafeToolFetch };
1
+ const METADATA_HOSTS = [
2
+ "metadata.google.internal",
3
+ "metadata.google.internal.",
4
+ ];
5
+
6
+ const DNS_REBIND_SUFFIXES = [
7
+ ".nip.io",
8
+ ".sslip.io",
9
+ ".xip.io",
10
+ ".localtest.me",
11
+ ".lvh.me",
12
+ ];
13
+
14
+ function isPrivateIpv4(a: number, b: number, c = 0, d = 0): boolean {
15
+ if (![a, b, c, d].every((part) => part >= 0 && part <= 255)) return true;
16
+ if (a === 127) return true;
17
+ if (a === 10) return true;
18
+ if (a === 172 && b >= 16 && b <= 31) return true;
19
+ if (a === 192 && b === 168) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 0) return true;
22
+ if (a === 100 && b >= 64 && b <= 127) return true;
23
+ if (a === 192 && b === 0) return true;
24
+ if (a === 198 && (b === 18 || b === 19)) return true;
25
+ if (a === 192 && b === 0 && c === 2) return true;
26
+ if (a === 198 && b === 51 && c === 100) return true;
27
+ if (a === 203 && b === 0 && c === 113) return true;
28
+ if (a >= 224) return true;
29
+ return false;
30
+ }
31
+
32
+ function isPrivateIpv4MappedHex(host: string): boolean {
33
+ const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
34
+ if (!mapped) return false;
35
+ const high = Number.parseInt(mapped[1], 16);
36
+ const low = Number.parseInt(mapped[2], 16);
37
+ if (high < 0 || high > 0xffff || low < 0 || low > 0xffff) return false;
38
+ const a = (high >> 8) & 0xff;
39
+ const b = high & 0xff;
40
+ const c = (low >> 8) & 0xff;
41
+ const d = low & 0xff;
42
+ return isPrivateIpv4(a, b, c, d);
43
+ }
44
+
45
+ function isPrivateHost(hostname: string): boolean {
46
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
47
+ if (
48
+ host === "localhost" ||
49
+ host === "::1" ||
50
+ host === "::0" ||
51
+ host === "::"
52
+ ) {
53
+ return true;
54
+ }
55
+ if (METADATA_HOSTS.includes(host)) return true;
56
+
57
+ // IPv6 ULA/link-local/multicast.
58
+ if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
59
+ if (/^ff/i.test(host)) return true;
60
+
61
+ // IPv4-mapped IPv6. URL parsing may preserve dotted form in some runtimes
62
+ // or normalize it to hex, e.g. [::ffff:127.0.0.1] -> ::ffff:7f00:1.
63
+ const v4mappedDotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
64
+ if (v4mappedDotted) {
65
+ const [a, b, c, d] = v4mappedDotted[1].split(".").map(Number);
66
+ if (isPrivateIpv4(a, b, c, d)) return true;
67
+ }
68
+ if (isPrivateIpv4MappedHex(host)) return true;
69
+
70
+ // Dotted IPv4. URL parsing normalizes shorthand/octal/hex IPv4 forms to
71
+ // dotted decimal before we reach this point.
72
+ const parts = host.split(".");
73
+ if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
74
+ const [a, b, c, d] = parts.map(Number);
75
+ if (isPrivateIpv4(a, b, c, d)) return true;
76
+ }
77
+
78
+ // Decimal integer IPv4.
79
+ if (/^\d+$/.test(host)) {
80
+ const num = Number(host);
81
+ if (num >= 0 && num <= 0xffffffff) {
82
+ const a = (num >>> 24) & 0xff;
83
+ const b = (num >>> 16) & 0xff;
84
+ const c = (num >>> 8) & 0xff;
85
+ const d = num & 0xff;
86
+ if (isPrivateIpv4(a, b, c, d)) return true;
87
+ }
88
+ }
89
+
90
+ return false;
91
+ }
92
+
93
+ export function isBlockedExtensionUrl(url: string): boolean {
94
+ try {
95
+ const parsed = new URL(url);
96
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
97
+ return true;
98
+ }
99
+ const host = parsed.hostname.toLowerCase();
100
+ if (isPrivateHost(host)) return true;
101
+ if (
102
+ DNS_REBIND_SUFFIXES.some((suffix) => {
103
+ const bare = suffix.slice(1);
104
+ return host === bare || host.endsWith(suffix);
105
+ })
106
+ ) {
107
+ return true;
108
+ }
109
+ } catch {
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ function isIpLiteralHost(hostname: string): boolean {
116
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
117
+ if (host.includes(":")) return true;
118
+ const parts = host.split(".");
119
+ return parts.length === 4 && parts.every((p) => /^\d+$/.test(p));
120
+ }
121
+
122
+ /**
123
+ * Async SSRF guard for environments that can resolve DNS. The synchronous
124
+ * guard catches literals and known rebinding domains; this closes the common
125
+ * "public hostname resolves to a private address" gap before dispatch.
126
+ */
127
+ export async function isBlockedExtensionUrlWithDns(
128
+ url: string,
129
+ ): Promise<boolean> {
130
+ if (isBlockedExtensionUrl(url)) return true;
131
+
132
+ let hostname: string;
133
+ try {
134
+ hostname = new URL(url).hostname.toLowerCase();
135
+ } catch {
136
+ return true;
137
+ }
138
+ if (!hostname || isIpLiteralHost(hostname)) return false;
139
+
140
+ try {
141
+ const { lookup } = await import("node:dns/promises");
142
+ const records = await lookup(hostname, { all: true, verbatim: true });
143
+ return records.some((record) => isPrivateHost(record.address));
144
+ } catch {
145
+ // Some edge runtimes do not expose DNS lookup. Keep the deterministic
146
+ // parser-based protections instead of failing every outbound request.
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Origins the deployment itself declares as its own surfaces. These come from
153
+ * operator-controlled configuration (env), NOT from user/agent input, so a
154
+ * fetch to them is a self-call, not an SSRF: cross-app A2A on a workspace
155
+ * (call-agent) targets sibling apps at the deployment's own base URL, and in
156
+ * local dev / self-hosted private networks that origin is a loopback or
157
+ * RFC-1918 address the private-address guard would otherwise block — which
158
+ * broke every workspace-internal agent call on `wrangler pages dev` and the
159
+ * dev gateway. Only EXACT origin matches (scheme + host + port) are trusted;
160
+ * everything else keeps the full SSRF policy (including redirect hops).
161
+ */
162
+ export function trustedInternalOrigins(): Set<string> {
163
+ const origins = new Set<string>();
164
+ const addUrl = (value: unknown) => {
165
+ if (typeof value !== "string" || !value.trim()) return;
166
+ try {
167
+ origins.add(new URL(value).origin);
168
+ } catch {
169
+ // Ignore malformed configured URLs they never matched anything.
170
+ }
171
+ };
172
+ const env = (globalThis as any)?.process?.env ?? {};
173
+ addUrl(env.APP_URL);
174
+ addUrl(env.BETTER_AUTH_URL);
175
+ addUrl(env.WEBHOOK_BASE_URL);
176
+ addUrl(env.WORKSPACE_GATEWAY_URL);
177
+ const manifestJson = env.AGENT_NATIVE_WORKSPACE_APPS_JSON;
178
+ if (typeof manifestJson === "string" && manifestJson.trim()) {
179
+ try {
180
+ const apps = JSON.parse(manifestJson);
181
+ if (Array.isArray(apps)) {
182
+ for (const app of apps) addUrl(app?.url);
183
+ }
184
+ } catch {
185
+ // Malformed manifest no trusted origins from it.
186
+ }
187
+ }
188
+ return origins;
189
+ }
190
+
191
+ /** True when `url`'s origin exactly matches a configured internal origin. */
192
+ export function isTrustedInternalUrl(url: string): boolean {
193
+ try {
194
+ return trustedInternalOrigins().has(new URL(url).origin);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build an undici Dispatcher whose connect-time DNS lookup runs through a
202
+ * private-IP guard. This closes the TOCTOU gap where:
203
+ * 1. We resolve hostname → public IP and pass.
204
+ * 2. Between that lookup and the actual connect, DNS rebinding flips the
205
+ * record to a private IP.
206
+ * 3. fetch() resolves again and connects to the private IP.
207
+ *
208
+ * With a custom dispatcher, the same lookup that produces the IP also gates
209
+ * the connect: if the IP is in the private set, the connect throws.
210
+ *
211
+ * Returns `null` if undici / node:dns are not available (e.g. some edge
212
+ * runtimes); the caller should fall back to the regular `fetch` path —
213
+ * `isBlockedExtensionUrlWithDns` will still have caught most rebinding cases.
214
+ */
215
+ export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
216
+ // Keep the optional undici import opaque to Vite/Rolldown. A static
217
+ // `import("undici")` makes browser builds try to resolve and bundle undici
218
+ // even though this dispatcher is only useful in Node server runtimes.
219
+ let undici: any;
220
+ let dnsModule: any;
221
+ try {
222
+ const runtimeImport = new Function(
223
+ "specifier",
224
+ "return import(specifier)",
225
+ ) as (specifier: string) => Promise<any>;
226
+ undici = await runtimeImport("undici");
227
+ dnsModule = await import("node:dns");
228
+ } catch {
229
+ return null;
230
+ }
231
+
232
+ const { Agent } = undici;
233
+ const { lookup } = dnsModule;
234
+ if (!Agent || !lookup) return null;
235
+
236
+ return new Agent({
237
+ connect: {
238
+ // Override DNS lookup at connect time so the IP we hand to undici's
239
+ // socket is the one we authorized. Reject any record in the private
240
+ // set BEFORE the TCP handshake.
241
+ lookup: (
242
+ hostname: string,
243
+ options: any,
244
+ callback: (
245
+ err: NodeJS.ErrnoException | null,
246
+ address?: string | { address: string; family: number }[],
247
+ family?: number,
248
+ ) => void,
249
+ ) => {
250
+ lookup(
251
+ hostname,
252
+ { all: true, verbatim: true },
253
+ (err: NodeJS.ErrnoException | null, addresses: any) => {
254
+ if (err) return callback(err);
255
+ const list: { address: string; family: number }[] = Array.isArray(
256
+ addresses,
257
+ )
258
+ ? addresses
259
+ : [{ address: addresses, family: 4 }];
260
+ for (const record of list) {
261
+ if (isPrivateHost(record.address)) {
262
+ const e = new Error(
263
+ `Connect blocked: ${hostname} resolved to private address ${record.address}`,
264
+ ) as NodeJS.ErrnoException;
265
+ e.code = "EAI_BLOCKED";
266
+ return callback(e);
267
+ }
268
+ }
269
+ // Mirror Node's lookup behavior: when `all` is true, return the
270
+ // array; otherwise the first entry. undici's connect honors
271
+ // `options.all`.
272
+ if (options && options.all) {
273
+ return callback(null, list as any);
274
+ }
275
+ const first = list[0];
276
+ return callback(null, first.address, first.family);
277
+ },
278
+ );
279
+ },
280
+ },
281
+ });
282
+ }
283
+
284
+ /**
285
+ * SSRF-safe `fetch` for any server-side request to a user/agent-supplied URL.
286
+ *
287
+ * Applies the same protections the extension proxy uses, so every call site
288
+ * that fetches an untrusted URL gets them without re-implementing the loop:
289
+ * 1. Pre-flight DNS-aware private-address check (isBlockedExtensionUrlWithDns)
290
+ * on the initial URL and on every redirect hop.
291
+ * 2. A connect-time dispatcher that re-checks the resolved IP at TCP-connect
292
+ * time (closes the DNS-rebinding TOCTOU) when undici is available.
293
+ * 3. Manual redirect handling — a public URL cannot 30x-redirect into the
294
+ * private network because each hop is re-validated before it is followed.
295
+ *
296
+ * Throws an Error whose message starts with "SSRF blocked:" when a target
297
+ * (initial or via redirect) resolves to a private/internal address, or when the
298
+ * redirect limit is exceeded. Otherwise returns the final Response.
299
+ *
300
+ * `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
301
+ * followed only to `https:` targets, so an HTTPS-only caller cannot be
302
+ * downgraded to plain HTTP by a 30x from the (untrusted) origin.
303
+ */
304
+ export async function ssrfSafeFetch(
305
+ url: string,
306
+ init: RequestInit = {},
307
+ options: { maxRedirects?: number; httpsOnly?: boolean } = {},
308
+ ): Promise<Response> {
309
+ const maxRedirects = options.maxRedirects ?? 3;
310
+ const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
311
+
312
+ let currentUrl = url;
313
+ for (let hop = 0; hop <= maxRedirects; hop++) {
314
+ if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
315
+ throw new Error(
316
+ `SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
317
+ );
318
+ }
319
+ // The deployment's own configured origins are self-calls (workspace A2A,
320
+ // self-dispatch), not attacker-controlled targets — skip only the
321
+ // private-address block for exact origin matches. Each redirect hop is
322
+ // still re-validated, so a trusted origin cannot 30x into the private
323
+ // network.
324
+ const trustedHop = isTrustedInternalUrl(currentUrl);
325
+ if (!trustedHop && (await isBlockedExtensionUrlWithDns(currentUrl))) {
326
+ throw new Error(
327
+ `SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
328
+ );
329
+ }
330
+ const fetchOpts: RequestInit & { dispatcher?: unknown } = {
331
+ ...init,
332
+ redirect: "manual",
333
+ };
334
+ // The connect-time guard would also reject a trusted internal origin's
335
+ // loopback/private IP at the TCP layer — attach it only to untrusted hops.
336
+ if (dispatcher && !trustedHop) fetchOpts.dispatcher = dispatcher;
337
+
338
+ const response = await fetch(currentUrl, fetchOpts);
339
+ if (response.status >= 300 && response.status < 400) {
340
+ const location = response.headers.get("location");
341
+ if (!location) return response;
342
+ // Drain the redirect body so the hop's connection is released instead
343
+ // of being held until GC.
344
+ await response.body?.cancel().catch(() => {});
345
+ currentUrl = new URL(location, currentUrl).href;
346
+ continue;
347
+ }
348
+ return response;
349
+ }
350
+ throw new Error(
351
+ `SSRF blocked: too many redirects (>${maxRedirects}) while fetching ${url}`,
352
+ );
353
+ }
354
+
355
+ // ─────────────────────────────────────────────────────────────────────────────
356
+ // Legacy aliases — predate the Tools → Extensions rename. Templates import
357
+ // these via the legacy `@agent-native/core/tools/url-safety` subpath; keep
358
+ // the names exported so they keep resolving until every consumer updates.
359
+ // ─────────────────────────────────────────────────────────────────────────────
360
+
361
+ export { isBlockedExtensionUrl as isBlockedToolUrl };
362
+ export { isBlockedExtensionUrlWithDns as isBlockedToolUrlWithDns };
363
+ export { ssrfSafeFetch as ssrfSafeToolFetch };