@dexto/tools-builtins 1.16.5 → 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
|
@@ -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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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
|
});
|