@revenexx/integrations-node-sdk 0.17.0 → 0.18.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/README.md +7 -0
- package/dist/index.cjs +238 -13
- package/dist/index.d.cts +45 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +225 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ configuration or auth token needed.
|
|
|
33
33
|
| `localized` | `normalizeLocalized` — reduce a `LocalizedString` (`string \| Record<string, string>`) to a single plain string. |
|
|
34
34
|
| `credentialType` | `normalizeCredentialType` — normalise a credential-type reference (`string \| string[] \| undefined`) to `string[]`. |
|
|
35
35
|
| `errors` | `NodeError` — typed error class for unexpected/system-level failures thrown inside `execute`. |
|
|
36
|
+
| `fetch` | `safeFetch` and the `read*` body helpers — timeout, retry, response size-cap, unified error form, and an always-on **SSRF guard** (blocks private/loopback/link-local/metadata targets, re-checked on every redirect hop). Prefer it over raw `fetch`. See [`docs/overview.md`](docs/overview.md#safefetch). |
|
|
37
|
+
| `ssrf` | `assertPublicUrl` / `isBlockedAddress` — the SSRF guard that backs `safeFetch`, exported for reuse. Best-effort (a DNS-rebinding TOCTOU gap remains); errors surface as `NodeError('BLOCKED_ADDRESS')`. Set `RVNXX_SSRF_ALLOW_PRIVATE=1` (off by default; local dev only) to reach `localhost`/internal targets while testing — see [`docs/overview.md`](docs/overview.md#ssrf-guard). |
|
|
36
38
|
| `extract` | `extractManifest` / `extractManifests` (nodes) and `extractCredentialManifest` / `extractCredentialManifests` (credentials) — pull descriptions off instances without executing them. |
|
|
37
39
|
| `manifest` | `buildManifest` / `MANIFEST_VERSION` — wrap node, credential and template descriptions in the `{ manifestVersion, nodes, credentials, templates }` envelope the registry expects. |
|
|
38
40
|
|
|
@@ -213,6 +215,11 @@ export class HttpRequestNode implements INode {
|
|
|
213
215
|
}
|
|
214
216
|
```
|
|
215
217
|
|
|
218
|
+
> The example calls `fetch` directly to stay focused on the error contract. In
|
|
219
|
+
> real nodes use [`safeFetch`](docs/overview.md#safefetch) instead — it adds the
|
|
220
|
+
> timeout, retry, size-cap and the SSRF guard (private/loopback/metadata targets
|
|
221
|
+
> are rejected with `NodeError('BLOCKED_ADDRESS')`, re-checked on every redirect).
|
|
222
|
+
|
|
216
223
|
**Error contract:** `throw NodeError(code, message, meta?)` for unexpected,
|
|
217
224
|
system-level failures; `return { outputs, branch: '<error-port>' }` for
|
|
218
225
|
expected, routable errors (a declared `kind: 'error'` port). Never mix both for
|
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
@@ -29,6 +39,7 @@ __export(index_exports, {
|
|
|
29
39
|
DEFAULT_RETRY_POLICY: () => DEFAULT_RETRY_POLICY,
|
|
30
40
|
DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
|
|
31
41
|
MANIFEST_VERSION: () => MANIFEST_VERSION,
|
|
42
|
+
MAX_REDIRECTS: () => MAX_REDIRECTS,
|
|
32
43
|
MAX_RESPONSE_BYTES: () => MAX_RESPONSE_BYTES,
|
|
33
44
|
MAX_RETRY_ATTEMPTS: () => MAX_RETRY_ATTEMPTS,
|
|
34
45
|
MAX_TIMEOUT_MS: () => MAX_TIMEOUT_MS,
|
|
@@ -37,6 +48,7 @@ __export(index_exports, {
|
|
|
37
48
|
OAuth2ClientCredentialsCredential: () => OAuth2ClientCredentialsCredential,
|
|
38
49
|
RetryableError: () => RetryableError,
|
|
39
50
|
SimpleValueCredential: () => SimpleValueCredential,
|
|
51
|
+
assertPublicUrl: () => assertPublicUrl,
|
|
40
52
|
backoffDelay: () => backoffDelay,
|
|
41
53
|
buildManifest: () => buildManifest,
|
|
42
54
|
clampResponseBytes: () => clampResponseBytes,
|
|
@@ -44,6 +56,7 @@ __export(index_exports, {
|
|
|
44
56
|
extractCredentialManifests: () => extractCredentialManifests,
|
|
45
57
|
extractManifest: () => extractManifest,
|
|
46
58
|
extractManifests: () => extractManifests,
|
|
59
|
+
isBlockedAddress: () => isBlockedAddress,
|
|
47
60
|
isNodeWithIteration: () => isNodeWithIteration,
|
|
48
61
|
isOAuthAuthorizeCredential: () => isOAuthAuthorizeCredential,
|
|
49
62
|
maxBytesConfigField: () => maxBytesConfigField,
|
|
@@ -129,9 +142,149 @@ function extractCredentialManifests(credentials) {
|
|
|
129
142
|
return credentials.map(extractCredentialManifest);
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
// src/ssrf.ts
|
|
146
|
+
var import_node_net = require("net");
|
|
147
|
+
var ssrfResolver = {
|
|
148
|
+
lookup: async (hostname) => {
|
|
149
|
+
const { lookup } = await import("dns/promises");
|
|
150
|
+
const results = await lookup(hostname, { all: true });
|
|
151
|
+
return results.map((r) => ({ address: r.address, family: r.family }));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
function parseIpv4(input) {
|
|
155
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(input);
|
|
156
|
+
if (!m) return null;
|
|
157
|
+
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
158
|
+
if (octets.some((n) => n > 255)) return null;
|
|
159
|
+
return octets;
|
|
160
|
+
}
|
|
161
|
+
function expandIpv6(input) {
|
|
162
|
+
let s = input;
|
|
163
|
+
const zone = s.indexOf("%");
|
|
164
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
165
|
+
if (s.includes(".")) {
|
|
166
|
+
const idx = s.lastIndexOf(":");
|
|
167
|
+
if (idx === -1) return null;
|
|
168
|
+
const v4 = parseIpv4(s.slice(idx + 1));
|
|
169
|
+
if (!v4) return null;
|
|
170
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
171
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
172
|
+
s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
|
|
173
|
+
}
|
|
174
|
+
const halves = s.split("::");
|
|
175
|
+
if (halves.length > 2) return null;
|
|
176
|
+
const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
|
|
177
|
+
const head = parseGroups(halves[0] ?? "");
|
|
178
|
+
const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
|
|
179
|
+
const declared = [...head, ...back ?? []];
|
|
180
|
+
if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
|
|
181
|
+
let hextets;
|
|
182
|
+
if (back === null) {
|
|
183
|
+
hextets = head;
|
|
184
|
+
} else {
|
|
185
|
+
const zeros = 8 - (head.length + back.length);
|
|
186
|
+
if (zeros < 1) return null;
|
|
187
|
+
hextets = [...head, ...new Array(zeros).fill(0), ...back];
|
|
188
|
+
}
|
|
189
|
+
return hextets.length === 8 ? hextets : null;
|
|
190
|
+
}
|
|
191
|
+
function isBlockedIpv4(o) {
|
|
192
|
+
const [a, b] = o;
|
|
193
|
+
return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
|
|
194
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
195
|
+
a === 10 || // 10.0.0.0/8 private
|
|
196
|
+
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
|
|
197
|
+
a === 192 && b === 168 || // 192.168.0.0/16 private
|
|
198
|
+
a === 169 && b === 254;
|
|
199
|
+
}
|
|
200
|
+
function isBlockedAddress(ip) {
|
|
201
|
+
const v4 = parseIpv4(ip);
|
|
202
|
+
if (v4) return isBlockedIpv4(v4);
|
|
203
|
+
const h = expandIpv6(ip);
|
|
204
|
+
if (!h) return true;
|
|
205
|
+
if (h.every((x) => x === 0)) return true;
|
|
206
|
+
if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true;
|
|
207
|
+
const embedsV4 = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && (h[5] === 65535 || h[5] === 0);
|
|
208
|
+
if (embedsV4) {
|
|
209
|
+
return isBlockedIpv4([h[6] >> 8, h[6] & 255, h[7] >> 8, h[7] & 255]);
|
|
210
|
+
}
|
|
211
|
+
if ((h[0] & 65024) === 64512) return true;
|
|
212
|
+
if ((h[0] & 65472) === 65152) return true;
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
var bypassNoticeLogged = false;
|
|
216
|
+
function guardRelaxedForLocalDev() {
|
|
217
|
+
const raw = process.env["RVNXX_SSRF_ALLOW_PRIVATE"];
|
|
218
|
+
const relaxed = raw != null && ["1", "true", "yes"].includes(raw.trim().toLowerCase());
|
|
219
|
+
if (relaxed && !bypassNoticeLogged) {
|
|
220
|
+
bypassNoticeLogged = true;
|
|
221
|
+
console.warn(
|
|
222
|
+
"[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback fetch targets. Intended for local development only."
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return relaxed;
|
|
226
|
+
}
|
|
227
|
+
function blockedError(host, address) {
|
|
228
|
+
if (host === address) {
|
|
229
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked request to private or reserved address ${address}`, {
|
|
230
|
+
status: 0
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
console.warn(`[ssrf] blocked request to ${host}: resolves to private/reserved address ${address}`);
|
|
234
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked request to ${host}: resolves to a private or reserved address`, {
|
|
235
|
+
status: 0
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async function resolveHost(lookup, host, signal) {
|
|
239
|
+
if (!signal) return lookup(host);
|
|
240
|
+
if (signal.aborted) throw signal.reason;
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const onAbort = () => reject(signal.reason);
|
|
243
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
244
|
+
lookup(host).then(
|
|
245
|
+
(addresses) => {
|
|
246
|
+
signal.removeEventListener("abort", onAbort);
|
|
247
|
+
resolve(addresses);
|
|
248
|
+
},
|
|
249
|
+
(err) => {
|
|
250
|
+
signal.removeEventListener("abort", onAbort);
|
|
251
|
+
reject(err);
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async function assertPublicUrl(url, opts = {}) {
|
|
257
|
+
const u = url instanceof URL ? url : new URL(url);
|
|
258
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
259
|
+
throw new NodeError("BLOCKED_ADDRESS", `Blocked non-HTTP(S) URL protocol: ${u.protocol}`, { status: 0 });
|
|
260
|
+
}
|
|
261
|
+
const rawHost = u.hostname;
|
|
262
|
+
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
|
263
|
+
if (!host) throw new NodeError("BLOCKED_ADDRESS", "Blocked URL with empty host", { status: 0 });
|
|
264
|
+
if (guardRelaxedForLocalDev()) return;
|
|
265
|
+
const lower = host.toLowerCase();
|
|
266
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) {
|
|
267
|
+
throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
|
|
268
|
+
}
|
|
269
|
+
if ((0, import_node_net.isIP)(host) !== 0) {
|
|
270
|
+
if (isBlockedAddress(host)) throw blockedError(host, host);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const lookup = opts.lookup ?? ssrfResolver.lookup;
|
|
274
|
+
const addresses = await resolveHost(lookup, host, opts.signal);
|
|
275
|
+
if (!addresses || addresses.length === 0) {
|
|
276
|
+
throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
|
|
277
|
+
}
|
|
278
|
+
for (const a of addresses) {
|
|
279
|
+
if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
132
283
|
// src/fetch.ts
|
|
133
284
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
134
285
|
var MAX_TIMEOUT_MS = 12e4;
|
|
286
|
+
var MAX_REDIRECTS = 5;
|
|
287
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
135
288
|
var DEFAULT_RETRY_ATTEMPTS = 0;
|
|
136
289
|
var MAX_RETRY_ATTEMPTS = 5;
|
|
137
290
|
var DEFAULT_RETRY_DELAY_MS = 1e3;
|
|
@@ -141,6 +294,77 @@ function clampResponseBytes(maxBytes) {
|
|
|
141
294
|
if (!Number.isFinite(maxBytes) || maxBytes < 1) return MAX_RESPONSE_BYTES;
|
|
142
295
|
return Math.min(maxBytes, MAX_RESPONSE_BYTES);
|
|
143
296
|
}
|
|
297
|
+
async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
298
|
+
const ac = new AbortController();
|
|
299
|
+
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
300
|
+
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
301
|
+
try {
|
|
302
|
+
return await fetch(url, { ...fetchOptions, signal });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
305
|
+
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
|
|
306
|
+
throw err;
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function guardUrl(url, ctxSignal, effectiveMs) {
|
|
312
|
+
const ac = new AbortController();
|
|
313
|
+
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
314
|
+
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
315
|
+
try {
|
|
316
|
+
await assertPublicUrl(url, { signal });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
319
|
+
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
|
|
320
|
+
throw err;
|
|
321
|
+
} finally {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
326
|
+
let currentUrl = url;
|
|
327
|
+
let currentInit = { ...fetchOptions, redirect: "manual" };
|
|
328
|
+
await guardUrl(currentUrl, ctxSignal, effectiveMs);
|
|
329
|
+
for (let hop = 0; ; hop++) {
|
|
330
|
+
const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
|
|
331
|
+
const location = res.headers.get("location");
|
|
332
|
+
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
333
|
+
const redirectStatus = res.status;
|
|
334
|
+
await res.body?.cancel().catch(() => {
|
|
335
|
+
});
|
|
336
|
+
if (hop >= MAX_REDIRECTS) {
|
|
337
|
+
throw new NodeError("TOO_MANY_REDIRECTS", `Exceeded ${MAX_REDIRECTS} redirects`, { status: redirectStatus });
|
|
338
|
+
}
|
|
339
|
+
const base = currentUrl instanceof URL ? currentUrl : new URL(currentUrl);
|
|
340
|
+
let nextUrl;
|
|
341
|
+
try {
|
|
342
|
+
nextUrl = new URL(location, base);
|
|
343
|
+
} catch {
|
|
344
|
+
throw new NodeError("BLOCKED_ADDRESS", "Blocked redirect with an invalid Location header", { status: 0 });
|
|
345
|
+
}
|
|
346
|
+
if (base.protocol === "https:" && nextUrl.protocol !== "https:") {
|
|
347
|
+
throw new NodeError("BLOCKED_ADDRESS", "Blocked https\u2192http downgrade on redirect", { status: 0 });
|
|
348
|
+
}
|
|
349
|
+
await guardUrl(nextUrl, ctxSignal, effectiveMs);
|
|
350
|
+
const headers = new Headers(currentInit.headers ?? void 0);
|
|
351
|
+
let method = (currentInit.method ?? "GET").toUpperCase();
|
|
352
|
+
let body = currentInit.body;
|
|
353
|
+
if (redirectStatus === 303 || (redirectStatus === 301 || redirectStatus === 302) && method === "POST") {
|
|
354
|
+
method = "GET";
|
|
355
|
+
body = void 0;
|
|
356
|
+
headers.delete("content-type");
|
|
357
|
+
headers.delete("content-length");
|
|
358
|
+
}
|
|
359
|
+
if (nextUrl.origin !== base.origin) {
|
|
360
|
+
headers.delete("authorization");
|
|
361
|
+
headers.delete("cookie");
|
|
362
|
+
headers.delete("proxy-authorization");
|
|
363
|
+
}
|
|
364
|
+
currentUrl = nextUrl;
|
|
365
|
+
currentInit = { ...currentInit, method, body, headers, redirect: "manual" };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
144
368
|
async function safeFetch(url, options = {}) {
|
|
145
369
|
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, ...fetchOptions } = options;
|
|
146
370
|
const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
@@ -151,29 +375,27 @@ async function safeFetch(url, options = {}) {
|
|
|
151
375
|
let lastError;
|
|
152
376
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
153
377
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
154
|
-
const ac = new AbortController();
|
|
155
|
-
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
156
|
-
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
157
378
|
try {
|
|
158
|
-
return await
|
|
379
|
+
return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
|
|
159
380
|
} catch (err) {
|
|
160
381
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
} else {
|
|
164
|
-
lastError = err;
|
|
382
|
+
if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS")) {
|
|
383
|
+
throw err;
|
|
165
384
|
}
|
|
385
|
+
lastError = err;
|
|
166
386
|
if (attempt < maxAttempts) {
|
|
167
387
|
await new Promise((resolve) => {
|
|
168
|
-
const
|
|
169
|
-
ctxSignal?.addEventListener("abort", () => {
|
|
388
|
+
const onAbort = () => {
|
|
170
389
|
clearTimeout(t);
|
|
171
390
|
resolve();
|
|
172
|
-
}
|
|
391
|
+
};
|
|
392
|
+
const t = setTimeout(() => {
|
|
393
|
+
ctxSignal?.removeEventListener("abort", onAbort);
|
|
394
|
+
resolve();
|
|
395
|
+
}, retryDelayMs);
|
|
396
|
+
ctxSignal?.addEventListener("abort", onAbort, { once: true });
|
|
173
397
|
});
|
|
174
398
|
}
|
|
175
|
-
} finally {
|
|
176
|
-
clearTimeout(timer);
|
|
177
399
|
}
|
|
178
400
|
}
|
|
179
401
|
throw lastError;
|
|
@@ -592,6 +814,7 @@ function base64Url(buf) {
|
|
|
592
814
|
DEFAULT_RETRY_POLICY,
|
|
593
815
|
DEFAULT_TIMEOUT_MS,
|
|
594
816
|
MANIFEST_VERSION,
|
|
817
|
+
MAX_REDIRECTS,
|
|
595
818
|
MAX_RESPONSE_BYTES,
|
|
596
819
|
MAX_RETRY_ATTEMPTS,
|
|
597
820
|
MAX_TIMEOUT_MS,
|
|
@@ -600,6 +823,7 @@ function base64Url(buf) {
|
|
|
600
823
|
OAuth2ClientCredentialsCredential,
|
|
601
824
|
RetryableError,
|
|
602
825
|
SimpleValueCredential,
|
|
826
|
+
assertPublicUrl,
|
|
603
827
|
backoffDelay,
|
|
604
828
|
buildManifest,
|
|
605
829
|
clampResponseBytes,
|
|
@@ -607,6 +831,7 @@ function base64Url(buf) {
|
|
|
607
831
|
extractCredentialManifests,
|
|
608
832
|
extractManifest,
|
|
609
833
|
extractManifests,
|
|
834
|
+
isBlockedAddress,
|
|
610
835
|
isNodeWithIteration,
|
|
611
836
|
isOAuthAuthorizeCredential,
|
|
612
837
|
maxBytesConfigField,
|
package/dist/index.d.cts
CHANGED
|
@@ -443,6 +443,8 @@ declare function extractCredentialManifests(credentials: ICredential[]): ICreden
|
|
|
443
443
|
|
|
444
444
|
declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
445
445
|
declare const MAX_TIMEOUT_MS = 120000;
|
|
446
|
+
/** Maximum number of redirect hops `safeFetch` follows before giving up. */
|
|
447
|
+
declare const MAX_REDIRECTS = 5;
|
|
446
448
|
declare const DEFAULT_RETRY_ATTEMPTS = 0;
|
|
447
449
|
declare const MAX_RETRY_ATTEMPTS = 5;
|
|
448
450
|
declare const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
@@ -506,6 +508,48 @@ declare function retryConfigFields(opts?: {
|
|
|
506
508
|
defaultDelayMs?: number;
|
|
507
509
|
}): IConfigField[];
|
|
508
510
|
|
|
511
|
+
/**
|
|
512
|
+
* A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
|
|
513
|
+
* `family` is `4` or `6`; only `address` is consulted by the guard.
|
|
514
|
+
*/
|
|
515
|
+
interface LookupAddress {
|
|
516
|
+
address: string;
|
|
517
|
+
family: number;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Resolves a hostname to every address it maps to. Modelled on
|
|
521
|
+
* `dns.lookup(host, { all: true })`. Injectable so tests can drive the guard
|
|
522
|
+
* deterministically without real DNS — see {@link ssrfResolver}.
|
|
523
|
+
*/
|
|
524
|
+
type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
|
|
525
|
+
/**
|
|
526
|
+
* Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
|
|
527
|
+
* loopback, link-local or otherwise non-public target that a server-side fetch
|
|
528
|
+
* must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
|
|
529
|
+
* and re-checked against the IPv4 rules. An address we cannot parse is treated as
|
|
530
|
+
* blocked (fail-closed).
|
|
531
|
+
*/
|
|
532
|
+
declare function isBlockedAddress(ip: string): boolean;
|
|
533
|
+
/**
|
|
534
|
+
* Assert that `url` is safe for a server-side fetch: an http(s) URL whose target
|
|
535
|
+
* resolves only to public addresses. Rejects non-http(s) protocols, empty hosts
|
|
536
|
+
* and `localhost`, checks literal-IP hosts directly, and otherwise resolves the
|
|
537
|
+
* hostname (via the injectable `lookup`, defaulting to {@link ssrfResolver}) and
|
|
538
|
+
* rejects if **any** resolved address is private/reserved. Throws
|
|
539
|
+
* `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
|
|
540
|
+
*
|
|
541
|
+
* Best-effort by design: Node re-resolves the hostname when it actually connects,
|
|
542
|
+
* so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
|
|
543
|
+
*
|
|
544
|
+
* Pass `signal` (the per-request timeout/cancellation budget) so a hung or
|
|
545
|
+
* hostile DNS resolve cannot block the guard past that budget — see
|
|
546
|
+
* {@link resolveHost}.
|
|
547
|
+
*/
|
|
548
|
+
declare function assertPublicUrl(url: string | URL, opts?: {
|
|
549
|
+
lookup?: LookupFn;
|
|
550
|
+
signal?: AbortSignal;
|
|
551
|
+
}): Promise<void>;
|
|
552
|
+
|
|
509
553
|
/**
|
|
510
554
|
* Transport-agnostic retry/backoff primitive (PO-139).
|
|
511
555
|
*
|
|
@@ -752,4 +796,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
|
|
|
752
796
|
test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
|
|
753
797
|
}
|
|
754
798
|
|
|
755
|
-
export { ApiKeyCredential, BaseCredential, BasicAuthCredential, type ConfigType, type CredentialAuthKind, type CredentialFieldType, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY_MS, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, type DataType, type IConfigField, type IConfigFieldBase, type IConfigOption, type IConfigValidation, type ICredential, type ICredentialContext, type ICredentialDescription, type ICredentialField, type ICredentialOAuthAuthorize, type ICredentialResolveResult, type ICredentialTestResult, type IImage, type IInputPort, type INode, type INodeAuthorContext, type INodeContext, type INodeDescription, type INodeResult, type INodeWithIteration, type IOutputField, type IOutputPort, type ITemplateDescription, type ITemplateTrigger, type ImageCategory, type LocalizedString, MANIFEST_VERSION, MAX_RESPONSE_BYTES, MAX_RETRY_ATTEMPTS, MAX_TIMEOUT_MS, type NodeCategory, NodeError, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, backoffDelay, buildManifest, clampResponseBytes, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isNodeWithIteration, isOAuthAuthorizeCredential, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, sleepWithSignal, timeoutConfigField, withRetry };
|
|
799
|
+
export { ApiKeyCredential, BaseCredential, BasicAuthCredential, type ConfigType, type CredentialAuthKind, type CredentialFieldType, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY_MS, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, type DataType, type IConfigField, type IConfigFieldBase, type IConfigOption, type IConfigValidation, type ICredential, type ICredentialContext, type ICredentialDescription, type ICredentialField, type ICredentialOAuthAuthorize, type ICredentialResolveResult, type ICredentialTestResult, type IImage, type IInputPort, type INode, type INodeAuthorContext, type INodeContext, type INodeDescription, type INodeResult, type INodeWithIteration, type IOutputField, type IOutputPort, type ITemplateDescription, type ITemplateTrigger, type ImageCategory, type LocalizedString, type LookupAddress, type LookupFn, MANIFEST_VERSION, MAX_REDIRECTS, MAX_RESPONSE_BYTES, MAX_RETRY_ATTEMPTS, MAX_TIMEOUT_MS, type NodeCategory, NodeError, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, sleepWithSignal, timeoutConfigField, withRetry };
|
package/dist/index.d.ts
CHANGED
|
@@ -443,6 +443,8 @@ declare function extractCredentialManifests(credentials: ICredential[]): ICreden
|
|
|
443
443
|
|
|
444
444
|
declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
445
445
|
declare const MAX_TIMEOUT_MS = 120000;
|
|
446
|
+
/** Maximum number of redirect hops `safeFetch` follows before giving up. */
|
|
447
|
+
declare const MAX_REDIRECTS = 5;
|
|
446
448
|
declare const DEFAULT_RETRY_ATTEMPTS = 0;
|
|
447
449
|
declare const MAX_RETRY_ATTEMPTS = 5;
|
|
448
450
|
declare const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
@@ -506,6 +508,48 @@ declare function retryConfigFields(opts?: {
|
|
|
506
508
|
defaultDelayMs?: number;
|
|
507
509
|
}): IConfigField[];
|
|
508
510
|
|
|
511
|
+
/**
|
|
512
|
+
* A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
|
|
513
|
+
* `family` is `4` or `6`; only `address` is consulted by the guard.
|
|
514
|
+
*/
|
|
515
|
+
interface LookupAddress {
|
|
516
|
+
address: string;
|
|
517
|
+
family: number;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Resolves a hostname to every address it maps to. Modelled on
|
|
521
|
+
* `dns.lookup(host, { all: true })`. Injectable so tests can drive the guard
|
|
522
|
+
* deterministically without real DNS — see {@link ssrfResolver}.
|
|
523
|
+
*/
|
|
524
|
+
type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
|
|
525
|
+
/**
|
|
526
|
+
* Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
|
|
527
|
+
* loopback, link-local or otherwise non-public target that a server-side fetch
|
|
528
|
+
* must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
|
|
529
|
+
* and re-checked against the IPv4 rules. An address we cannot parse is treated as
|
|
530
|
+
* blocked (fail-closed).
|
|
531
|
+
*/
|
|
532
|
+
declare function isBlockedAddress(ip: string): boolean;
|
|
533
|
+
/**
|
|
534
|
+
* Assert that `url` is safe for a server-side fetch: an http(s) URL whose target
|
|
535
|
+
* resolves only to public addresses. Rejects non-http(s) protocols, empty hosts
|
|
536
|
+
* and `localhost`, checks literal-IP hosts directly, and otherwise resolves the
|
|
537
|
+
* hostname (via the injectable `lookup`, defaulting to {@link ssrfResolver}) and
|
|
538
|
+
* rejects if **any** resolved address is private/reserved. Throws
|
|
539
|
+
* `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
|
|
540
|
+
*
|
|
541
|
+
* Best-effort by design: Node re-resolves the hostname when it actually connects,
|
|
542
|
+
* so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
|
|
543
|
+
*
|
|
544
|
+
* Pass `signal` (the per-request timeout/cancellation budget) so a hung or
|
|
545
|
+
* hostile DNS resolve cannot block the guard past that budget — see
|
|
546
|
+
* {@link resolveHost}.
|
|
547
|
+
*/
|
|
548
|
+
declare function assertPublicUrl(url: string | URL, opts?: {
|
|
549
|
+
lookup?: LookupFn;
|
|
550
|
+
signal?: AbortSignal;
|
|
551
|
+
}): Promise<void>;
|
|
552
|
+
|
|
509
553
|
/**
|
|
510
554
|
* Transport-agnostic retry/backoff primitive (PO-139).
|
|
511
555
|
*
|
|
@@ -752,4 +796,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
|
|
|
752
796
|
test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
|
|
753
797
|
}
|
|
754
798
|
|
|
755
|
-
export { ApiKeyCredential, BaseCredential, BasicAuthCredential, type ConfigType, type CredentialAuthKind, type CredentialFieldType, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY_MS, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, type DataType, type IConfigField, type IConfigFieldBase, type IConfigOption, type IConfigValidation, type ICredential, type ICredentialContext, type ICredentialDescription, type ICredentialField, type ICredentialOAuthAuthorize, type ICredentialResolveResult, type ICredentialTestResult, type IImage, type IInputPort, type INode, type INodeAuthorContext, type INodeContext, type INodeDescription, type INodeResult, type INodeWithIteration, type IOutputField, type IOutputPort, type ITemplateDescription, type ITemplateTrigger, type ImageCategory, type LocalizedString, MANIFEST_VERSION, MAX_RESPONSE_BYTES, MAX_RETRY_ATTEMPTS, MAX_TIMEOUT_MS, type NodeCategory, NodeError, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, backoffDelay, buildManifest, clampResponseBytes, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isNodeWithIteration, isOAuthAuthorizeCredential, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, sleepWithSignal, timeoutConfigField, withRetry };
|
|
799
|
+
export { ApiKeyCredential, BaseCredential, BasicAuthCredential, type ConfigType, type CredentialAuthKind, type CredentialFieldType, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_DELAY_MS, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, type DataType, type IConfigField, type IConfigFieldBase, type IConfigOption, type IConfigValidation, type ICredential, type ICredentialContext, type ICredentialDescription, type ICredentialField, type ICredentialOAuthAuthorize, type ICredentialResolveResult, type ICredentialTestResult, type IImage, type IInputPort, type INode, type INodeAuthorContext, type INodeContext, type INodeDescription, type INodeResult, type INodeWithIteration, type IOutputField, type IOutputPort, type ITemplateDescription, type ITemplateTrigger, type ImageCategory, type LocalizedString, type LookupAddress, type LookupFn, MANIFEST_VERSION, MAX_REDIRECTS, MAX_RESPONSE_BYTES, MAX_RETRY_ATTEMPTS, MAX_TIMEOUT_MS, type NodeCategory, NodeError, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, sleepWithSignal, timeoutConfigField, withRetry };
|
package/dist/index.js
CHANGED
|
@@ -62,9 +62,149 @@ var NodeError = class extends Error {
|
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
// src/ssrf.ts
|
|
66
|
+
import { isIP } from "net";
|
|
67
|
+
var ssrfResolver = {
|
|
68
|
+
lookup: async (hostname) => {
|
|
69
|
+
const { lookup } = await import("dns/promises");
|
|
70
|
+
const results = await lookup(hostname, { all: true });
|
|
71
|
+
return results.map((r) => ({ address: r.address, family: r.family }));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function parseIpv4(input) {
|
|
75
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(input);
|
|
76
|
+
if (!m) return null;
|
|
77
|
+
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
78
|
+
if (octets.some((n) => n > 255)) return null;
|
|
79
|
+
return octets;
|
|
80
|
+
}
|
|
81
|
+
function expandIpv6(input) {
|
|
82
|
+
let s = input;
|
|
83
|
+
const zone = s.indexOf("%");
|
|
84
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
85
|
+
if (s.includes(".")) {
|
|
86
|
+
const idx = s.lastIndexOf(":");
|
|
87
|
+
if (idx === -1) return null;
|
|
88
|
+
const v4 = parseIpv4(s.slice(idx + 1));
|
|
89
|
+
if (!v4) return null;
|
|
90
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
91
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
92
|
+
s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
|
|
93
|
+
}
|
|
94
|
+
const halves = s.split("::");
|
|
95
|
+
if (halves.length > 2) return null;
|
|
96
|
+
const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
|
|
97
|
+
const head = parseGroups(halves[0] ?? "");
|
|
98
|
+
const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
|
|
99
|
+
const declared = [...head, ...back ?? []];
|
|
100
|
+
if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
|
|
101
|
+
let hextets;
|
|
102
|
+
if (back === null) {
|
|
103
|
+
hextets = head;
|
|
104
|
+
} else {
|
|
105
|
+
const zeros = 8 - (head.length + back.length);
|
|
106
|
+
if (zeros < 1) return null;
|
|
107
|
+
hextets = [...head, ...new Array(zeros).fill(0), ...back];
|
|
108
|
+
}
|
|
109
|
+
return hextets.length === 8 ? hextets : null;
|
|
110
|
+
}
|
|
111
|
+
function isBlockedIpv4(o) {
|
|
112
|
+
const [a, b] = o;
|
|
113
|
+
return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
|
|
114
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
115
|
+
a === 10 || // 10.0.0.0/8 private
|
|
116
|
+
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
|
|
117
|
+
a === 192 && b === 168 || // 192.168.0.0/16 private
|
|
118
|
+
a === 169 && b === 254;
|
|
119
|
+
}
|
|
120
|
+
function isBlockedAddress(ip) {
|
|
121
|
+
const v4 = parseIpv4(ip);
|
|
122
|
+
if (v4) return isBlockedIpv4(v4);
|
|
123
|
+
const h = expandIpv6(ip);
|
|
124
|
+
if (!h) return true;
|
|
125
|
+
if (h.every((x) => x === 0)) return true;
|
|
126
|
+
if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true;
|
|
127
|
+
const embedsV4 = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && (h[5] === 65535 || h[5] === 0);
|
|
128
|
+
if (embedsV4) {
|
|
129
|
+
return isBlockedIpv4([h[6] >> 8, h[6] & 255, h[7] >> 8, h[7] & 255]);
|
|
130
|
+
}
|
|
131
|
+
if ((h[0] & 65024) === 64512) return true;
|
|
132
|
+
if ((h[0] & 65472) === 65152) return true;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
var bypassNoticeLogged = false;
|
|
136
|
+
function guardRelaxedForLocalDev() {
|
|
137
|
+
const raw = process.env["RVNXX_SSRF_ALLOW_PRIVATE"];
|
|
138
|
+
const relaxed = raw != null && ["1", "true", "yes"].includes(raw.trim().toLowerCase());
|
|
139
|
+
if (relaxed && !bypassNoticeLogged) {
|
|
140
|
+
bypassNoticeLogged = true;
|
|
141
|
+
console.warn(
|
|
142
|
+
"[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback fetch targets. Intended for local development only."
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return relaxed;
|
|
146
|
+
}
|
|
147
|
+
function blockedError(host, address) {
|
|
148
|
+
if (host === address) {
|
|
149
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked request to private or reserved address ${address}`, {
|
|
150
|
+
status: 0
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
console.warn(`[ssrf] blocked request to ${host}: resolves to private/reserved address ${address}`);
|
|
154
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked request to ${host}: resolves to a private or reserved address`, {
|
|
155
|
+
status: 0
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function resolveHost(lookup, host, signal) {
|
|
159
|
+
if (!signal) return lookup(host);
|
|
160
|
+
if (signal.aborted) throw signal.reason;
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const onAbort = () => reject(signal.reason);
|
|
163
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
lookup(host).then(
|
|
165
|
+
(addresses) => {
|
|
166
|
+
signal.removeEventListener("abort", onAbort);
|
|
167
|
+
resolve(addresses);
|
|
168
|
+
},
|
|
169
|
+
(err) => {
|
|
170
|
+
signal.removeEventListener("abort", onAbort);
|
|
171
|
+
reject(err);
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async function assertPublicUrl(url, opts = {}) {
|
|
177
|
+
const u = url instanceof URL ? url : new URL(url);
|
|
178
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
179
|
+
throw new NodeError("BLOCKED_ADDRESS", `Blocked non-HTTP(S) URL protocol: ${u.protocol}`, { status: 0 });
|
|
180
|
+
}
|
|
181
|
+
const rawHost = u.hostname;
|
|
182
|
+
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
|
|
183
|
+
if (!host) throw new NodeError("BLOCKED_ADDRESS", "Blocked URL with empty host", { status: 0 });
|
|
184
|
+
if (guardRelaxedForLocalDev()) return;
|
|
185
|
+
const lower = host.toLowerCase();
|
|
186
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) {
|
|
187
|
+
throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
|
|
188
|
+
}
|
|
189
|
+
if (isIP(host) !== 0) {
|
|
190
|
+
if (isBlockedAddress(host)) throw blockedError(host, host);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const lookup = opts.lookup ?? ssrfResolver.lookup;
|
|
194
|
+
const addresses = await resolveHost(lookup, host, opts.signal);
|
|
195
|
+
if (!addresses || addresses.length === 0) {
|
|
196
|
+
throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
|
|
197
|
+
}
|
|
198
|
+
for (const a of addresses) {
|
|
199
|
+
if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
65
203
|
// src/fetch.ts
|
|
66
204
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
67
205
|
var MAX_TIMEOUT_MS = 12e4;
|
|
206
|
+
var MAX_REDIRECTS = 5;
|
|
207
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
68
208
|
var DEFAULT_RETRY_ATTEMPTS = 0;
|
|
69
209
|
var MAX_RETRY_ATTEMPTS = 5;
|
|
70
210
|
var DEFAULT_RETRY_DELAY_MS = 1e3;
|
|
@@ -74,6 +214,77 @@ function clampResponseBytes(maxBytes) {
|
|
|
74
214
|
if (!Number.isFinite(maxBytes) || maxBytes < 1) return MAX_RESPONSE_BYTES;
|
|
75
215
|
return Math.min(maxBytes, MAX_RESPONSE_BYTES);
|
|
76
216
|
}
|
|
217
|
+
async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
218
|
+
const ac = new AbortController();
|
|
219
|
+
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
220
|
+
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
221
|
+
try {
|
|
222
|
+
return await fetch(url, { ...fetchOptions, signal });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
225
|
+
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
|
|
226
|
+
throw err;
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function guardUrl(url, ctxSignal, effectiveMs) {
|
|
232
|
+
const ac = new AbortController();
|
|
233
|
+
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
234
|
+
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
235
|
+
try {
|
|
236
|
+
await assertPublicUrl(url, { signal });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
239
|
+
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
|
|
240
|
+
throw err;
|
|
241
|
+
} finally {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
246
|
+
let currentUrl = url;
|
|
247
|
+
let currentInit = { ...fetchOptions, redirect: "manual" };
|
|
248
|
+
await guardUrl(currentUrl, ctxSignal, effectiveMs);
|
|
249
|
+
for (let hop = 0; ; hop++) {
|
|
250
|
+
const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
|
|
251
|
+
const location = res.headers.get("location");
|
|
252
|
+
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
253
|
+
const redirectStatus = res.status;
|
|
254
|
+
await res.body?.cancel().catch(() => {
|
|
255
|
+
});
|
|
256
|
+
if (hop >= MAX_REDIRECTS) {
|
|
257
|
+
throw new NodeError("TOO_MANY_REDIRECTS", `Exceeded ${MAX_REDIRECTS} redirects`, { status: redirectStatus });
|
|
258
|
+
}
|
|
259
|
+
const base = currentUrl instanceof URL ? currentUrl : new URL(currentUrl);
|
|
260
|
+
let nextUrl;
|
|
261
|
+
try {
|
|
262
|
+
nextUrl = new URL(location, base);
|
|
263
|
+
} catch {
|
|
264
|
+
throw new NodeError("BLOCKED_ADDRESS", "Blocked redirect with an invalid Location header", { status: 0 });
|
|
265
|
+
}
|
|
266
|
+
if (base.protocol === "https:" && nextUrl.protocol !== "https:") {
|
|
267
|
+
throw new NodeError("BLOCKED_ADDRESS", "Blocked https\u2192http downgrade on redirect", { status: 0 });
|
|
268
|
+
}
|
|
269
|
+
await guardUrl(nextUrl, ctxSignal, effectiveMs);
|
|
270
|
+
const headers = new Headers(currentInit.headers ?? void 0);
|
|
271
|
+
let method = (currentInit.method ?? "GET").toUpperCase();
|
|
272
|
+
let body = currentInit.body;
|
|
273
|
+
if (redirectStatus === 303 || (redirectStatus === 301 || redirectStatus === 302) && method === "POST") {
|
|
274
|
+
method = "GET";
|
|
275
|
+
body = void 0;
|
|
276
|
+
headers.delete("content-type");
|
|
277
|
+
headers.delete("content-length");
|
|
278
|
+
}
|
|
279
|
+
if (nextUrl.origin !== base.origin) {
|
|
280
|
+
headers.delete("authorization");
|
|
281
|
+
headers.delete("cookie");
|
|
282
|
+
headers.delete("proxy-authorization");
|
|
283
|
+
}
|
|
284
|
+
currentUrl = nextUrl;
|
|
285
|
+
currentInit = { ...currentInit, method, body, headers, redirect: "manual" };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
77
288
|
async function safeFetch(url, options = {}) {
|
|
78
289
|
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, ...fetchOptions } = options;
|
|
79
290
|
const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
@@ -84,29 +295,27 @@ async function safeFetch(url, options = {}) {
|
|
|
84
295
|
let lastError;
|
|
85
296
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
86
297
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
87
|
-
const ac = new AbortController();
|
|
88
|
-
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
89
|
-
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
90
298
|
try {
|
|
91
|
-
return await
|
|
299
|
+
return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
|
|
92
300
|
} catch (err) {
|
|
93
301
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
} else {
|
|
97
|
-
lastError = err;
|
|
302
|
+
if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS")) {
|
|
303
|
+
throw err;
|
|
98
304
|
}
|
|
305
|
+
lastError = err;
|
|
99
306
|
if (attempt < maxAttempts) {
|
|
100
307
|
await new Promise((resolve) => {
|
|
101
|
-
const
|
|
102
|
-
ctxSignal?.addEventListener("abort", () => {
|
|
308
|
+
const onAbort = () => {
|
|
103
309
|
clearTimeout(t);
|
|
104
310
|
resolve();
|
|
105
|
-
}
|
|
311
|
+
};
|
|
312
|
+
const t = setTimeout(() => {
|
|
313
|
+
ctxSignal?.removeEventListener("abort", onAbort);
|
|
314
|
+
resolve();
|
|
315
|
+
}, retryDelayMs);
|
|
316
|
+
ctxSignal?.addEventListener("abort", onAbort, { once: true });
|
|
106
317
|
});
|
|
107
318
|
}
|
|
108
|
-
} finally {
|
|
109
|
-
clearTimeout(timer);
|
|
110
319
|
}
|
|
111
320
|
}
|
|
112
321
|
throw lastError;
|
|
@@ -497,6 +706,7 @@ export {
|
|
|
497
706
|
DEFAULT_RETRY_POLICY,
|
|
498
707
|
DEFAULT_TIMEOUT_MS,
|
|
499
708
|
MANIFEST_VERSION,
|
|
709
|
+
MAX_REDIRECTS,
|
|
500
710
|
MAX_RESPONSE_BYTES,
|
|
501
711
|
MAX_RETRY_ATTEMPTS,
|
|
502
712
|
MAX_TIMEOUT_MS,
|
|
@@ -505,6 +715,7 @@ export {
|
|
|
505
715
|
OAuth2ClientCredentialsCredential,
|
|
506
716
|
RetryableError,
|
|
507
717
|
SimpleValueCredential,
|
|
718
|
+
assertPublicUrl,
|
|
508
719
|
backoffDelay,
|
|
509
720
|
buildManifest,
|
|
510
721
|
clampResponseBytes,
|
|
@@ -512,6 +723,7 @@ export {
|
|
|
512
723
|
extractCredentialManifests,
|
|
513
724
|
extractManifest,
|
|
514
725
|
extractManifests,
|
|
726
|
+
isBlockedAddress,
|
|
515
727
|
isNodeWithIteration,
|
|
516
728
|
isOAuthAuthorizeCredential,
|
|
517
729
|
maxBytesConfigField,
|