@revenexx/integrations-node-sdk 0.17.0 → 0.18.1

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 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 fetch(url, { ...fetchOptions, signal });
379
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
159
380
  } catch (err) {
160
381
  if (ctxSignal?.aborted) throw ctxSignal.reason;
161
- if (ac.signal.aborted) {
162
- lastError = new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
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 t = setTimeout(resolve, retryDelayMs);
169
- ctxSignal?.addEventListener("abort", () => {
388
+ const onAbort = () => {
170
389
  clearTimeout(t);
171
390
  resolve();
172
- }, { once: true });
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
@@ -122,6 +122,14 @@ interface INodeDescription {
122
122
  /** Associated images (screenshots, logos, banners) shipped with the package. */
123
123
  images?: IImage[];
124
124
  inputs: Record<string, IInputPort>;
125
+ /**
126
+ * Output ports the workflow editor wires to downstream nodes. An empty array
127
+ * marks a **terminal node**: the path ends here and no edge may leave it —
128
+ * the mirror image of `inputs: {}` on a trigger. `StopAndErrorNode` is the
129
+ * canonical case; it always throws, so there is no success path to wire, and
130
+ * a port that can never fire would leave a dead handle on the canvas. The
131
+ * key stays required so terminality is declared, not forgotten.
132
+ */
125
133
  outputs: IOutputPort[];
126
134
  config?: IConfigField[];
127
135
  }
@@ -443,6 +451,8 @@ declare function extractCredentialManifests(credentials: ICredential[]): ICreden
443
451
 
444
452
  declare const DEFAULT_TIMEOUT_MS = 30000;
445
453
  declare const MAX_TIMEOUT_MS = 120000;
454
+ /** Maximum number of redirect hops `safeFetch` follows before giving up. */
455
+ declare const MAX_REDIRECTS = 5;
446
456
  declare const DEFAULT_RETRY_ATTEMPTS = 0;
447
457
  declare const MAX_RETRY_ATTEMPTS = 5;
448
458
  declare const DEFAULT_RETRY_DELAY_MS = 1000;
@@ -506,6 +516,48 @@ declare function retryConfigFields(opts?: {
506
516
  defaultDelayMs?: number;
507
517
  }): IConfigField[];
508
518
 
519
+ /**
520
+ * A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
521
+ * `family` is `4` or `6`; only `address` is consulted by the guard.
522
+ */
523
+ interface LookupAddress {
524
+ address: string;
525
+ family: number;
526
+ }
527
+ /**
528
+ * Resolves a hostname to every address it maps to. Modelled on
529
+ * `dns.lookup(host, { all: true })`. Injectable so tests can drive the guard
530
+ * deterministically without real DNS — see {@link ssrfResolver}.
531
+ */
532
+ type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
533
+ /**
534
+ * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
535
+ * loopback, link-local or otherwise non-public target that a server-side fetch
536
+ * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
537
+ * and re-checked against the IPv4 rules. An address we cannot parse is treated as
538
+ * blocked (fail-closed).
539
+ */
540
+ declare function isBlockedAddress(ip: string): boolean;
541
+ /**
542
+ * Assert that `url` is safe for a server-side fetch: an http(s) URL whose target
543
+ * resolves only to public addresses. Rejects non-http(s) protocols, empty hosts
544
+ * and `localhost`, checks literal-IP hosts directly, and otherwise resolves the
545
+ * hostname (via the injectable `lookup`, defaulting to {@link ssrfResolver}) and
546
+ * rejects if **any** resolved address is private/reserved. Throws
547
+ * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
548
+ *
549
+ * Best-effort by design: Node re-resolves the hostname when it actually connects,
550
+ * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
551
+ *
552
+ * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
553
+ * hostile DNS resolve cannot block the guard past that budget — see
554
+ * {@link resolveHost}.
555
+ */
556
+ declare function assertPublicUrl(url: string | URL, opts?: {
557
+ lookup?: LookupFn;
558
+ signal?: AbortSignal;
559
+ }): Promise<void>;
560
+
509
561
  /**
510
562
  * Transport-agnostic retry/backoff primitive (PO-139).
511
563
  *
@@ -752,4 +804,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
752
804
  test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
753
805
  }
754
806
 
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 };
807
+ 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
@@ -122,6 +122,14 @@ interface INodeDescription {
122
122
  /** Associated images (screenshots, logos, banners) shipped with the package. */
123
123
  images?: IImage[];
124
124
  inputs: Record<string, IInputPort>;
125
+ /**
126
+ * Output ports the workflow editor wires to downstream nodes. An empty array
127
+ * marks a **terminal node**: the path ends here and no edge may leave it —
128
+ * the mirror image of `inputs: {}` on a trigger. `StopAndErrorNode` is the
129
+ * canonical case; it always throws, so there is no success path to wire, and
130
+ * a port that can never fire would leave a dead handle on the canvas. The
131
+ * key stays required so terminality is declared, not forgotten.
132
+ */
125
133
  outputs: IOutputPort[];
126
134
  config?: IConfigField[];
127
135
  }
@@ -443,6 +451,8 @@ declare function extractCredentialManifests(credentials: ICredential[]): ICreden
443
451
 
444
452
  declare const DEFAULT_TIMEOUT_MS = 30000;
445
453
  declare const MAX_TIMEOUT_MS = 120000;
454
+ /** Maximum number of redirect hops `safeFetch` follows before giving up. */
455
+ declare const MAX_REDIRECTS = 5;
446
456
  declare const DEFAULT_RETRY_ATTEMPTS = 0;
447
457
  declare const MAX_RETRY_ATTEMPTS = 5;
448
458
  declare const DEFAULT_RETRY_DELAY_MS = 1000;
@@ -506,6 +516,48 @@ declare function retryConfigFields(opts?: {
506
516
  defaultDelayMs?: number;
507
517
  }): IConfigField[];
508
518
 
519
+ /**
520
+ * A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
521
+ * `family` is `4` or `6`; only `address` is consulted by the guard.
522
+ */
523
+ interface LookupAddress {
524
+ address: string;
525
+ family: number;
526
+ }
527
+ /**
528
+ * Resolves a hostname to every address it maps to. Modelled on
529
+ * `dns.lookup(host, { all: true })`. Injectable so tests can drive the guard
530
+ * deterministically without real DNS — see {@link ssrfResolver}.
531
+ */
532
+ type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
533
+ /**
534
+ * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
535
+ * loopback, link-local or otherwise non-public target that a server-side fetch
536
+ * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
537
+ * and re-checked against the IPv4 rules. An address we cannot parse is treated as
538
+ * blocked (fail-closed).
539
+ */
540
+ declare function isBlockedAddress(ip: string): boolean;
541
+ /**
542
+ * Assert that `url` is safe for a server-side fetch: an http(s) URL whose target
543
+ * resolves only to public addresses. Rejects non-http(s) protocols, empty hosts
544
+ * and `localhost`, checks literal-IP hosts directly, and otherwise resolves the
545
+ * hostname (via the injectable `lookup`, defaulting to {@link ssrfResolver}) and
546
+ * rejects if **any** resolved address is private/reserved. Throws
547
+ * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
548
+ *
549
+ * Best-effort by design: Node re-resolves the hostname when it actually connects,
550
+ * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
551
+ *
552
+ * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
553
+ * hostile DNS resolve cannot block the guard past that budget — see
554
+ * {@link resolveHost}.
555
+ */
556
+ declare function assertPublicUrl(url: string | URL, opts?: {
557
+ lookup?: LookupFn;
558
+ signal?: AbortSignal;
559
+ }): Promise<void>;
560
+
509
561
  /**
510
562
  * Transport-agnostic retry/backoff primitive (PO-139).
511
563
  *
@@ -752,4 +804,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
752
804
  test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
753
805
  }
754
806
 
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 };
807
+ 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 fetch(url, { ...fetchOptions, signal });
299
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
92
300
  } catch (err) {
93
301
  if (ctxSignal?.aborted) throw ctxSignal.reason;
94
- if (ac.signal.aborted) {
95
- lastError = new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
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 t = setTimeout(resolve, retryDelayMs);
102
- ctxSignal?.addEventListener("abort", () => {
308
+ const onAbort = () => {
103
309
  clearTimeout(t);
104
310
  resolve();
105
- }, { once: true });
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "TypeScript interfaces and utilities for Revenexx integration nodes",
5
5
  "license": "MIT",
6
6
  "author": "revenexx GmbH",