@dexto/tools-builtins 1.16.6 → 1.17.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.
@@ -23,6 +23,8 @@ __export(http_request_tool_exports, {
23
23
  });
24
24
  module.exports = __toCommonJS(http_request_tool_exports);
25
25
  var import_zod = require("zod");
26
+ var import_core = require("@dexto/core");
27
+ var import_llm = require("@dexto/core/llm");
26
28
  var import_errors = require("@dexto/core/errors");
27
29
  var import_tools = require("@dexto/core/tools");
28
30
  var import_node_dns = require("node:dns");
@@ -54,56 +56,13 @@ const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost"]);
54
56
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
55
57
  const MAX_REDIRECTS = 20;
56
58
  const SAFE_DISPATCHER = createSafeDispatcher();
57
- function isPrivateIpv4(ip) {
58
- const parts = ip.split(".").map((part) => Number(part));
59
- if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
60
- return false;
61
- }
62
- const a = parts[0];
63
- const b = parts[1];
64
- const c = parts[2];
65
- const d = parts[3];
66
- if (a === void 0 || b === void 0 || c === void 0 || d === void 0) {
67
- return false;
68
- }
69
- if (a === 0) return true;
70
- if (a === 10) return true;
71
- if (a === 127) return true;
72
- if (a === 169 && b === 254) return true;
73
- if (a === 172 && b >= 16 && b <= 31) return true;
74
- if (a === 192 && b === 168) return true;
75
- if (a === 100 && b >= 64 && b <= 127) return true;
76
- if (a === 192 && b === 0 && c === 0) return true;
77
- if (a === 192 && b === 0 && c === 2) return true;
78
- if (a === 198 && b === 51 && c === 100) return true;
79
- if (a === 203 && b === 0 && c === 113) return true;
80
- if (a === 198 && (b === 18 || b === 19)) return true;
81
- if (a >= 224 && a <= 239) return true;
82
- if (a >= 240) return true;
83
- return false;
84
- }
85
- function isPrivateIpv6(ip) {
86
- const normalized = new URL(`http://[${ip.split("%")[0]}]/`).hostname.slice(1, -1);
87
- if (normalized === "::" || normalized === "::1") return true;
88
- if (normalized.startsWith("64:ff9b:1:")) return true;
89
- if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true;
90
- if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
91
- if (normalized.startsWith("::ffff:")) {
92
- const parts = normalized.slice("::ffff:".length).split(":");
93
- if (parts.length === 2) {
94
- const high = Number.parseInt(parts[0] ?? "", 16);
95
- const low = Number.parseInt(parts[1] ?? "", 16);
96
- return isPrivateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
97
- }
98
- }
99
- return false;
100
- }
101
- function isPrivateAddress(ip) {
102
- const address = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
103
- const version = (0, import_node_net.isIP)(address);
104
- if (version === 4) return isPrivateIpv4(address);
105
- if (version === 6) return isPrivateIpv6(address);
106
- return false;
59
+ function blockedTargetError(hostname) {
60
+ return new import_errors.DextoRuntimeError(
61
+ "HTTP_REQUEST_UNSAFE_TARGET",
62
+ import_errors.ErrorScope.TOOLS,
63
+ import_errors.ErrorType.FORBIDDEN,
64
+ `Request to ${hostname} is blocked`
65
+ );
107
66
  }
108
67
  function createSafeLookup(config) {
109
68
  const dnsLookup = config?.dnsLookup ?? import_node_dns.promises.lookup;
@@ -122,16 +81,11 @@ function createSafeLookup(config) {
122
81
  };
123
82
  try {
124
83
  if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
125
- emitError(
126
- toErrnoException(
127
- new import_errors.DextoRuntimeError(
128
- "HTTP_REQUEST_UNSAFE_TARGET",
129
- import_errors.ErrorScope.TOOLS,
130
- import_errors.ErrorType.FORBIDDEN,
131
- `Blocked request to local hostname: ${hostname}`
132
- )
133
- )
134
- );
84
+ emitError(toErrnoException(blockedTargetError(hostname)));
85
+ return;
86
+ }
87
+ if ((0, import_core.isMetadataHost)(hostname)) {
88
+ emitError(toErrnoException(blockedTargetError(hostname)));
135
89
  return;
136
90
  }
137
91
  const records = await dnsLookup(hostname, {
@@ -154,17 +108,8 @@ function createSafeLookup(config) {
154
108
  return;
155
109
  }
156
110
  for (const record of records) {
157
- if (isPrivateAddress(record.address)) {
158
- emitError(
159
- toErrnoException(
160
- new import_errors.DextoRuntimeError(
161
- "HTTP_REQUEST_UNSAFE_TARGET",
162
- import_errors.ErrorScope.TOOLS,
163
- import_errors.ErrorType.FORBIDDEN,
164
- `Blocked request to private address: ${record.address}`
165
- )
166
- )
167
- );
111
+ if ((0, import_core.isMetadataHost)(record.address) || (0, import_llm.isPrivateAddress)(record.address)) {
112
+ emitError(toErrnoException(blockedTargetError(hostname)));
168
113
  return;
169
114
  }
170
115
  }
@@ -235,20 +180,10 @@ async function assertSafeUrl(requestUrl) {
235
180
  );
236
181
  }
237
182
  if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
238
- throw new import_errors.DextoRuntimeError(
239
- "HTTP_REQUEST_UNSAFE_TARGET",
240
- import_errors.ErrorScope.TOOLS,
241
- import_errors.ErrorType.FORBIDDEN,
242
- `Blocked request to local hostname: ${hostname}`
243
- );
183
+ throw blockedTargetError(hostname);
244
184
  }
245
- if (isPrivateAddress(hostname)) {
246
- throw new import_errors.DextoRuntimeError(
247
- "HTTP_REQUEST_UNSAFE_TARGET",
248
- import_errors.ErrorScope.TOOLS,
249
- import_errors.ErrorType.FORBIDDEN,
250
- `Blocked request to private address: ${hostname}`
251
- );
185
+ if ((0, import_core.isMetadataHost)(hostname) || (0, import_llm.isPrivateAddress)(hostname)) {
186
+ throw blockedTargetError(hostname);
252
187
  }
253
188
  }
254
189
  async function readResponseTextWithLimit(response) {
@@ -356,11 +291,6 @@ function createHttpRequestTool() {
356
291
  );
357
292
  }
358
293
  const nextUrl = new URL(location, currentUrl);
359
- if (nextUrl.origin !== currentUrl.origin) {
360
- for (const key of Object.keys(requestHeaders)) {
361
- delete requestHeaders[key];
362
- }
363
- }
364
294
  if (response.status === 303 || (response.status === 301 || response.status === 302) && requestInit.method === "POST") {
365
295
  requestInit.method = "GET";
366
296
  delete requestInit.body;
@@ -369,6 +299,19 @@ function createHttpRequestTool() {
369
299
  delete requestHeaders[key];
370
300
  }
371
301
  }
302
+ if (nextUrl.origin !== currentUrl.origin) {
303
+ if (requestInit.body !== void 0) {
304
+ throw new import_errors.DextoRuntimeError(
305
+ "HTTP_REQUEST_REDIRECT_BLOCKED",
306
+ import_errors.ErrorScope.TOOLS,
307
+ import_errors.ErrorType.FORBIDDEN,
308
+ `Blocked a ${response.status} redirect to ${nextUrl.origin}: the request body is not sent to another origin`
309
+ );
310
+ }
311
+ for (const key of Object.keys(requestHeaders)) {
312
+ delete requestHeaders[key];
313
+ }
314
+ }
372
315
  currentUrl = nextUrl;
373
316
  }
374
317
  const responseText = await readResponseTextWithLimit(response);
@@ -1 +1 @@
1
- {"version":3,"file":"http-request-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/http-request-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AAQpE,OAAO,EAAE,QAAQ,IAAI,GAAG,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAKnF,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;kBA2Bf,CAAC;AA+Bd,KAAK,cAAc,GACb,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,GAC9E,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC,CAAC;AA4DhF,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE;IACtC,SAAS,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,CAAC;CACjC,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,CAyI/E;AA2FD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAAC,OAAO,sBAAsB,CAAC,CA4I3E"}
1
+ {"version":3,"file":"http-request-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/http-request-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AAUpE,OAAO,EAAE,QAAQ,IAAI,GAAG,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAKnF,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;kBA2Bf,CAAC;AA+Bd,KAAK,cAAc,GACb,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,GAC9E,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC,CAAC;AAehF,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE;IACtC,SAAS,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,CAAC;CACjC,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,CA4H/E;AAiFD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAAC,OAAO,sBAAsB,CAAC,CAqJ3E"}
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { isMetadataHost } from "@dexto/core";
3
+ import { isPrivateAddress } from "@dexto/core/llm";
2
4
  import { DextoRuntimeError, ErrorScope, ErrorType } from "@dexto/core/errors";
3
5
  import {
4
6
  TOOL_ACTIVITY,
@@ -35,56 +37,13 @@ const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost"]);
35
37
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
36
38
  const MAX_REDIRECTS = 20;
37
39
  const SAFE_DISPATCHER = createSafeDispatcher();
38
- function isPrivateIpv4(ip) {
39
- const parts = ip.split(".").map((part) => Number(part));
40
- if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
41
- return false;
42
- }
43
- const a = parts[0];
44
- const b = parts[1];
45
- const c = parts[2];
46
- const d = parts[3];
47
- if (a === void 0 || b === void 0 || c === void 0 || d === void 0) {
48
- return false;
49
- }
50
- if (a === 0) return true;
51
- if (a === 10) return true;
52
- if (a === 127) return true;
53
- if (a === 169 && b === 254) return true;
54
- if (a === 172 && b >= 16 && b <= 31) return true;
55
- if (a === 192 && b === 168) return true;
56
- if (a === 100 && b >= 64 && b <= 127) return true;
57
- if (a === 192 && b === 0 && c === 0) return true;
58
- if (a === 192 && b === 0 && c === 2) return true;
59
- if (a === 198 && b === 51 && c === 100) return true;
60
- if (a === 203 && b === 0 && c === 113) return true;
61
- if (a === 198 && (b === 18 || b === 19)) return true;
62
- if (a >= 224 && a <= 239) return true;
63
- if (a >= 240) return true;
64
- return false;
65
- }
66
- function isPrivateIpv6(ip) {
67
- const normalized = new URL(`http://[${ip.split("%")[0]}]/`).hostname.slice(1, -1);
68
- if (normalized === "::" || normalized === "::1") return true;
69
- if (normalized.startsWith("64:ff9b:1:")) return true;
70
- if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true;
71
- if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
72
- if (normalized.startsWith("::ffff:")) {
73
- const parts = normalized.slice("::ffff:".length).split(":");
74
- if (parts.length === 2) {
75
- const high = Number.parseInt(parts[0] ?? "", 16);
76
- const low = Number.parseInt(parts[1] ?? "", 16);
77
- return isPrivateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
78
- }
79
- }
80
- return false;
81
- }
82
- function isPrivateAddress(ip) {
83
- const address = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
84
- const version = isIP(address);
85
- if (version === 4) return isPrivateIpv4(address);
86
- if (version === 6) return isPrivateIpv6(address);
87
- return false;
40
+ function blockedTargetError(hostname) {
41
+ return new DextoRuntimeError(
42
+ "HTTP_REQUEST_UNSAFE_TARGET",
43
+ ErrorScope.TOOLS,
44
+ ErrorType.FORBIDDEN,
45
+ `Request to ${hostname} is blocked`
46
+ );
88
47
  }
89
48
  function createSafeLookup(config) {
90
49
  const dnsLookup = config?.dnsLookup ?? dns.lookup;
@@ -103,16 +62,11 @@ function createSafeLookup(config) {
103
62
  };
104
63
  try {
105
64
  if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
106
- emitError(
107
- toErrnoException(
108
- new DextoRuntimeError(
109
- "HTTP_REQUEST_UNSAFE_TARGET",
110
- ErrorScope.TOOLS,
111
- ErrorType.FORBIDDEN,
112
- `Blocked request to local hostname: ${hostname}`
113
- )
114
- )
115
- );
65
+ emitError(toErrnoException(blockedTargetError(hostname)));
66
+ return;
67
+ }
68
+ if (isMetadataHost(hostname)) {
69
+ emitError(toErrnoException(blockedTargetError(hostname)));
116
70
  return;
117
71
  }
118
72
  const records = await dnsLookup(hostname, {
@@ -135,17 +89,8 @@ function createSafeLookup(config) {
135
89
  return;
136
90
  }
137
91
  for (const record of records) {
138
- if (isPrivateAddress(record.address)) {
139
- emitError(
140
- toErrnoException(
141
- new DextoRuntimeError(
142
- "HTTP_REQUEST_UNSAFE_TARGET",
143
- ErrorScope.TOOLS,
144
- ErrorType.FORBIDDEN,
145
- `Blocked request to private address: ${record.address}`
146
- )
147
- )
148
- );
92
+ if (isMetadataHost(record.address) || isPrivateAddress(record.address)) {
93
+ emitError(toErrnoException(blockedTargetError(hostname)));
149
94
  return;
150
95
  }
151
96
  }
@@ -216,20 +161,10 @@ async function assertSafeUrl(requestUrl) {
216
161
  );
217
162
  }
218
163
  if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
219
- throw new DextoRuntimeError(
220
- "HTTP_REQUEST_UNSAFE_TARGET",
221
- ErrorScope.TOOLS,
222
- ErrorType.FORBIDDEN,
223
- `Blocked request to local hostname: ${hostname}`
224
- );
164
+ throw blockedTargetError(hostname);
225
165
  }
226
- if (isPrivateAddress(hostname)) {
227
- throw new DextoRuntimeError(
228
- "HTTP_REQUEST_UNSAFE_TARGET",
229
- ErrorScope.TOOLS,
230
- ErrorType.FORBIDDEN,
231
- `Blocked request to private address: ${hostname}`
232
- );
166
+ if (isMetadataHost(hostname) || isPrivateAddress(hostname)) {
167
+ throw blockedTargetError(hostname);
233
168
  }
234
169
  }
235
170
  async function readResponseTextWithLimit(response) {
@@ -337,11 +272,6 @@ function createHttpRequestTool() {
337
272
  );
338
273
  }
339
274
  const nextUrl = new URL(location, currentUrl);
340
- if (nextUrl.origin !== currentUrl.origin) {
341
- for (const key of Object.keys(requestHeaders)) {
342
- delete requestHeaders[key];
343
- }
344
- }
345
275
  if (response.status === 303 || (response.status === 301 || response.status === 302) && requestInit.method === "POST") {
346
276
  requestInit.method = "GET";
347
277
  delete requestInit.body;
@@ -350,6 +280,19 @@ function createHttpRequestTool() {
350
280
  delete requestHeaders[key];
351
281
  }
352
282
  }
283
+ if (nextUrl.origin !== currentUrl.origin) {
284
+ if (requestInit.body !== void 0) {
285
+ throw new DextoRuntimeError(
286
+ "HTTP_REQUEST_REDIRECT_BLOCKED",
287
+ ErrorScope.TOOLS,
288
+ ErrorType.FORBIDDEN,
289
+ `Blocked a ${response.status} redirect to ${nextUrl.origin}: the request body is not sent to another origin`
290
+ );
291
+ }
292
+ for (const key of Object.keys(requestHeaders)) {
293
+ delete requestHeaders[key];
294
+ }
295
+ }
353
296
  currentUrl = nextUrl;
354
297
  }
355
298
  const responseText = await readResponseTextWithLimit(response);
@@ -52,7 +52,12 @@ var import_http_request_tool = require("./http-request-tool.js");
52
52
  "[0:0:0:0:0:0:0:1]",
53
53
  "[::ffff:127.0.0.1]",
54
54
  "[::ffff:10.0.0.1]",
55
- "[64:ff9b:1::a00:1]"
55
+ "[64:ff9b:1::a00:1]",
56
+ "[64:ff9b::7f00:1]",
57
+ "[fec0::1]",
58
+ "[feff::1]",
59
+ "[ff02::1]",
60
+ "[ff00::1]"
56
61
  ])("blocks private IPv6 literal %s before fetching", async (host) => {
57
62
  const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch");
58
63
  try {
@@ -67,6 +72,32 @@ var import_http_request_tool = require("./http-request-tool.js");
67
72
  fetchSpy.mockRestore();
68
73
  }
69
74
  });
75
+ (0, import_vitest.it)("refuses a name that answers a private address without echoing the answer", async () => {
76
+ const dnsLookup = import_vitest.vi.fn().mockResolvedValue([
77
+ { address: "93.184.216.34", family: 4 },
78
+ { address: "10.20.30.40", family: 4 }
79
+ ]);
80
+ const error = await new Promise((resolve) => {
81
+ (0, import_http_request_tool.createSafeLookup)({ dnsLookup })(
82
+ "files.example.com",
83
+ { all: true },
84
+ ((err) => resolve(err))
85
+ );
86
+ });
87
+ (0, import_vitest.expect)(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
88
+ (0, import_vitest.expect)(error?.message).toBe("Request to files.example.com is blocked");
89
+ });
90
+ import_vitest.it.each(["localhost", "10.0.0.1", "[::1]"])(
91
+ "refuses %s with a message that only names the host",
92
+ async (host) => {
93
+ await (0, import_vitest.expect)(
94
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
95
+ { url: `http://${host}/`, method: "GET", timeoutMs: 1e3 },
96
+ {}
97
+ )
98
+ ).rejects.toMatchObject({ message: `Request to ${host} is blocked` });
99
+ }
100
+ );
70
101
  (0, import_vitest.it)("blocks a redirect to a private literal before the next request", async () => {
71
102
  const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValue(
72
103
  new Response(null, { status: 302, headers: { location: "http://[::1]/secret" } })
@@ -124,4 +155,220 @@ var import_http_request_tool = require("./http-request-tool.js");
124
155
  fetchSpy.mockRestore();
125
156
  }
126
157
  });
158
+ import_vitest.it.each([307, 308])(
159
+ "refuses a %i redirect that would send the body to another origin",
160
+ async (status) => {
161
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
162
+ new Response(null, {
163
+ status,
164
+ headers: { location: "https://other.example/collect" }
165
+ })
166
+ ).mockResolvedValueOnce(new Response("ok"));
167
+ try {
168
+ await (0, import_vitest.expect)(
169
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
170
+ {
171
+ url: "https://example.com/submit",
172
+ method: "POST",
173
+ body: { note: "private text" },
174
+ timeoutMs: 1e3
175
+ },
176
+ {}
177
+ )
178
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
179
+ (0, import_vitest.expect)(fetchSpy).toHaveBeenCalledTimes(1);
180
+ } finally {
181
+ fetchSpy.mockRestore();
182
+ }
183
+ }
184
+ );
185
+ import_vitest.it.each([
186
+ ["PUT", 301],
187
+ ["PATCH", 301],
188
+ ["PUT", 302],
189
+ ["DELETE", 302],
190
+ ["PATCH", 302]
191
+ ])(
192
+ "refuses a %s %i redirect that would send the body to another origin",
193
+ async (method, status) => {
194
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
195
+ new Response(null, {
196
+ status,
197
+ headers: { location: "https://other.example/collect" }
198
+ })
199
+ ).mockResolvedValueOnce(new Response("ok"));
200
+ try {
201
+ await (0, import_vitest.expect)(
202
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
203
+ {
204
+ url: "https://example.com/item",
205
+ method,
206
+ body: "private text",
207
+ timeoutMs: 1e3
208
+ },
209
+ {}
210
+ )
211
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
212
+ (0, import_vitest.expect)(fetchSpy).toHaveBeenCalledTimes(1);
213
+ } finally {
214
+ fetchSpy.mockRestore();
215
+ }
216
+ }
217
+ );
218
+ import_vitest.it.each([301, 302])(
219
+ "still turns a cross-origin %i redirect of a POST into a body-less GET",
220
+ async (status) => {
221
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
222
+ new Response(null, { status, headers: { location: "https://other.example/" } })
223
+ ).mockResolvedValueOnce(new Response("ok"));
224
+ try {
225
+ await (0, import_http_request_tool.createHttpRequestTool)().execute(
226
+ {
227
+ url: "https://example.com/",
228
+ method: "POST",
229
+ body: "payload",
230
+ timeoutMs: 1e3
231
+ },
232
+ {}
233
+ );
234
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "GET" });
235
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[1]).not.toHaveProperty("body");
236
+ } finally {
237
+ fetchSpy.mockRestore();
238
+ }
239
+ }
240
+ );
241
+ import_vitest.it.each([307, 308])("keeps the body on a same-origin %i redirect", async (status) => {
242
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status, headers: { location: "/moved" } })).mockResolvedValueOnce(new Response("ok"));
243
+ try {
244
+ await (0, import_http_request_tool.createHttpRequestTool)().execute(
245
+ {
246
+ url: "https://example.com/submit",
247
+ method: "POST",
248
+ body: "payload",
249
+ timeoutMs: 1e3
250
+ },
251
+ {}
252
+ );
253
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[0]).toBe("https://example.com/moved");
254
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "POST", body: "payload" });
255
+ } finally {
256
+ fetchSpy.mockRestore();
257
+ }
258
+ });
259
+ (0, import_vitest.it)("still follows a cross-origin 307 when there is no body", async () => {
260
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
261
+ new Response(null, {
262
+ status: 307,
263
+ headers: { location: "https://other.example/" }
264
+ })
265
+ ).mockResolvedValueOnce(new Response("ok"));
266
+ try {
267
+ const result = await (0, import_http_request_tool.createHttpRequestTool)().execute(
268
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
269
+ {}
270
+ );
271
+ (0, import_vitest.expect)(result).toMatchObject({ ok: true, body: "ok" });
272
+ } finally {
273
+ fetchSpy.mockRestore();
274
+ }
275
+ });
276
+ });
277
+ (0, import_vitest.describe)("http_request cloud metadata hosts", () => {
278
+ const METADATA_HOSTS = [
279
+ "metadata.google.internal",
280
+ "METADATA.GOOG",
281
+ "instance-data.",
282
+ "instance-data.ec2.internal",
283
+ "169.254.169.254",
284
+ "[fd00:ec2::254]",
285
+ "100.100.100.200",
286
+ // Not private ranges, so only the metadata check refuses them.
287
+ "168.63.129.16",
288
+ "[::ffff:a83f:8110]"
289
+ ];
290
+ import_vitest.it.each(METADATA_HOSTS)("refuses %s before fetching", async (host) => {
291
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch");
292
+ try {
293
+ await (0, import_vitest.expect)(
294
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
295
+ { url: `http://${host}/latest/meta-data/`, method: "GET", timeoutMs: 1e3 },
296
+ {}
297
+ )
298
+ ).rejects.toMatchObject({
299
+ code: "HTTP_REQUEST_UNSAFE_TARGET",
300
+ message: `Request to ${new URL(`http://${host}`).hostname} is blocked`
301
+ });
302
+ (0, import_vitest.expect)(fetchSpy).not.toHaveBeenCalled();
303
+ } finally {
304
+ fetchSpy.mockRestore();
305
+ }
306
+ });
307
+ (0, import_vitest.it)("refuses a redirect to a metadata host name", async () => {
308
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValue(
309
+ new Response(null, {
310
+ status: 302,
311
+ headers: { location: "http://metadata.google.internal/computeMetadata/v1/" }
312
+ })
313
+ );
314
+ try {
315
+ await (0, import_vitest.expect)(
316
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
317
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
318
+ {}
319
+ )
320
+ ).rejects.toMatchObject({
321
+ code: "HTTP_REQUEST_UNSAFE_TARGET",
322
+ message: "Request to metadata.google.internal is blocked"
323
+ });
324
+ (0, import_vitest.expect)(fetchSpy).toHaveBeenCalledTimes(1);
325
+ } finally {
326
+ fetchSpy.mockRestore();
327
+ }
328
+ });
329
+ import_vitest.it.each([
330
+ // Not private ranges, so only the metadata check refuses them.
331
+ "168.63.129.16",
332
+ "::ffff:168.63.129.16",
333
+ // Also private ranges.
334
+ "169.254.169.254",
335
+ "fd00:ec2::254",
336
+ "192.0.0.192",
337
+ "100.100.100.200"
338
+ ])(
339
+ "the connection lookup refuses a public name that answers the metadata address %s",
340
+ async (address) => {
341
+ const dnsLookup = import_vitest.vi.fn().mockResolvedValue([
342
+ { address: "93.184.216.34", family: 4 },
343
+ { address, family: 4 }
344
+ ]);
345
+ const safeLookup = (0, import_http_request_tool.createSafeLookup)({ dnsLookup });
346
+ const error = await new Promise((resolve) => {
347
+ safeLookup(
348
+ "files.example.com",
349
+ { all: true },
350
+ ((err) => resolve(err))
351
+ );
352
+ });
353
+ (0, import_vitest.expect)(dnsLookup).toHaveBeenCalledOnce();
354
+ (0, import_vitest.expect)(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
355
+ (0, import_vitest.expect)(error?.message).toBe("Request to files.example.com is blocked");
356
+ }
357
+ );
358
+ import_vitest.it.each(["metadata.google.internal", "metadata.goog", "instance-data"])(
359
+ "the connection lookup refuses %s without resolving it",
360
+ async (host) => {
361
+ const dnsLookup = import_vitest.vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
362
+ const safeLookup = (0, import_http_request_tool.createSafeLookup)({ dnsLookup });
363
+ const error = await new Promise((resolve) => {
364
+ safeLookup(
365
+ host,
366
+ { all: true },
367
+ ((err) => resolve(err))
368
+ );
369
+ });
370
+ (0, import_vitest.expect)(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
371
+ (0, import_vitest.expect)(dnsLookup).not.toHaveBeenCalled();
372
+ }
373
+ );
127
374
  });