@revenexx/integrations-node-sdk 1.1.0 → 1.3.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 CHANGED
@@ -4,9 +4,10 @@ The shared TypeScript contract library for **Revenexx integration nodes**. It
4
4
  defines the interfaces every integration node and credential must implement, and
5
5
  ships the small helper toolchain the node registry uses to build manifests.
6
6
 
7
- It has **no runtime dependencies** and contains no business logic beyond the
8
- manifest helpers — it exists purely to share one contract between the node
9
- packages and the workflow engine that runs them.
7
+ It carries **one runtime dependency** — `ipaddr.js`, which the SSRF guard defers
8
+ its address classification to (PO-183) — and no business logic beyond the manifest
9
+ helpers: it exists purely to share one contract between the node packages and the
10
+ workflow engine that runs them.
10
11
 
11
12
  ```
12
13
  integrations-node-sdk ← this package (types & helpers only)
@@ -34,7 +35,7 @@ configuration or auth token needed.
34
35
  | `credentialType` | `normalizeCredentialType` — normalise a credential-type reference (`string \| string[] \| undefined`) to `string[]`. |
35
36
  | `errors` | `NodeError` — typed error class for unexpected/system-level failures thrown inside `execute`. |
36
37
  | `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). |
38
+ | `ssrf` | `assertPublicUrl` / `isBlockedAddress` — the SSRF guard that backs `safeFetch`, exported for reuse. An address is refused unless it is public unicast — `ipaddr.js` classifies the range, and for IPv6 the address must also sit inside IANA's allocated `2000::/3`, since that classification's *default* verdict is `unicast`. So reserved and unallocated bands nobody enumerated are refused too (PO-183). These two are the pre-flight check; `safeFetch` additionally judges the address each connection lands on, so a DNS answer that changes between check and connect gets no request (PO-184). 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). |
38
39
  | `extract` | `extractManifest` / `extractManifests` (nodes) and `extractCredentialManifest` / `extractCredentialManifests` (credentials) — pull descriptions off instances without executing them. |
39
40
  | `manifest` | `buildManifest` / `MANIFEST_VERSION` — wrap node, credential and template descriptions in the `{ manifestVersion, nodes, credentials, templates }` envelope the registry expects. |
40
41
 
package/dist/index.cjs CHANGED
@@ -54,11 +54,14 @@ __export(index_exports, {
54
54
  backoffDelay: () => backoffDelay,
55
55
  buildManifest: () => buildManifest,
56
56
  clampResponseBytes: () => clampResponseBytes,
57
+ errorPort: () => errorPort,
58
+ errorResult: () => errorResult,
57
59
  evaluate: () => evaluate,
58
60
  extractCredentialManifest: () => extractCredentialManifest,
59
61
  extractCredentialManifests: () => extractCredentialManifests,
60
62
  extractManifest: () => extractManifest,
61
63
  extractManifests: () => extractManifests,
64
+ httpErrorResult: () => httpErrorResult,
62
65
  isBlockedAddress: () => isBlockedAddress,
63
66
  isNodeWithIteration: () => isNodeWithIteration,
64
67
  isOAuthAuthorizeCredential: () => isOAuthAuthorizeCredential,
@@ -76,6 +79,8 @@ __export(index_exports, {
76
79
  sleepWithSignal: () => sleepWithSignal,
77
80
  takesValue: () => takesValue,
78
81
  timeoutConfigField: () => timeoutConfigField,
82
+ toErrorOutput: () => toErrorOutput,
83
+ toErrorResult: () => toErrorResult,
79
84
  withRetry: () => withRetry
80
85
  });
81
86
  module.exports = __toCommonJS(index_exports);
@@ -203,6 +208,76 @@ var NodeError = class extends Error {
203
208
  }
204
209
  };
205
210
 
211
+ // src/nodeErrors.ts
212
+ function toErrorOutput(err) {
213
+ if (err instanceof NodeError) {
214
+ const raw = err.meta?.["status"];
215
+ const status = typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
216
+ return { code: err.code, message: err.message, status };
217
+ }
218
+ if (err instanceof Error && err.name === "AbortError") {
219
+ return { code: "TIMEOUT", message: "Request timed out", status: 0 };
220
+ }
221
+ if (err instanceof Error) {
222
+ return { code: "REQUEST_FAILED", message: err.message, status: 0 };
223
+ }
224
+ return { code: "REQUEST_FAILED", message: String(err), status: 0 };
225
+ }
226
+ function errorResult(output, port = "error") {
227
+ return { outputs: { [port]: output }, branch: port };
228
+ }
229
+ function toErrorResult(err, port = "error") {
230
+ return errorResult(toErrorOutput(err), port);
231
+ }
232
+ function httpErrorResult(err, port = "error") {
233
+ const body = err instanceof NodeError ? err.meta?.["body"] ?? null : null;
234
+ return errorResult({ ...toErrorOutput(err), body }, port);
235
+ }
236
+ function errorPort(opts = {}) {
237
+ return {
238
+ name: "error",
239
+ kind: "error",
240
+ dataType: "object",
241
+ label: { en: "Error", de: "Fehler" },
242
+ description: {
243
+ en: "Taken when the attempt fails in a way the workflow can carry on from. A node that cannot run as configured fails the run instead of taking this path.",
244
+ de: "Wird genommen, wenn der Versuch auf eine Weise fehlschl\xE4gt, mit der der Workflow weiterarbeiten kann. Ein Node, der so konfiguriert nicht laufen kann, l\xE4sst den Run scheitern, statt diesen Pfad zu nehmen."
245
+ },
246
+ fields: {
247
+ code: {
248
+ dataType: "string",
249
+ description: {
250
+ en: "Machine-readable name of the failure \u2014 branch on this rather than on the message. A node that talks to a remote system shares `TIMEOUT` and `REQUEST_FAILED` with every other one; the remaining codes belong to this node alone.",
251
+ de: "Maschinenlesbarer Name des Fehlers \u2014 hierauf verzweigen, nicht auf die Meldung. Ein Node, der mit einem Fremdsystem spricht, teilt `TIMEOUT` und `REQUEST_FAILED` mit allen anderen; die \xFCbrigen Codes geh\xF6ren allein zu diesem Node."
252
+ }
253
+ },
254
+ message: {
255
+ dataType: "string",
256
+ description: {
257
+ en: "Wording for whoever reads the run afterwards, frequently the wording the far end used. It is not a stable value to branch on.",
258
+ de: "Wortlaut f\xFCr den, der den Run sp\xE4ter liest, h\xE4ufig der des Gegen\xFCbers. Kein stabiler Wert, auf den sich Verzweigungen st\xFCtzen sollten."
259
+ }
260
+ },
261
+ status: {
262
+ dataType: "number",
263
+ description: {
264
+ en: "How the far end answered, as an HTTP status code, where one answered with a status at all. `0` otherwise \u2014 a timeout, a refused connection, an input the node could not use, a node that never leaves the workflow, or a failure the far end reported in its own answer rather than in a status, as a bulk job that ends as failed does. So `0` means there is no status to read, not that nothing was reached.",
265
+ de: "Wie das Gegen\xFCber geantwortet hat, als HTTP-Statuscode \u2014 sofern eines \xFCberhaupt mit einem Status geantwortet hat. Sonst `0`: Timeout, abgelehnte Verbindung, eine unbrauchbare Eingabe, ein Node, der den Workflow nicht verl\xE4sst, oder ein Fehler, den das Gegen\xFCber in seiner Antwort statt im Status gemeldet hat \u2014 wie ein Bulk-Job, der als fehlgeschlagen endet. `0` hei\xDFt also: kein Status zu lesen, nicht: nichts erreicht."
266
+ }
267
+ },
268
+ ...opts.body ? {
269
+ body: {
270
+ dataType: "any",
271
+ description: {
272
+ en: "The answer the far end gave, where the failure got far enough to have one: the response body, or the job the platform reported as failed.",
273
+ de: "Die Antwort des Gegen\xFCbers, sofern der Fehler weit genug kam, um eine zu haben: der Response-Body oder der von der Plattform als fehlgeschlagen gemeldete Job."
274
+ }
275
+ }
276
+ } : {}
277
+ }
278
+ };
279
+ }
280
+
206
281
  // src/extract.ts
207
282
  function extractManifest(node) {
208
283
  return node.description;
@@ -218,7 +293,9 @@ function extractCredentialManifests(credentials) {
218
293
  }
219
294
 
220
295
  // src/ssrf.ts
296
+ var import_node_diagnostics_channel = require("diagnostics_channel");
221
297
  var import_node_net = require("net");
298
+ var import_ipaddr = __toESM(require("ipaddr.js"), 1);
222
299
  var ssrfResolver = {
223
300
  lookup: async (hostname) => {
224
301
  const { lookup } = await import("dns/promises");
@@ -226,66 +303,27 @@ var ssrfResolver = {
226
303
  return results.map((r) => ({ address: r.address, family: r.family }));
227
304
  }
228
305
  };
229
- function parseIpv4(input) {
230
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(input);
231
- if (!m) return null;
232
- const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
233
- if (octets.some((n) => n > 255)) return null;
234
- return octets;
235
- }
236
- function expandIpv6(input) {
237
- let s = input;
238
- const zone = s.indexOf("%");
239
- if (zone !== -1) s = s.slice(0, zone);
240
- if (s.includes(".")) {
241
- const idx = s.lastIndexOf(":");
242
- if (idx === -1) return null;
243
- const v4 = parseIpv4(s.slice(idx + 1));
244
- if (!v4) return null;
245
- const hi = (v4[0] << 8 | v4[1]).toString(16);
246
- const lo = (v4[2] << 8 | v4[3]).toString(16);
247
- s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
248
- }
249
- const halves = s.split("::");
250
- if (halves.length > 2) return null;
251
- const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
252
- const head = parseGroups(halves[0] ?? "");
253
- const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
254
- const declared = [...head, ...back ?? []];
255
- if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
256
- let hextets;
257
- if (back === null) {
258
- hextets = head;
259
- } else {
260
- const zeros = 8 - (head.length + back.length);
261
- if (zeros < 1) return null;
262
- hextets = [...head, ...new Array(zeros).fill(0), ...back];
263
- }
264
- return hextets.length === 8 ? hextets : null;
265
- }
266
- function isBlockedIpv4(o) {
267
- const [a, b] = o;
268
- return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
269
- a === 127 || // 127.0.0.0/8 loopback
270
- a === 10 || // 10.0.0.0/8 private
271
- a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
272
- a === 192 && b === 168 || // 192.168.0.0/16 private
273
- a === 169 && b === 254;
306
+ function isIPv6(addr) {
307
+ return addr.kind() === "ipv6";
308
+ }
309
+ var PUBLIC_RANGE = "unicast";
310
+ var GLOBAL_UNICAST_V6 = import_ipaddr.default.parseCIDR("2000::/3");
311
+ var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
312
+ function embeddedIpv4(addr) {
313
+ return import_ipaddr.default.fromByteArray(addr.toByteArray().slice(12));
274
314
  }
275
315
  function isBlockedAddress(ip) {
276
- const v4 = parseIpv4(ip);
277
- if (v4) return isBlockedIpv4(v4);
278
- const h = expandIpv6(ip);
279
- if (!h) return true;
280
- if (h.every((x) => x === 0)) return true;
281
- if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true;
282
- const embedsV4 = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && (h[5] === 65535 || h[5] === 0);
283
- if (embedsV4) {
284
- return isBlockedIpv4([h[6] >> 8, h[6] & 255, h[7] >> 8, h[7] & 255]);
285
- }
286
- if ((h[0] & 65024) === 64512) return true;
287
- if ((h[0] & 65472) === 65152) return true;
288
- return false;
316
+ let addr;
317
+ try {
318
+ addr = import_ipaddr.default.parse(ip);
319
+ } catch {
320
+ return true;
321
+ }
322
+ if (isIPv6(addr)) {
323
+ if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
324
+ if (!addr.match(GLOBAL_UNICAST_V6)) return true;
325
+ }
326
+ return addr.range() !== PUBLIC_RANGE;
289
327
  }
290
328
  var bypassNoticeLogged = false;
291
329
  function guardRelaxedForLocalDev() {
@@ -310,6 +348,82 @@ function blockedError(host, address) {
310
348
  status: 0
311
349
  });
312
350
  }
351
+ var guardedTargets = /* @__PURE__ */ new Map();
352
+ var connectGuardInstalled = false;
353
+ function normalizeHost(hostname) {
354
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
355
+ return host.toLowerCase();
356
+ }
357
+ function targetKey(hostname, port, protocol) {
358
+ const host = normalizeHost(hostname);
359
+ const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
360
+ return `${host}:${effective}`;
361
+ }
362
+ var unreadableMessageLogged = false;
363
+ function reportUnreadableMessage() {
364
+ if (unreadableMessageLogged) return;
365
+ unreadableMessageLogged = true;
366
+ console.warn(
367
+ "[ssrf] undici:client:connected published a message this guard cannot read: the connect-time half of the SSRF guard is not judging connections. The pre-flight check still applies."
368
+ );
369
+ }
370
+ function connectBlockedError(host, address) {
371
+ if (address == null) {
372
+ console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
373
+ return new NodeError(
374
+ "BLOCKED_ADDRESS",
375
+ `Blocked request to ${host}: the address the connection landed on could not be read`,
376
+ { status: 0 }
377
+ );
378
+ }
379
+ if (host === address) {
380
+ return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
381
+ status: 0
382
+ });
383
+ }
384
+ console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
385
+ return new NodeError(
386
+ "BLOCKED_ADDRESS",
387
+ `Blocked request to ${host}: the connection landed on a private or reserved address`,
388
+ { status: 0 }
389
+ );
390
+ }
391
+ function judgeConnection(message) {
392
+ const { connectParams, socket } = message ?? {};
393
+ const hostname = connectParams?.hostname;
394
+ const port = connectParams?.port;
395
+ const protocol = connectParams?.protocol;
396
+ if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
397
+ reportUnreadableMessage();
398
+ return;
399
+ }
400
+ if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
401
+ if (guardRelaxedForLocalDev()) return;
402
+ const peer = socket.remoteAddress;
403
+ if (peer != null && !isBlockedAddress(peer)) return;
404
+ socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
405
+ }
406
+ function guardConnectionsTo(url) {
407
+ if (!connectGuardInstalled) {
408
+ connectGuardInstalled = true;
409
+ (0, import_node_diagnostics_channel.subscribe)("undici:client:connected", judgeConnection);
410
+ }
411
+ const key = targetKey(url.hostname, url.port, url.protocol);
412
+ guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
413
+ let released = false;
414
+ return () => {
415
+ if (released) return;
416
+ released = true;
417
+ const count = (guardedTargets.get(key) ?? 1) - 1;
418
+ if (count > 0) guardedTargets.set(key, count);
419
+ else guardedTargets.delete(key);
420
+ };
421
+ }
422
+ function connectionRefusal(err) {
423
+ const cause = err?.cause;
424
+ if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
425
+ return void 0;
426
+ }
313
427
  async function resolveHost(lookup, host, signal) {
314
428
  if (!signal) return lookup(host);
315
429
  if (signal.aborted) throw signal.reason;
@@ -376,6 +490,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
376
490
  try {
377
491
  return await fetch(url, { ...fetchOptions, signal });
378
492
  } catch (err) {
493
+ const blocked = connectionRefusal(err);
494
+ if (blocked) throw blocked;
379
495
  if (ctxSignal?.aborted) throw ctxSignal.reason;
380
496
  if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
381
497
  throw err;
@@ -402,7 +518,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
402
518
  let currentInit = { ...fetchOptions, redirect: "manual" };
403
519
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
404
520
  for (let hop = 0; ; hop++) {
405
- const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
521
+ const release = guardConnectionsTo(
522
+ currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
523
+ );
524
+ let res;
525
+ try {
526
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
527
+ } finally {
528
+ release();
529
+ }
406
530
  const location = res.headers.get("location");
407
531
  if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
408
532
  const redirectStatus = res.status;
@@ -486,6 +610,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
486
610
  const cap = clampResponseBytes(maxBytes);
487
611
  const declared = Number(res.headers.get("content-length"));
488
612
  if (Number.isFinite(declared) && declared > cap) {
613
+ await res.body?.cancel().catch(() => {
614
+ });
489
615
  throw tooLargeError(res.status, declared, cap);
490
616
  }
491
617
  const body = res.body;
@@ -694,6 +820,7 @@ function expiryFromExpiresIn(expiresIn) {
694
820
  }
695
821
  return new Date(Date.now() + seconds * 1e3).toISOString();
696
822
  }
823
+ var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
697
824
  var BaseCredential = class {
698
825
  async test(_ctx, config) {
699
826
  for (const field of this.description.fields) {
@@ -721,6 +848,12 @@ var BaseCredential = class {
721
848
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
722
849
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
723
850
  * redirect; one that does was never going to complete the exchange anyway.
851
+ *
852
+ * The third protection is the caller's own: safeFetch hands back a `Response`
853
+ * and reading it is not its job, so the answer goes through {@link readText}
854
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
855
+ * Both branches below read that same string, so the cap covers the refusal as
856
+ * well as the token — an error body is the less predictable of the two.
724
857
  */
725
858
  async postForm(ctx, url, form, headers = {}) {
726
859
  const body = new URLSearchParams();
@@ -735,7 +868,7 @@ var BaseCredential = class {
735
868
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
736
869
  body
737
870
  });
738
- const text = await res.text();
871
+ const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
739
872
  if (!res.ok) {
740
873
  throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
741
874
  }
@@ -922,11 +1055,14 @@ function base64Url(buf) {
922
1055
  backoffDelay,
923
1056
  buildManifest,
924
1057
  clampResponseBytes,
1058
+ errorPort,
1059
+ errorResult,
925
1060
  evaluate,
926
1061
  extractCredentialManifest,
927
1062
  extractCredentialManifests,
928
1063
  extractManifest,
929
1064
  extractManifests,
1065
+ httpErrorResult,
930
1066
  isBlockedAddress,
931
1067
  isNodeWithIteration,
932
1068
  isOAuthAuthorizeCredential,
@@ -944,5 +1080,7 @@ function base64Url(buf) {
944
1080
  sleepWithSignal,
945
1081
  takesValue,
946
1082
  timeoutConfigField,
1083
+ toErrorOutput,
1084
+ toErrorResult,
947
1085
  withRetry
948
1086
  });
package/dist/index.d.cts CHANGED
@@ -650,6 +650,67 @@ declare class NodeError extends Error {
650
650
  constructor(code: string, message: string, meta?: Record<string, unknown>);
651
651
  }
652
652
 
653
+ /**
654
+ * Canonical error-output shape emitted on a node's `error` port. It lives here
655
+ * rather than in a node package because all four of them were building it
656
+ * separately — `integrations-nodes-core` in an internal module, `deepl` in a
657
+ * copy of that module, Business Central in `bc-fetch.ts` and Pipedrive in
658
+ * `pipedrive-client.ts` — so the workflow editor's guarantee of one
659
+ * `{ code, message, status }` payload rested on four files agreeing by hand
660
+ * (PO-442; asked for by PO-134 in June and not done then).
661
+ */
662
+ interface NodeErrorOutput {
663
+ /** Machine-readable error code, e.g. `HTTP_ERROR`, `TIMEOUT`, `REQUEST_FAILED`. */
664
+ code: string;
665
+ message: string;
666
+ /** HTTP status code, or `0` for timeouts / network / non-HTTP errors. */
667
+ status: number;
668
+ /** Optional response body — HTTP-specific. */
669
+ body?: unknown;
670
+ }
671
+ /**
672
+ * Normalize any caught value into the canonical {@link NodeErrorOutput} shape.
673
+ * Use this in a node's `catch` block before routing to the `error` port.
674
+ */
675
+ declare function toErrorOutput(err: unknown): NodeErrorOutput;
676
+ /** Wrap an error output into an `INodeResult` routed to the `error` port. */
677
+ declare function errorResult(output: NodeErrorOutput, port?: string): INodeResult;
678
+ /**
679
+ * Catch-block shortcut: normalize any thrown value and route it to the failure
680
+ * output. The one helper every node's `catch` funnels into, so the
681
+ * `{ code, message, status }` mapping lives in a single place instead of being
682
+ * re-implemented per protocol (FTP/SFTP) or per package.
683
+ *
684
+ * `port` is the output taken, `error` unless the node declares its failure
685
+ * output under another name (`{ ...errorPort(), name: 'failed' }`) — pass the
686
+ * same name here, or the branch names a port that node does not have.
687
+ */
688
+ declare function toErrorResult(err: unknown, port?: string): INodeResult;
689
+ /**
690
+ * Catch-block shortcut for the HTTP-style nodes whose failure output additionally
691
+ * declares `body`: the canonical `{ code, message, status }` mapping plus the
692
+ * response body a {@link NodeError} carries in `meta.body` (`null` when the
693
+ * failure never reached a response). `port` names the output as above.
694
+ */
695
+ declare function httpErrorResult(err: unknown, port?: string): INodeResult;
696
+ /**
697
+ * Shared `error` output port — the uniform `{ code, message, status }` triple
698
+ * declared on every node that has an `error` branch. Pass `body: true` for
699
+ * HTTP-style nodes that additionally emit a response body on the port.
700
+ *
701
+ * The sentences travel with the port. The three values mean the same thing
702
+ * behind every node, so they are written here once instead of per node; a node
703
+ * whose failures are worth naming more precisely spreads the port and replaces
704
+ * only its `description` (`{ ...errorPort(), description: … }`).
705
+ *
706
+ * The second sentence of the description is the rule this port exists under,
707
+ * and it is promised in `specs/error-handling.md`: a fault in the author's own
708
+ * configuration ends the run, a fault met while running is offered here.
709
+ */
710
+ declare function errorPort(opts?: {
711
+ body?: boolean;
712
+ }): IOutputPort;
713
+
653
714
  declare function extractManifest(node: INode): INodeDescription;
654
715
  declare function extractManifests(nodes: INode[]): INodeDescription[];
655
716
  declare function extractCredentialManifest(credential: ICredential): ICredentialDescription;
@@ -694,7 +755,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
694
755
  * The `Content-Length` header is used as a fast-reject (bail before downloading
695
756
  * anything), but the limit is *also* enforced while streaming, since the header
696
757
  * can be absent or lie. On overrun the stream is cancelled and a
697
- * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
758
+ * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
759
+ * cancel the body first, so neither leaves the connection held.
698
760
  */
699
761
  declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
700
762
  /** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
@@ -738,10 +800,19 @@ interface LookupAddress {
738
800
  type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
739
801
  /**
740
802
  * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
741
- * loopback, link-local or otherwise non-public target that a server-side fetch
742
- * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
743
- * and re-checked against the IPv4 rules. An address we cannot parse is treated as
803
+ * loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
804
+ * unallocated or otherwise non-public target that a server-side fetch must never be
805
+ * steered to.
806
+ *
807
+ * The verdict is an allow-list: the address is refused unless the classification
808
+ * calls its range public unicast and — for IPv6, where that verdict is a default
809
+ * rather than a determination — it also sits inside the block IANA has allocated
810
+ * (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
811
+ * unwrapped and the embedded address judged in its place, so a public host stays
812
+ * reachable when it is written that way. An address we cannot parse is treated as
744
813
  * blocked (fail-closed).
814
+ *
815
+ * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
745
816
  */
746
817
  declare function isBlockedAddress(ip: string): boolean;
747
818
  /**
@@ -752,8 +823,10 @@ declare function isBlockedAddress(ip: string): boolean;
752
823
  * rejects if **any** resolved address is private/reserved. Throws
753
824
  * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
754
825
  *
755
- * Best-effort by design: Node re-resolves the hostname when it actually connects,
756
- * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
826
+ * This is the pre-flight half of the guard: the connection that follows resolves
827
+ * the name again on its own, so `safeFetch` also judges the address the socket
828
+ * actually reached (PO-184). A caller that opens its own request after
829
+ * `assertPublicUrl` gets the check and not that second half.
757
830
  *
758
831
  * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
759
832
  * hostile DNS resolve cannot block the guard past that budget — see
@@ -952,6 +1025,12 @@ declare abstract class BaseCredential implements ICredential {
952
1025
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
953
1026
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
954
1027
  * redirect; one that does was never going to complete the exchange anyway.
1028
+ *
1029
+ * The third protection is the caller's own: safeFetch hands back a `Response`
1030
+ * and reading it is not its job, so the answer goes through {@link readText}
1031
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
1032
+ * Both branches below read that same string, so the cap covers the refusal as
1033
+ * well as the token — an error body is the less predictable of the two.
955
1034
  */
956
1035
  protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
957
1036
  }
@@ -1028,4 +1107,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
1028
1107
  test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
1029
1108
  }
1030
1109
 
1031
- 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 INodeState, type INodeWithIteration, type IOutputField, type IOutputPort, type IShowIfCondition, 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, OPERATORS, type Operator, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, VALUELESS_OPERATORS, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, evaluate, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, isOperator, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, settingApplies, sleepWithSignal, takesValue, timeoutConfigField, withRetry };
1110
+ 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 INodeState, type INodeWithIteration, type IOutputField, type IOutputPort, type IShowIfCondition, 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 NodeErrorOutput, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, OPERATORS, type Operator, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, VALUELESS_OPERATORS, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, errorPort, errorResult, evaluate, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, httpErrorResult, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, isOperator, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, settingApplies, sleepWithSignal, takesValue, timeoutConfigField, toErrorOutput, toErrorResult, withRetry };
package/dist/index.d.ts CHANGED
@@ -650,6 +650,67 @@ declare class NodeError extends Error {
650
650
  constructor(code: string, message: string, meta?: Record<string, unknown>);
651
651
  }
652
652
 
653
+ /**
654
+ * Canonical error-output shape emitted on a node's `error` port. It lives here
655
+ * rather than in a node package because all four of them were building it
656
+ * separately — `integrations-nodes-core` in an internal module, `deepl` in a
657
+ * copy of that module, Business Central in `bc-fetch.ts` and Pipedrive in
658
+ * `pipedrive-client.ts` — so the workflow editor's guarantee of one
659
+ * `{ code, message, status }` payload rested on four files agreeing by hand
660
+ * (PO-442; asked for by PO-134 in June and not done then).
661
+ */
662
+ interface NodeErrorOutput {
663
+ /** Machine-readable error code, e.g. `HTTP_ERROR`, `TIMEOUT`, `REQUEST_FAILED`. */
664
+ code: string;
665
+ message: string;
666
+ /** HTTP status code, or `0` for timeouts / network / non-HTTP errors. */
667
+ status: number;
668
+ /** Optional response body — HTTP-specific. */
669
+ body?: unknown;
670
+ }
671
+ /**
672
+ * Normalize any caught value into the canonical {@link NodeErrorOutput} shape.
673
+ * Use this in a node's `catch` block before routing to the `error` port.
674
+ */
675
+ declare function toErrorOutput(err: unknown): NodeErrorOutput;
676
+ /** Wrap an error output into an `INodeResult` routed to the `error` port. */
677
+ declare function errorResult(output: NodeErrorOutput, port?: string): INodeResult;
678
+ /**
679
+ * Catch-block shortcut: normalize any thrown value and route it to the failure
680
+ * output. The one helper every node's `catch` funnels into, so the
681
+ * `{ code, message, status }` mapping lives in a single place instead of being
682
+ * re-implemented per protocol (FTP/SFTP) or per package.
683
+ *
684
+ * `port` is the output taken, `error` unless the node declares its failure
685
+ * output under another name (`{ ...errorPort(), name: 'failed' }`) — pass the
686
+ * same name here, or the branch names a port that node does not have.
687
+ */
688
+ declare function toErrorResult(err: unknown, port?: string): INodeResult;
689
+ /**
690
+ * Catch-block shortcut for the HTTP-style nodes whose failure output additionally
691
+ * declares `body`: the canonical `{ code, message, status }` mapping plus the
692
+ * response body a {@link NodeError} carries in `meta.body` (`null` when the
693
+ * failure never reached a response). `port` names the output as above.
694
+ */
695
+ declare function httpErrorResult(err: unknown, port?: string): INodeResult;
696
+ /**
697
+ * Shared `error` output port — the uniform `{ code, message, status }` triple
698
+ * declared on every node that has an `error` branch. Pass `body: true` for
699
+ * HTTP-style nodes that additionally emit a response body on the port.
700
+ *
701
+ * The sentences travel with the port. The three values mean the same thing
702
+ * behind every node, so they are written here once instead of per node; a node
703
+ * whose failures are worth naming more precisely spreads the port and replaces
704
+ * only its `description` (`{ ...errorPort(), description: … }`).
705
+ *
706
+ * The second sentence of the description is the rule this port exists under,
707
+ * and it is promised in `specs/error-handling.md`: a fault in the author's own
708
+ * configuration ends the run, a fault met while running is offered here.
709
+ */
710
+ declare function errorPort(opts?: {
711
+ body?: boolean;
712
+ }): IOutputPort;
713
+
653
714
  declare function extractManifest(node: INode): INodeDescription;
654
715
  declare function extractManifests(nodes: INode[]): INodeDescription[];
655
716
  declare function extractCredentialManifest(credential: ICredential): ICredentialDescription;
@@ -694,7 +755,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
694
755
  * The `Content-Length` header is used as a fast-reject (bail before downloading
695
756
  * anything), but the limit is *also* enforced while streaming, since the header
696
757
  * can be absent or lie. On overrun the stream is cancelled and a
697
- * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
758
+ * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
759
+ * cancel the body first, so neither leaves the connection held.
698
760
  */
699
761
  declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
700
762
  /** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
@@ -738,10 +800,19 @@ interface LookupAddress {
738
800
  type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
739
801
  /**
740
802
  * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
741
- * loopback, link-local or otherwise non-public target that a server-side fetch
742
- * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
743
- * and re-checked against the IPv4 rules. An address we cannot parse is treated as
803
+ * loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
804
+ * unallocated or otherwise non-public target that a server-side fetch must never be
805
+ * steered to.
806
+ *
807
+ * The verdict is an allow-list: the address is refused unless the classification
808
+ * calls its range public unicast and — for IPv6, where that verdict is a default
809
+ * rather than a determination — it also sits inside the block IANA has allocated
810
+ * (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
811
+ * unwrapped and the embedded address judged in its place, so a public host stays
812
+ * reachable when it is written that way. An address we cannot parse is treated as
744
813
  * blocked (fail-closed).
814
+ *
815
+ * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
745
816
  */
746
817
  declare function isBlockedAddress(ip: string): boolean;
747
818
  /**
@@ -752,8 +823,10 @@ declare function isBlockedAddress(ip: string): boolean;
752
823
  * rejects if **any** resolved address is private/reserved. Throws
753
824
  * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
754
825
  *
755
- * Best-effort by design: Node re-resolves the hostname when it actually connects,
756
- * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
826
+ * This is the pre-flight half of the guard: the connection that follows resolves
827
+ * the name again on its own, so `safeFetch` also judges the address the socket
828
+ * actually reached (PO-184). A caller that opens its own request after
829
+ * `assertPublicUrl` gets the check and not that second half.
757
830
  *
758
831
  * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
759
832
  * hostile DNS resolve cannot block the guard past that budget — see
@@ -952,6 +1025,12 @@ declare abstract class BaseCredential implements ICredential {
952
1025
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
953
1026
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
954
1027
  * redirect; one that does was never going to complete the exchange anyway.
1028
+ *
1029
+ * The third protection is the caller's own: safeFetch hands back a `Response`
1030
+ * and reading it is not its job, so the answer goes through {@link readText}
1031
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
1032
+ * Both branches below read that same string, so the cap covers the refusal as
1033
+ * well as the token — an error body is the less predictable of the two.
955
1034
  */
956
1035
  protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
957
1036
  }
@@ -1028,4 +1107,4 @@ declare abstract class OAuth2AuthCodeCredential extends BaseCredential implement
1028
1107
  test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
1029
1108
  }
1030
1109
 
1031
- 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 INodeState, type INodeWithIteration, type IOutputField, type IOutputPort, type IShowIfCondition, 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, OPERATORS, type Operator, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, VALUELESS_OPERATORS, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, evaluate, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, isOperator, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, settingApplies, sleepWithSignal, takesValue, timeoutConfigField, withRetry };
1110
+ 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 INodeState, type INodeWithIteration, type IOutputField, type IOutputPort, type IShowIfCondition, 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 NodeErrorOutput, type NodeManifest, type NodePackageMeta, OAuth2AuthCodeCredential, OAuth2ClientCredentialsCredential, type OAuthTokenResponse, OPERATORS, type Operator, type OutputKind, type RetryHooks, type RetryPolicy, RetryableError, type SafeFetchOptions, type SafeFetchRetry, SimpleValueCredential, type TemplateLevel, type TemplateTriggerType, VALUELESS_OPERATORS, assertPublicUrl, backoffDelay, buildManifest, clampResponseBytes, errorPort, errorResult, evaluate, extractCredentialManifest, extractCredentialManifests, extractManifest, extractManifests, httpErrorResult, isBlockedAddress, isNodeWithIteration, isOAuthAuthorizeCredential, isOperator, maxBytesConfigField, normalizeCredentialType, normalizeLocalized, parsePackageMeta, readArrayBuffer, readJsonOrText, readText, retryConfigFields, safeFetch, settingApplies, sleepWithSignal, takesValue, timeoutConfigField, toErrorOutput, toErrorResult, withRetry };
package/dist/index.js CHANGED
@@ -131,8 +131,80 @@ var NodeError = class extends Error {
131
131
  }
132
132
  };
133
133
 
134
+ // src/nodeErrors.ts
135
+ function toErrorOutput(err) {
136
+ if (err instanceof NodeError) {
137
+ const raw = err.meta?.["status"];
138
+ const status = typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
139
+ return { code: err.code, message: err.message, status };
140
+ }
141
+ if (err instanceof Error && err.name === "AbortError") {
142
+ return { code: "TIMEOUT", message: "Request timed out", status: 0 };
143
+ }
144
+ if (err instanceof Error) {
145
+ return { code: "REQUEST_FAILED", message: err.message, status: 0 };
146
+ }
147
+ return { code: "REQUEST_FAILED", message: String(err), status: 0 };
148
+ }
149
+ function errorResult(output, port = "error") {
150
+ return { outputs: { [port]: output }, branch: port };
151
+ }
152
+ function toErrorResult(err, port = "error") {
153
+ return errorResult(toErrorOutput(err), port);
154
+ }
155
+ function httpErrorResult(err, port = "error") {
156
+ const body = err instanceof NodeError ? err.meta?.["body"] ?? null : null;
157
+ return errorResult({ ...toErrorOutput(err), body }, port);
158
+ }
159
+ function errorPort(opts = {}) {
160
+ return {
161
+ name: "error",
162
+ kind: "error",
163
+ dataType: "object",
164
+ label: { en: "Error", de: "Fehler" },
165
+ description: {
166
+ en: "Taken when the attempt fails in a way the workflow can carry on from. A node that cannot run as configured fails the run instead of taking this path.",
167
+ de: "Wird genommen, wenn der Versuch auf eine Weise fehlschl\xE4gt, mit der der Workflow weiterarbeiten kann. Ein Node, der so konfiguriert nicht laufen kann, l\xE4sst den Run scheitern, statt diesen Pfad zu nehmen."
168
+ },
169
+ fields: {
170
+ code: {
171
+ dataType: "string",
172
+ description: {
173
+ en: "Machine-readable name of the failure \u2014 branch on this rather than on the message. A node that talks to a remote system shares `TIMEOUT` and `REQUEST_FAILED` with every other one; the remaining codes belong to this node alone.",
174
+ de: "Maschinenlesbarer Name des Fehlers \u2014 hierauf verzweigen, nicht auf die Meldung. Ein Node, der mit einem Fremdsystem spricht, teilt `TIMEOUT` und `REQUEST_FAILED` mit allen anderen; die \xFCbrigen Codes geh\xF6ren allein zu diesem Node."
175
+ }
176
+ },
177
+ message: {
178
+ dataType: "string",
179
+ description: {
180
+ en: "Wording for whoever reads the run afterwards, frequently the wording the far end used. It is not a stable value to branch on.",
181
+ de: "Wortlaut f\xFCr den, der den Run sp\xE4ter liest, h\xE4ufig der des Gegen\xFCbers. Kein stabiler Wert, auf den sich Verzweigungen st\xFCtzen sollten."
182
+ }
183
+ },
184
+ status: {
185
+ dataType: "number",
186
+ description: {
187
+ en: "How the far end answered, as an HTTP status code, where one answered with a status at all. `0` otherwise \u2014 a timeout, a refused connection, an input the node could not use, a node that never leaves the workflow, or a failure the far end reported in its own answer rather than in a status, as a bulk job that ends as failed does. So `0` means there is no status to read, not that nothing was reached.",
188
+ de: "Wie das Gegen\xFCber geantwortet hat, als HTTP-Statuscode \u2014 sofern eines \xFCberhaupt mit einem Status geantwortet hat. Sonst `0`: Timeout, abgelehnte Verbindung, eine unbrauchbare Eingabe, ein Node, der den Workflow nicht verl\xE4sst, oder ein Fehler, den das Gegen\xFCber in seiner Antwort statt im Status gemeldet hat \u2014 wie ein Bulk-Job, der als fehlgeschlagen endet. `0` hei\xDFt also: kein Status zu lesen, nicht: nichts erreicht."
189
+ }
190
+ },
191
+ ...opts.body ? {
192
+ body: {
193
+ dataType: "any",
194
+ description: {
195
+ en: "The answer the far end gave, where the failure got far enough to have one: the response body, or the job the platform reported as failed.",
196
+ de: "Die Antwort des Gegen\xFCbers, sofern der Fehler weit genug kam, um eine zu haben: der Response-Body oder der von der Plattform als fehlgeschlagen gemeldete Job."
197
+ }
198
+ }
199
+ } : {}
200
+ }
201
+ };
202
+ }
203
+
134
204
  // src/ssrf.ts
205
+ import { subscribe } from "diagnostics_channel";
135
206
  import { isIP } from "net";
207
+ import ipaddr from "ipaddr.js";
136
208
  var ssrfResolver = {
137
209
  lookup: async (hostname) => {
138
210
  const { lookup } = await import("dns/promises");
@@ -140,66 +212,27 @@ var ssrfResolver = {
140
212
  return results.map((r) => ({ address: r.address, family: r.family }));
141
213
  }
142
214
  };
143
- function parseIpv4(input) {
144
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(input);
145
- if (!m) return null;
146
- const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
147
- if (octets.some((n) => n > 255)) return null;
148
- return octets;
149
- }
150
- function expandIpv6(input) {
151
- let s = input;
152
- const zone = s.indexOf("%");
153
- if (zone !== -1) s = s.slice(0, zone);
154
- if (s.includes(".")) {
155
- const idx = s.lastIndexOf(":");
156
- if (idx === -1) return null;
157
- const v4 = parseIpv4(s.slice(idx + 1));
158
- if (!v4) return null;
159
- const hi = (v4[0] << 8 | v4[1]).toString(16);
160
- const lo = (v4[2] << 8 | v4[3]).toString(16);
161
- s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
162
- }
163
- const halves = s.split("::");
164
- if (halves.length > 2) return null;
165
- const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
166
- const head = parseGroups(halves[0] ?? "");
167
- const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
168
- const declared = [...head, ...back ?? []];
169
- if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
170
- let hextets;
171
- if (back === null) {
172
- hextets = head;
173
- } else {
174
- const zeros = 8 - (head.length + back.length);
175
- if (zeros < 1) return null;
176
- hextets = [...head, ...new Array(zeros).fill(0), ...back];
177
- }
178
- return hextets.length === 8 ? hextets : null;
179
- }
180
- function isBlockedIpv4(o) {
181
- const [a, b] = o;
182
- return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
183
- a === 127 || // 127.0.0.0/8 loopback
184
- a === 10 || // 10.0.0.0/8 private
185
- a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
186
- a === 192 && b === 168 || // 192.168.0.0/16 private
187
- a === 169 && b === 254;
215
+ function isIPv6(addr) {
216
+ return addr.kind() === "ipv6";
217
+ }
218
+ var PUBLIC_RANGE = "unicast";
219
+ var GLOBAL_UNICAST_V6 = ipaddr.parseCIDR("2000::/3");
220
+ var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
221
+ function embeddedIpv4(addr) {
222
+ return ipaddr.fromByteArray(addr.toByteArray().slice(12));
188
223
  }
189
224
  function isBlockedAddress(ip) {
190
- const v4 = parseIpv4(ip);
191
- if (v4) return isBlockedIpv4(v4);
192
- const h = expandIpv6(ip);
193
- if (!h) return true;
194
- if (h.every((x) => x === 0)) return true;
195
- if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true;
196
- const embedsV4 = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && (h[5] === 65535 || h[5] === 0);
197
- if (embedsV4) {
198
- return isBlockedIpv4([h[6] >> 8, h[6] & 255, h[7] >> 8, h[7] & 255]);
199
- }
200
- if ((h[0] & 65024) === 64512) return true;
201
- if ((h[0] & 65472) === 65152) return true;
202
- return false;
225
+ let addr;
226
+ try {
227
+ addr = ipaddr.parse(ip);
228
+ } catch {
229
+ return true;
230
+ }
231
+ if (isIPv6(addr)) {
232
+ if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
233
+ if (!addr.match(GLOBAL_UNICAST_V6)) return true;
234
+ }
235
+ return addr.range() !== PUBLIC_RANGE;
203
236
  }
204
237
  var bypassNoticeLogged = false;
205
238
  function guardRelaxedForLocalDev() {
@@ -224,6 +257,82 @@ function blockedError(host, address) {
224
257
  status: 0
225
258
  });
226
259
  }
260
+ var guardedTargets = /* @__PURE__ */ new Map();
261
+ var connectGuardInstalled = false;
262
+ function normalizeHost(hostname) {
263
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
264
+ return host.toLowerCase();
265
+ }
266
+ function targetKey(hostname, port, protocol) {
267
+ const host = normalizeHost(hostname);
268
+ const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
269
+ return `${host}:${effective}`;
270
+ }
271
+ var unreadableMessageLogged = false;
272
+ function reportUnreadableMessage() {
273
+ if (unreadableMessageLogged) return;
274
+ unreadableMessageLogged = true;
275
+ console.warn(
276
+ "[ssrf] undici:client:connected published a message this guard cannot read: the connect-time half of the SSRF guard is not judging connections. The pre-flight check still applies."
277
+ );
278
+ }
279
+ function connectBlockedError(host, address) {
280
+ if (address == null) {
281
+ console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
282
+ return new NodeError(
283
+ "BLOCKED_ADDRESS",
284
+ `Blocked request to ${host}: the address the connection landed on could not be read`,
285
+ { status: 0 }
286
+ );
287
+ }
288
+ if (host === address) {
289
+ return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
290
+ status: 0
291
+ });
292
+ }
293
+ console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
294
+ return new NodeError(
295
+ "BLOCKED_ADDRESS",
296
+ `Blocked request to ${host}: the connection landed on a private or reserved address`,
297
+ { status: 0 }
298
+ );
299
+ }
300
+ function judgeConnection(message) {
301
+ const { connectParams, socket } = message ?? {};
302
+ const hostname = connectParams?.hostname;
303
+ const port = connectParams?.port;
304
+ const protocol = connectParams?.protocol;
305
+ if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
306
+ reportUnreadableMessage();
307
+ return;
308
+ }
309
+ if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
310
+ if (guardRelaxedForLocalDev()) return;
311
+ const peer = socket.remoteAddress;
312
+ if (peer != null && !isBlockedAddress(peer)) return;
313
+ socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
314
+ }
315
+ function guardConnectionsTo(url) {
316
+ if (!connectGuardInstalled) {
317
+ connectGuardInstalled = true;
318
+ subscribe("undici:client:connected", judgeConnection);
319
+ }
320
+ const key = targetKey(url.hostname, url.port, url.protocol);
321
+ guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
322
+ let released = false;
323
+ return () => {
324
+ if (released) return;
325
+ released = true;
326
+ const count = (guardedTargets.get(key) ?? 1) - 1;
327
+ if (count > 0) guardedTargets.set(key, count);
328
+ else guardedTargets.delete(key);
329
+ };
330
+ }
331
+ function connectionRefusal(err) {
332
+ const cause = err?.cause;
333
+ if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
334
+ return void 0;
335
+ }
227
336
  async function resolveHost(lookup, host, signal) {
228
337
  if (!signal) return lookup(host);
229
338
  if (signal.aborted) throw signal.reason;
@@ -290,6 +399,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
290
399
  try {
291
400
  return await fetch(url, { ...fetchOptions, signal });
292
401
  } catch (err) {
402
+ const blocked = connectionRefusal(err);
403
+ if (blocked) throw blocked;
293
404
  if (ctxSignal?.aborted) throw ctxSignal.reason;
294
405
  if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
295
406
  throw err;
@@ -316,7 +427,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
316
427
  let currentInit = { ...fetchOptions, redirect: "manual" };
317
428
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
318
429
  for (let hop = 0; ; hop++) {
319
- const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
430
+ const release = guardConnectionsTo(
431
+ currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
432
+ );
433
+ let res;
434
+ try {
435
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
436
+ } finally {
437
+ release();
438
+ }
320
439
  const location = res.headers.get("location");
321
440
  if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
322
441
  const redirectStatus = res.status;
@@ -400,6 +519,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
400
519
  const cap = clampResponseBytes(maxBytes);
401
520
  const declared = Number(res.headers.get("content-length"));
402
521
  if (Number.isFinite(declared) && declared > cap) {
522
+ await res.body?.cancel().catch(() => {
523
+ });
403
524
  throw tooLargeError(res.status, declared, cap);
404
525
  }
405
526
  const body = res.body;
@@ -581,6 +702,7 @@ function expiryFromExpiresIn(expiresIn) {
581
702
  }
582
703
  return new Date(Date.now() + seconds * 1e3).toISOString();
583
704
  }
705
+ var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
584
706
  var BaseCredential = class {
585
707
  async test(_ctx, config) {
586
708
  for (const field of this.description.fields) {
@@ -608,6 +730,12 @@ var BaseCredential = class {
608
730
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
609
731
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
610
732
  * redirect; one that does was never going to complete the exchange anyway.
733
+ *
734
+ * The third protection is the caller's own: safeFetch hands back a `Response`
735
+ * and reading it is not its job, so the answer goes through {@link readText}
736
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
737
+ * Both branches below read that same string, so the cap covers the refusal as
738
+ * well as the token — an error body is the less predictable of the two.
611
739
  */
612
740
  async postForm(ctx, url, form, headers = {}) {
613
741
  const body = new URLSearchParams();
@@ -622,7 +750,7 @@ var BaseCredential = class {
622
750
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
623
751
  body
624
752
  });
625
- const text = await res.text();
753
+ const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
626
754
  if (!res.ok) {
627
755
  throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
628
756
  }
@@ -808,11 +936,14 @@ export {
808
936
  backoffDelay,
809
937
  buildManifest,
810
938
  clampResponseBytes,
939
+ errorPort,
940
+ errorResult,
811
941
  evaluate,
812
942
  extractCredentialManifest,
813
943
  extractCredentialManifests,
814
944
  extractManifest,
815
945
  extractManifests,
946
+ httpErrorResult,
816
947
  isBlockedAddress,
817
948
  isNodeWithIteration,
818
949
  isOAuthAuthorizeCredential,
@@ -830,5 +961,7 @@ export {
830
961
  sleepWithSignal,
831
962
  takesValue,
832
963
  timeoutConfigField,
964
+ toErrorOutput,
965
+ toErrorResult,
833
966
  withRetry
834
967
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "TypeScript interfaces and utilities for Revenexx integration nodes",
5
5
  "license": "MIT",
6
6
  "author": "revenexx GmbH",
@@ -61,5 +61,8 @@
61
61
  "tsup": "^8.0.0",
62
62
  "tsx": "^4.0.0",
63
63
  "typescript": "^5.0.0"
64
+ },
65
+ "dependencies": {
66
+ "ipaddr.js": "2.5.0"
64
67
  }
65
68
  }