@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.
- package/dist/implementations/delegate-to-url-tool.cjs +61 -7
- package/dist/implementations/delegate-to-url-tool.d.ts +5 -1
- package/dist/implementations/delegate-to-url-tool.d.ts.map +1 -1
- package/dist/implementations/delegate-to-url-tool.js +61 -7
- package/dist/implementations/delegate-to-url-tool.test.cjs +98 -2
- package/dist/implementations/delegate-to-url-tool.test.js +99 -3
- package/dist/implementations/http-request-tool.cjs +32 -89
- package/dist/implementations/http-request-tool.d.ts.map +1 -1
- package/dist/implementations/http-request-tool.js +32 -89
- package/dist/implementations/http-request-tool.test.cjs +248 -1
- package/dist/implementations/http-request-tool.test.js +248 -1
- package/package.json +3 -3
|
@@ -51,7 +51,12 @@ describe("http_request tool", () => {
|
|
|
51
51
|
"[0:0:0:0:0:0:0:1]",
|
|
52
52
|
"[::ffff:127.0.0.1]",
|
|
53
53
|
"[::ffff:10.0.0.1]",
|
|
54
|
-
"[64:ff9b:1::a00:1]"
|
|
54
|
+
"[64:ff9b:1::a00:1]",
|
|
55
|
+
"[64:ff9b::7f00:1]",
|
|
56
|
+
"[fec0::1]",
|
|
57
|
+
"[feff::1]",
|
|
58
|
+
"[ff02::1]",
|
|
59
|
+
"[ff00::1]"
|
|
55
60
|
])("blocks private IPv6 literal %s before fetching", async (host) => {
|
|
56
61
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
57
62
|
try {
|
|
@@ -66,6 +71,32 @@ describe("http_request tool", () => {
|
|
|
66
71
|
fetchSpy.mockRestore();
|
|
67
72
|
}
|
|
68
73
|
});
|
|
74
|
+
it("refuses a name that answers a private address without echoing the answer", async () => {
|
|
75
|
+
const dnsLookup = vi.fn().mockResolvedValue([
|
|
76
|
+
{ address: "93.184.216.34", family: 4 },
|
|
77
|
+
{ address: "10.20.30.40", family: 4 }
|
|
78
|
+
]);
|
|
79
|
+
const error = await new Promise((resolve) => {
|
|
80
|
+
createSafeLookup({ dnsLookup })(
|
|
81
|
+
"files.example.com",
|
|
82
|
+
{ all: true },
|
|
83
|
+
((err) => resolve(err))
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
|
|
87
|
+
expect(error?.message).toBe("Request to files.example.com is blocked");
|
|
88
|
+
});
|
|
89
|
+
it.each(["localhost", "10.0.0.1", "[::1]"])(
|
|
90
|
+
"refuses %s with a message that only names the host",
|
|
91
|
+
async (host) => {
|
|
92
|
+
await expect(
|
|
93
|
+
createHttpRequestTool().execute(
|
|
94
|
+
{ url: `http://${host}/`, method: "GET", timeoutMs: 1e3 },
|
|
95
|
+
{}
|
|
96
|
+
)
|
|
97
|
+
).rejects.toMatchObject({ message: `Request to ${host} is blocked` });
|
|
98
|
+
}
|
|
99
|
+
);
|
|
69
100
|
it("blocks a redirect to a private literal before the next request", async () => {
|
|
70
101
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
71
102
|
new Response(null, { status: 302, headers: { location: "http://[::1]/secret" } })
|
|
@@ -123,4 +154,220 @@ describe("http_request tool", () => {
|
|
|
123
154
|
fetchSpy.mockRestore();
|
|
124
155
|
}
|
|
125
156
|
});
|
|
157
|
+
it.each([307, 308])(
|
|
158
|
+
"refuses a %i redirect that would send the body to another origin",
|
|
159
|
+
async (status) => {
|
|
160
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
161
|
+
new Response(null, {
|
|
162
|
+
status,
|
|
163
|
+
headers: { location: "https://other.example/collect" }
|
|
164
|
+
})
|
|
165
|
+
).mockResolvedValueOnce(new Response("ok"));
|
|
166
|
+
try {
|
|
167
|
+
await expect(
|
|
168
|
+
createHttpRequestTool().execute(
|
|
169
|
+
{
|
|
170
|
+
url: "https://example.com/submit",
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: { note: "private text" },
|
|
173
|
+
timeoutMs: 1e3
|
|
174
|
+
},
|
|
175
|
+
{}
|
|
176
|
+
)
|
|
177
|
+
).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
|
|
178
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
179
|
+
} finally {
|
|
180
|
+
fetchSpy.mockRestore();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
it.each([
|
|
185
|
+
["PUT", 301],
|
|
186
|
+
["PATCH", 301],
|
|
187
|
+
["PUT", 302],
|
|
188
|
+
["DELETE", 302],
|
|
189
|
+
["PATCH", 302]
|
|
190
|
+
])(
|
|
191
|
+
"refuses a %s %i redirect that would send the body to another origin",
|
|
192
|
+
async (method, status) => {
|
|
193
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
194
|
+
new Response(null, {
|
|
195
|
+
status,
|
|
196
|
+
headers: { location: "https://other.example/collect" }
|
|
197
|
+
})
|
|
198
|
+
).mockResolvedValueOnce(new Response("ok"));
|
|
199
|
+
try {
|
|
200
|
+
await expect(
|
|
201
|
+
createHttpRequestTool().execute(
|
|
202
|
+
{
|
|
203
|
+
url: "https://example.com/item",
|
|
204
|
+
method,
|
|
205
|
+
body: "private text",
|
|
206
|
+
timeoutMs: 1e3
|
|
207
|
+
},
|
|
208
|
+
{}
|
|
209
|
+
)
|
|
210
|
+
).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
|
|
211
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
212
|
+
} finally {
|
|
213
|
+
fetchSpy.mockRestore();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
it.each([301, 302])(
|
|
218
|
+
"still turns a cross-origin %i redirect of a POST into a body-less GET",
|
|
219
|
+
async (status) => {
|
|
220
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
221
|
+
new Response(null, { status, headers: { location: "https://other.example/" } })
|
|
222
|
+
).mockResolvedValueOnce(new Response("ok"));
|
|
223
|
+
try {
|
|
224
|
+
await createHttpRequestTool().execute(
|
|
225
|
+
{
|
|
226
|
+
url: "https://example.com/",
|
|
227
|
+
method: "POST",
|
|
228
|
+
body: "payload",
|
|
229
|
+
timeoutMs: 1e3
|
|
230
|
+
},
|
|
231
|
+
{}
|
|
232
|
+
);
|
|
233
|
+
expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "GET" });
|
|
234
|
+
expect(fetchSpy.mock.calls[1]?.[1]).not.toHaveProperty("body");
|
|
235
|
+
} finally {
|
|
236
|
+
fetchSpy.mockRestore();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
it.each([307, 308])("keeps the body on a same-origin %i redirect", async (status) => {
|
|
241
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status, headers: { location: "/moved" } })).mockResolvedValueOnce(new Response("ok"));
|
|
242
|
+
try {
|
|
243
|
+
await createHttpRequestTool().execute(
|
|
244
|
+
{
|
|
245
|
+
url: "https://example.com/submit",
|
|
246
|
+
method: "POST",
|
|
247
|
+
body: "payload",
|
|
248
|
+
timeoutMs: 1e3
|
|
249
|
+
},
|
|
250
|
+
{}
|
|
251
|
+
);
|
|
252
|
+
expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://example.com/moved");
|
|
253
|
+
expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "POST", body: "payload" });
|
|
254
|
+
} finally {
|
|
255
|
+
fetchSpy.mockRestore();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
it("still follows a cross-origin 307 when there is no body", async () => {
|
|
259
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
260
|
+
new Response(null, {
|
|
261
|
+
status: 307,
|
|
262
|
+
headers: { location: "https://other.example/" }
|
|
263
|
+
})
|
|
264
|
+
).mockResolvedValueOnce(new Response("ok"));
|
|
265
|
+
try {
|
|
266
|
+
const result = await createHttpRequestTool().execute(
|
|
267
|
+
{ url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
|
|
268
|
+
{}
|
|
269
|
+
);
|
|
270
|
+
expect(result).toMatchObject({ ok: true, body: "ok" });
|
|
271
|
+
} finally {
|
|
272
|
+
fetchSpy.mockRestore();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe("http_request cloud metadata hosts", () => {
|
|
277
|
+
const METADATA_HOSTS = [
|
|
278
|
+
"metadata.google.internal",
|
|
279
|
+
"METADATA.GOOG",
|
|
280
|
+
"instance-data.",
|
|
281
|
+
"instance-data.ec2.internal",
|
|
282
|
+
"169.254.169.254",
|
|
283
|
+
"[fd00:ec2::254]",
|
|
284
|
+
"100.100.100.200",
|
|
285
|
+
// Not private ranges, so only the metadata check refuses them.
|
|
286
|
+
"168.63.129.16",
|
|
287
|
+
"[::ffff:a83f:8110]"
|
|
288
|
+
];
|
|
289
|
+
it.each(METADATA_HOSTS)("refuses %s before fetching", async (host) => {
|
|
290
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
291
|
+
try {
|
|
292
|
+
await expect(
|
|
293
|
+
createHttpRequestTool().execute(
|
|
294
|
+
{ url: `http://${host}/latest/meta-data/`, method: "GET", timeoutMs: 1e3 },
|
|
295
|
+
{}
|
|
296
|
+
)
|
|
297
|
+
).rejects.toMatchObject({
|
|
298
|
+
code: "HTTP_REQUEST_UNSAFE_TARGET",
|
|
299
|
+
message: `Request to ${new URL(`http://${host}`).hostname} is blocked`
|
|
300
|
+
});
|
|
301
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
302
|
+
} finally {
|
|
303
|
+
fetchSpy.mockRestore();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
it("refuses a redirect to a metadata host name", async () => {
|
|
307
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
308
|
+
new Response(null, {
|
|
309
|
+
status: 302,
|
|
310
|
+
headers: { location: "http://metadata.google.internal/computeMetadata/v1/" }
|
|
311
|
+
})
|
|
312
|
+
);
|
|
313
|
+
try {
|
|
314
|
+
await expect(
|
|
315
|
+
createHttpRequestTool().execute(
|
|
316
|
+
{ url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
|
|
317
|
+
{}
|
|
318
|
+
)
|
|
319
|
+
).rejects.toMatchObject({
|
|
320
|
+
code: "HTTP_REQUEST_UNSAFE_TARGET",
|
|
321
|
+
message: "Request to metadata.google.internal is blocked"
|
|
322
|
+
});
|
|
323
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
324
|
+
} finally {
|
|
325
|
+
fetchSpy.mockRestore();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it.each([
|
|
329
|
+
// Not private ranges, so only the metadata check refuses them.
|
|
330
|
+
"168.63.129.16",
|
|
331
|
+
"::ffff:168.63.129.16",
|
|
332
|
+
// Also private ranges.
|
|
333
|
+
"169.254.169.254",
|
|
334
|
+
"fd00:ec2::254",
|
|
335
|
+
"192.0.0.192",
|
|
336
|
+
"100.100.100.200"
|
|
337
|
+
])(
|
|
338
|
+
"the connection lookup refuses a public name that answers the metadata address %s",
|
|
339
|
+
async (address) => {
|
|
340
|
+
const dnsLookup = vi.fn().mockResolvedValue([
|
|
341
|
+
{ address: "93.184.216.34", family: 4 },
|
|
342
|
+
{ address, family: 4 }
|
|
343
|
+
]);
|
|
344
|
+
const safeLookup = createSafeLookup({ dnsLookup });
|
|
345
|
+
const error = await new Promise((resolve) => {
|
|
346
|
+
safeLookup(
|
|
347
|
+
"files.example.com",
|
|
348
|
+
{ all: true },
|
|
349
|
+
((err) => resolve(err))
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
expect(dnsLookup).toHaveBeenCalledOnce();
|
|
353
|
+
expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
|
|
354
|
+
expect(error?.message).toBe("Request to files.example.com is blocked");
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
it.each(["metadata.google.internal", "metadata.goog", "instance-data"])(
|
|
358
|
+
"the connection lookup refuses %s without resolving it",
|
|
359
|
+
async (host) => {
|
|
360
|
+
const dnsLookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
361
|
+
const safeLookup = createSafeLookup({ dnsLookup });
|
|
362
|
+
const error = await new Promise((resolve) => {
|
|
363
|
+
safeLookup(
|
|
364
|
+
host,
|
|
365
|
+
{ all: true },
|
|
366
|
+
((err) => resolve(err))
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
|
|
370
|
+
expect(dnsLookup).not.toHaveBeenCalled();
|
|
371
|
+
}
|
|
372
|
+
);
|
|
126
373
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dexto/tools-builtins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Built-in tools factory for Dexto agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"undici": "^7.30.1",
|
|
21
21
|
"zod": "^4.3.6",
|
|
22
|
-
"@dexto/agent-config": "1.
|
|
23
|
-
"@dexto/core": "1.
|
|
22
|
+
"@dexto/agent-config": "1.17.0",
|
|
23
|
+
"@dexto/core": "1.17.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.0.0",
|