@revenexx/integrations-node-sdk 1.0.0 → 1.2.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
@@ -218,7 +218,9 @@ function extractCredentialManifests(credentials) {
218
218
  }
219
219
 
220
220
  // src/ssrf.ts
221
+ var import_node_diagnostics_channel = require("diagnostics_channel");
221
222
  var import_node_net = require("net");
223
+ var import_ipaddr = __toESM(require("ipaddr.js"), 1);
222
224
  var ssrfResolver = {
223
225
  lookup: async (hostname) => {
224
226
  const { lookup } = await import("dns/promises");
@@ -226,66 +228,27 @@ var ssrfResolver = {
226
228
  return results.map((r) => ({ address: r.address, family: r.family }));
227
229
  }
228
230
  };
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;
231
+ function isIPv6(addr) {
232
+ return addr.kind() === "ipv6";
233
+ }
234
+ var PUBLIC_RANGE = "unicast";
235
+ var GLOBAL_UNICAST_V6 = import_ipaddr.default.parseCIDR("2000::/3");
236
+ var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
237
+ function embeddedIpv4(addr) {
238
+ return import_ipaddr.default.fromByteArray(addr.toByteArray().slice(12));
274
239
  }
275
240
  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;
241
+ let addr;
242
+ try {
243
+ addr = import_ipaddr.default.parse(ip);
244
+ } catch {
245
+ return true;
246
+ }
247
+ if (isIPv6(addr)) {
248
+ if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
249
+ if (!addr.match(GLOBAL_UNICAST_V6)) return true;
250
+ }
251
+ return addr.range() !== PUBLIC_RANGE;
289
252
  }
290
253
  var bypassNoticeLogged = false;
291
254
  function guardRelaxedForLocalDev() {
@@ -310,6 +273,82 @@ function blockedError(host, address) {
310
273
  status: 0
311
274
  });
312
275
  }
276
+ var guardedTargets = /* @__PURE__ */ new Map();
277
+ var connectGuardInstalled = false;
278
+ function normalizeHost(hostname) {
279
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
280
+ return host.toLowerCase();
281
+ }
282
+ function targetKey(hostname, port, protocol) {
283
+ const host = normalizeHost(hostname);
284
+ const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
285
+ return `${host}:${effective}`;
286
+ }
287
+ var unreadableMessageLogged = false;
288
+ function reportUnreadableMessage() {
289
+ if (unreadableMessageLogged) return;
290
+ unreadableMessageLogged = true;
291
+ console.warn(
292
+ "[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."
293
+ );
294
+ }
295
+ function connectBlockedError(host, address) {
296
+ if (address == null) {
297
+ console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
298
+ return new NodeError(
299
+ "BLOCKED_ADDRESS",
300
+ `Blocked request to ${host}: the address the connection landed on could not be read`,
301
+ { status: 0 }
302
+ );
303
+ }
304
+ if (host === address) {
305
+ return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
306
+ status: 0
307
+ });
308
+ }
309
+ console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
310
+ return new NodeError(
311
+ "BLOCKED_ADDRESS",
312
+ `Blocked request to ${host}: the connection landed on a private or reserved address`,
313
+ { status: 0 }
314
+ );
315
+ }
316
+ function judgeConnection(message) {
317
+ const { connectParams, socket } = message ?? {};
318
+ const hostname = connectParams?.hostname;
319
+ const port = connectParams?.port;
320
+ const protocol = connectParams?.protocol;
321
+ if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
322
+ reportUnreadableMessage();
323
+ return;
324
+ }
325
+ if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
326
+ if (guardRelaxedForLocalDev()) return;
327
+ const peer = socket.remoteAddress;
328
+ if (peer != null && !isBlockedAddress(peer)) return;
329
+ socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
330
+ }
331
+ function guardConnectionsTo(url) {
332
+ if (!connectGuardInstalled) {
333
+ connectGuardInstalled = true;
334
+ (0, import_node_diagnostics_channel.subscribe)("undici:client:connected", judgeConnection);
335
+ }
336
+ const key = targetKey(url.hostname, url.port, url.protocol);
337
+ guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
338
+ let released = false;
339
+ return () => {
340
+ if (released) return;
341
+ released = true;
342
+ const count = (guardedTargets.get(key) ?? 1) - 1;
343
+ if (count > 0) guardedTargets.set(key, count);
344
+ else guardedTargets.delete(key);
345
+ };
346
+ }
347
+ function connectionRefusal(err) {
348
+ const cause = err?.cause;
349
+ if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
350
+ return void 0;
351
+ }
313
352
  async function resolveHost(lookup, host, signal) {
314
353
  if (!signal) return lookup(host);
315
354
  if (signal.aborted) throw signal.reason;
@@ -376,6 +415,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
376
415
  try {
377
416
  return await fetch(url, { ...fetchOptions, signal });
378
417
  } catch (err) {
418
+ const blocked = connectionRefusal(err);
419
+ if (blocked) throw blocked;
379
420
  if (ctxSignal?.aborted) throw ctxSignal.reason;
380
421
  if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
381
422
  throw err;
@@ -402,7 +443,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
402
443
  let currentInit = { ...fetchOptions, redirect: "manual" };
403
444
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
404
445
  for (let hop = 0; ; hop++) {
405
- const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
446
+ const release = guardConnectionsTo(
447
+ currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
448
+ );
449
+ let res;
450
+ try {
451
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
452
+ } finally {
453
+ release();
454
+ }
406
455
  const location = res.headers.get("location");
407
456
  if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
408
457
  const redirectStatus = res.status;
@@ -486,6 +535,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
486
535
  const cap = clampResponseBytes(maxBytes);
487
536
  const declared = Number(res.headers.get("content-length"));
488
537
  if (Number.isFinite(declared) && declared > cap) {
538
+ await res.body?.cancel().catch(() => {
539
+ });
489
540
  throw tooLargeError(res.status, declared, cap);
490
541
  }
491
542
  const body = res.body;
@@ -694,6 +745,7 @@ function expiryFromExpiresIn(expiresIn) {
694
745
  }
695
746
  return new Date(Date.now() + seconds * 1e3).toISOString();
696
747
  }
748
+ var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
697
749
  var BaseCredential = class {
698
750
  async test(_ctx, config) {
699
751
  for (const field of this.description.fields) {
@@ -721,6 +773,12 @@ var BaseCredential = class {
721
773
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
722
774
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
723
775
  * redirect; one that does was never going to complete the exchange anyway.
776
+ *
777
+ * The third protection is the caller's own: safeFetch hands back a `Response`
778
+ * and reading it is not its job, so the answer goes through {@link readText}
779
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
780
+ * Both branches below read that same string, so the cap covers the refusal as
781
+ * well as the token — an error body is the less predictable of the two.
724
782
  */
725
783
  async postForm(ctx, url, form, headers = {}) {
726
784
  const body = new URLSearchParams();
@@ -735,7 +793,7 @@ var BaseCredential = class {
735
793
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
736
794
  body
737
795
  });
738
- const text = await res.text();
796
+ const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
739
797
  if (!res.ok) {
740
798
  throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
741
799
  }
package/dist/index.d.cts CHANGED
@@ -178,8 +178,28 @@ interface IConfigFieldBase {
178
178
  stateRole?: 'mapping' | 'cursor' | 'dedupe' | 'digest';
179
179
  }
180
180
  interface IConfigField extends IConfigFieldBase {
181
- properties?: IConfigFieldBase[];
182
- items?: IConfigFieldBase;
181
+ /**
182
+ * The sub-settings of a field that holds a group of them (`type: 'object'`).
183
+ *
184
+ * A full `IConfigField`, not the base. `IConfigFieldBase` already carries
185
+ * `options`, `default`, `required`, `description` and `showIf`, so those were
186
+ * never what a nested setting was missing — `properties` and `items` were: the
187
+ * base has neither, so a sub-setting could not itself group or repeat without
188
+ * a cast. The manifest schema permits that nesting and the engine's config
189
+ * walker reads nested fields as first-class; the type was the only place
190
+ * stopping at one level (PO-436).
191
+ */
192
+ properties?: IConfigField[];
193
+ /**
194
+ * What one entry of a list field (`type: 'array'`) looks like.
195
+ *
196
+ * Full `IConfigField` for the same reason as `properties`, and this is the one
197
+ * that bit: `items.type: 'object'` plus `items.properties` is the repeating row
198
+ * every mapping-style setting is built from, and `items` typed as the base had
199
+ * no `properties` to give it. `SwitchNode` in integrations-nodes-core ends its
200
+ * items block with `as IConfigField` for exactly that reason.
201
+ */
202
+ items?: IConfigField;
183
203
  }
184
204
  interface INodeDescription {
185
205
  slug: string;
@@ -674,7 +694,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
674
694
  * The `Content-Length` header is used as a fast-reject (bail before downloading
675
695
  * anything), but the limit is *also* enforced while streaming, since the header
676
696
  * can be absent or lie. On overrun the stream is cancelled and a
677
- * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
697
+ * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
698
+ * cancel the body first, so neither leaves the connection held.
678
699
  */
679
700
  declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
680
701
  /** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
@@ -718,10 +739,19 @@ interface LookupAddress {
718
739
  type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
719
740
  /**
720
741
  * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
721
- * loopback, link-local or otherwise non-public target that a server-side fetch
722
- * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
723
- * and re-checked against the IPv4 rules. An address we cannot parse is treated as
742
+ * loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
743
+ * unallocated or otherwise non-public target that a server-side fetch must never be
744
+ * steered to.
745
+ *
746
+ * The verdict is an allow-list: the address is refused unless the classification
747
+ * calls its range public unicast and — for IPv6, where that verdict is a default
748
+ * rather than a determination — it also sits inside the block IANA has allocated
749
+ * (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
750
+ * unwrapped and the embedded address judged in its place, so a public host stays
751
+ * reachable when it is written that way. An address we cannot parse is treated as
724
752
  * blocked (fail-closed).
753
+ *
754
+ * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
725
755
  */
726
756
  declare function isBlockedAddress(ip: string): boolean;
727
757
  /**
@@ -732,8 +762,10 @@ declare function isBlockedAddress(ip: string): boolean;
732
762
  * rejects if **any** resolved address is private/reserved. Throws
733
763
  * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
734
764
  *
735
- * Best-effort by design: Node re-resolves the hostname when it actually connects,
736
- * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
765
+ * This is the pre-flight half of the guard: the connection that follows resolves
766
+ * the name again on its own, so `safeFetch` also judges the address the socket
767
+ * actually reached (PO-184). A caller that opens its own request after
768
+ * `assertPublicUrl` gets the check and not that second half.
737
769
  *
738
770
  * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
739
771
  * hostile DNS resolve cannot block the guard past that budget — see
@@ -932,6 +964,12 @@ declare abstract class BaseCredential implements ICredential {
932
964
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
933
965
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
934
966
  * redirect; one that does was never going to complete the exchange anyway.
967
+ *
968
+ * The third protection is the caller's own: safeFetch hands back a `Response`
969
+ * and reading it is not its job, so the answer goes through {@link readText}
970
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
971
+ * Both branches below read that same string, so the cap covers the refusal as
972
+ * well as the token — an error body is the less predictable of the two.
935
973
  */
936
974
  protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
937
975
  }
package/dist/index.d.ts CHANGED
@@ -178,8 +178,28 @@ interface IConfigFieldBase {
178
178
  stateRole?: 'mapping' | 'cursor' | 'dedupe' | 'digest';
179
179
  }
180
180
  interface IConfigField extends IConfigFieldBase {
181
- properties?: IConfigFieldBase[];
182
- items?: IConfigFieldBase;
181
+ /**
182
+ * The sub-settings of a field that holds a group of them (`type: 'object'`).
183
+ *
184
+ * A full `IConfigField`, not the base. `IConfigFieldBase` already carries
185
+ * `options`, `default`, `required`, `description` and `showIf`, so those were
186
+ * never what a nested setting was missing — `properties` and `items` were: the
187
+ * base has neither, so a sub-setting could not itself group or repeat without
188
+ * a cast. The manifest schema permits that nesting and the engine's config
189
+ * walker reads nested fields as first-class; the type was the only place
190
+ * stopping at one level (PO-436).
191
+ */
192
+ properties?: IConfigField[];
193
+ /**
194
+ * What one entry of a list field (`type: 'array'`) looks like.
195
+ *
196
+ * Full `IConfigField` for the same reason as `properties`, and this is the one
197
+ * that bit: `items.type: 'object'` plus `items.properties` is the repeating row
198
+ * every mapping-style setting is built from, and `items` typed as the base had
199
+ * no `properties` to give it. `SwitchNode` in integrations-nodes-core ends its
200
+ * items block with `as IConfigField` for exactly that reason.
201
+ */
202
+ items?: IConfigField;
183
203
  }
184
204
  interface INodeDescription {
185
205
  slug: string;
@@ -674,7 +694,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
674
694
  * The `Content-Length` header is used as a fast-reject (bail before downloading
675
695
  * anything), but the limit is *also* enforced while streaming, since the header
676
696
  * can be absent or lie. On overrun the stream is cancelled and a
677
- * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
697
+ * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
698
+ * cancel the body first, so neither leaves the connection held.
678
699
  */
679
700
  declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
680
701
  /** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
@@ -718,10 +739,19 @@ interface LookupAddress {
718
739
  type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
719
740
  /**
720
741
  * Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
721
- * loopback, link-local or otherwise non-public target that a server-side fetch
722
- * must never be steered to. IPv4-mapped/-compatible IPv6 addresses are unwrapped
723
- * and re-checked against the IPv4 rules. An address we cannot parse is treated as
742
+ * loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
743
+ * unallocated or otherwise non-public target that a server-side fetch must never be
744
+ * steered to.
745
+ *
746
+ * The verdict is an allow-list: the address is refused unless the classification
747
+ * calls its range public unicast and — for IPv6, where that verdict is a default
748
+ * rather than a determination — it also sits inside the block IANA has allocated
749
+ * (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
750
+ * unwrapped and the embedded address judged in its place, so a public host stays
751
+ * reachable when it is written that way. An address we cannot parse is treated as
724
752
  * blocked (fail-closed).
753
+ *
754
+ * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
725
755
  */
726
756
  declare function isBlockedAddress(ip: string): boolean;
727
757
  /**
@@ -732,8 +762,10 @@ declare function isBlockedAddress(ip: string): boolean;
732
762
  * rejects if **any** resolved address is private/reserved. Throws
733
763
  * `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
734
764
  *
735
- * Best-effort by design: Node re-resolves the hostname when it actually connects,
736
- * so a DNS-rebinding race (TOCTOU) remains. See the SDK README.
765
+ * This is the pre-flight half of the guard: the connection that follows resolves
766
+ * the name again on its own, so `safeFetch` also judges the address the socket
767
+ * actually reached (PO-184). A caller that opens its own request after
768
+ * `assertPublicUrl` gets the check and not that second half.
737
769
  *
738
770
  * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
739
771
  * hostile DNS resolve cannot block the guard past that budget — see
@@ -932,6 +964,12 @@ declare abstract class BaseCredential implements ICredential {
932
964
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
933
965
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
934
966
  * redirect; one that does was never going to complete the exchange anyway.
967
+ *
968
+ * The third protection is the caller's own: safeFetch hands back a `Response`
969
+ * and reading it is not its job, so the answer goes through {@link readText}
970
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
971
+ * Both branches below read that same string, so the cap covers the refusal as
972
+ * well as the token — an error body is the less predictable of the two.
935
973
  */
936
974
  protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
937
975
  }
package/dist/index.js CHANGED
@@ -132,7 +132,9 @@ var NodeError = class extends Error {
132
132
  };
133
133
 
134
134
  // src/ssrf.ts
135
+ import { subscribe } from "diagnostics_channel";
135
136
  import { isIP } from "net";
137
+ import ipaddr from "ipaddr.js";
136
138
  var ssrfResolver = {
137
139
  lookup: async (hostname) => {
138
140
  const { lookup } = await import("dns/promises");
@@ -140,66 +142,27 @@ var ssrfResolver = {
140
142
  return results.map((r) => ({ address: r.address, family: r.family }));
141
143
  }
142
144
  };
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;
145
+ function isIPv6(addr) {
146
+ return addr.kind() === "ipv6";
147
+ }
148
+ var PUBLIC_RANGE = "unicast";
149
+ var GLOBAL_UNICAST_V6 = ipaddr.parseCIDR("2000::/3");
150
+ var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
151
+ function embeddedIpv4(addr) {
152
+ return ipaddr.fromByteArray(addr.toByteArray().slice(12));
188
153
  }
189
154
  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;
155
+ let addr;
156
+ try {
157
+ addr = ipaddr.parse(ip);
158
+ } catch {
159
+ return true;
160
+ }
161
+ if (isIPv6(addr)) {
162
+ if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
163
+ if (!addr.match(GLOBAL_UNICAST_V6)) return true;
164
+ }
165
+ return addr.range() !== PUBLIC_RANGE;
203
166
  }
204
167
  var bypassNoticeLogged = false;
205
168
  function guardRelaxedForLocalDev() {
@@ -224,6 +187,82 @@ function blockedError(host, address) {
224
187
  status: 0
225
188
  });
226
189
  }
190
+ var guardedTargets = /* @__PURE__ */ new Map();
191
+ var connectGuardInstalled = false;
192
+ function normalizeHost(hostname) {
193
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
194
+ return host.toLowerCase();
195
+ }
196
+ function targetKey(hostname, port, protocol) {
197
+ const host = normalizeHost(hostname);
198
+ const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
199
+ return `${host}:${effective}`;
200
+ }
201
+ var unreadableMessageLogged = false;
202
+ function reportUnreadableMessage() {
203
+ if (unreadableMessageLogged) return;
204
+ unreadableMessageLogged = true;
205
+ console.warn(
206
+ "[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."
207
+ );
208
+ }
209
+ function connectBlockedError(host, address) {
210
+ if (address == null) {
211
+ console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
212
+ return new NodeError(
213
+ "BLOCKED_ADDRESS",
214
+ `Blocked request to ${host}: the address the connection landed on could not be read`,
215
+ { status: 0 }
216
+ );
217
+ }
218
+ if (host === address) {
219
+ return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
220
+ status: 0
221
+ });
222
+ }
223
+ console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
224
+ return new NodeError(
225
+ "BLOCKED_ADDRESS",
226
+ `Blocked request to ${host}: the connection landed on a private or reserved address`,
227
+ { status: 0 }
228
+ );
229
+ }
230
+ function judgeConnection(message) {
231
+ const { connectParams, socket } = message ?? {};
232
+ const hostname = connectParams?.hostname;
233
+ const port = connectParams?.port;
234
+ const protocol = connectParams?.protocol;
235
+ if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
236
+ reportUnreadableMessage();
237
+ return;
238
+ }
239
+ if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
240
+ if (guardRelaxedForLocalDev()) return;
241
+ const peer = socket.remoteAddress;
242
+ if (peer != null && !isBlockedAddress(peer)) return;
243
+ socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
244
+ }
245
+ function guardConnectionsTo(url) {
246
+ if (!connectGuardInstalled) {
247
+ connectGuardInstalled = true;
248
+ subscribe("undici:client:connected", judgeConnection);
249
+ }
250
+ const key = targetKey(url.hostname, url.port, url.protocol);
251
+ guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
252
+ let released = false;
253
+ return () => {
254
+ if (released) return;
255
+ released = true;
256
+ const count = (guardedTargets.get(key) ?? 1) - 1;
257
+ if (count > 0) guardedTargets.set(key, count);
258
+ else guardedTargets.delete(key);
259
+ };
260
+ }
261
+ function connectionRefusal(err) {
262
+ const cause = err?.cause;
263
+ if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
264
+ return void 0;
265
+ }
227
266
  async function resolveHost(lookup, host, signal) {
228
267
  if (!signal) return lookup(host);
229
268
  if (signal.aborted) throw signal.reason;
@@ -290,6 +329,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
290
329
  try {
291
330
  return await fetch(url, { ...fetchOptions, signal });
292
331
  } catch (err) {
332
+ const blocked = connectionRefusal(err);
333
+ if (blocked) throw blocked;
293
334
  if (ctxSignal?.aborted) throw ctxSignal.reason;
294
335
  if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
295
336
  throw err;
@@ -316,7 +357,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
316
357
  let currentInit = { ...fetchOptions, redirect: "manual" };
317
358
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
318
359
  for (let hop = 0; ; hop++) {
319
- const res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
360
+ const release = guardConnectionsTo(
361
+ currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
362
+ );
363
+ let res;
364
+ try {
365
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
366
+ } finally {
367
+ release();
368
+ }
320
369
  const location = res.headers.get("location");
321
370
  if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
322
371
  const redirectStatus = res.status;
@@ -400,6 +449,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
400
449
  const cap = clampResponseBytes(maxBytes);
401
450
  const declared = Number(res.headers.get("content-length"));
402
451
  if (Number.isFinite(declared) && declared > cap) {
452
+ await res.body?.cancel().catch(() => {
453
+ });
403
454
  throw tooLargeError(res.status, declared, cap);
404
455
  }
405
456
  const body = res.body;
@@ -581,6 +632,7 @@ function expiryFromExpiresIn(expiresIn) {
581
632
  }
582
633
  return new Date(Date.now() + seconds * 1e3).toISOString();
583
634
  }
635
+ var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
584
636
  var BaseCredential = class {
585
637
  async test(_ctx, config) {
586
638
  for (const field of this.description.fields) {
@@ -608,6 +660,12 @@ var BaseCredential = class {
608
660
  * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
609
661
  * per the redirect rules, not a quirk of ours. Real token endpoints do not
610
662
  * redirect; one that does was never going to complete the exchange anyway.
663
+ *
664
+ * The third protection is the caller's own: safeFetch hands back a `Response`
665
+ * and reading it is not its job, so the answer goes through {@link readText}
666
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
667
+ * Both branches below read that same string, so the cap covers the refusal as
668
+ * well as the token — an error body is the less predictable of the two.
611
669
  */
612
670
  async postForm(ctx, url, form, headers = {}) {
613
671
  const body = new URLSearchParams();
@@ -622,7 +680,7 @@ var BaseCredential = class {
622
680
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
623
681
  body
624
682
  });
625
- const text = await res.text();
683
+ const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
626
684
  if (!res.ok) {
627
685
  throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
628
686
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.2.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
  }