@revenexx/integrations-node-sdk 1.11.0 → 1.13.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The per-run allow-list of the SSRF guard (PO-828): the exact `ip:port` pairs a
3
+ * run may reach although the address is private — its own tenant's VPN routes,
4
+ * served by the relay at a private overlay address (an RFC 1918 or IPv6
5
+ * unique-local one — no other address may be listed).
6
+ *
7
+ * **The global symbol is the contract, not this module.** Every node bundle carries
8
+ * its own copy of this package, so a module-level setter would reach only the copy
9
+ * that happened to be imported by the host. The trusted host instead defines one
10
+ * property on `globalThis`, before any node bundle is imported, and every copy reads
11
+ * that same property:
12
+ *
13
+ * ```ts
14
+ * Object.defineProperty(globalThis, Symbol.for('revenexx.ssrf.allowedTargets'), {
15
+ * value: Object.freeze([...targets]),
16
+ * writable: false, configurable: false, enumerable: false,
17
+ * });
18
+ * ```
19
+ *
20
+ * The host may do that itself (the runtimes do, without depending on this package)
21
+ * or call {@link installAllowedTargets}, which does exactly that.
22
+ *
23
+ * A property that is not shaped that way is read as no list at all. Non-writable and
24
+ * non-configurable is what makes the list impossible to widen after the host set it;
25
+ * a property that lacks either was not set by a host following the contract, and a
26
+ * guard that honoured it would let whoever set it decide what is private.
27
+ */
28
+ export declare const ALLOWED_TARGETS_SYMBOL: unique symbol;
29
+ /** Test seam: how many times a list has been validated. Not re-exported from the package. */
30
+ export declare const allowListStats: {
31
+ validations: number;
32
+ };
33
+ /**
34
+ * Read the list off `holder` — `globalThis` in production, a fresh object in the
35
+ * tests that need a shape the one-shot global cannot be given twice. Anything but a
36
+ * frozen array of valid entries on a non-writable, non-configurable data property
37
+ * reads as the empty list: fail closed, never partly.
38
+ */
39
+ export declare function readAllowedTargets(holder: object): readonly string[];
40
+ /** {@link isAllowedTarget} against the list on `holder`. */
41
+ export declare function isAllowedTargetOn(holder: object, ip: string, port: number): boolean;
42
+ /** {@link installAllowedTargets} onto `holder`. */
43
+ export declare function installAllowedTargetsOn(holder: object, targets: readonly string[]): void;
44
+ /**
45
+ * Install the per-run allow-list on `globalThis`. For the trusted host, before any
46
+ * node bundle is imported; one-shot — a second call throws, as does an entry that is
47
+ * not `"<ip>:<port>"` (IPv4 dotted) or `"[v6]:<port>"`, or whose address is not an
48
+ * RFC 1918 or IPv6 unique-local one.
49
+ *
50
+ * Install it even when the list is empty. A process that never installs one leaves
51
+ * the property free for the first code that runs to define it — node code included.
52
+ *
53
+ * Promised behaviour: specs/ssrf-guard.md (AC-26 to AC-31).
54
+ */
55
+ export declare function installAllowedTargets(targets: readonly string[]): void;
56
+ /**
57
+ * The per-run allow-list as the host installed it, or an empty list when none is
58
+ * installed or the property is malformed (see {@link ALLOWED_TARGETS_SYMBOL}).
59
+ */
60
+ export declare function allowedTargets(): readonly string[];
61
+ /**
62
+ * Whether the run may reach `ip` on `port` although the address is private: `true`
63
+ * only when exactly that pair is on the allow-list. The port is part of the target —
64
+ * `10.31.0.7:8080` on the list does not allow `10.31.0.7:22`. For a caller that opens
65
+ * its own socket; `safeFetch` and `assertPublicUrl` already consult it.
66
+ */
67
+ export declare function isAllowedTarget(ip: string, port: number): boolean;
package/dist/errors.d.ts CHANGED
@@ -3,3 +3,24 @@ export declare class NodeError extends Error {
3
3
  readonly meta?: Record<string, unknown>;
4
4
  constructor(code: string, message: string, meta?: Record<string, unknown>);
5
5
  }
6
+ /**
7
+ * The two codes a step fails with when the VPN route it was configured with
8
+ * cannot be used (PO-825). Both are failures of the world around the run, not of
9
+ * the data a record carried, and neither is retried by the platform.
10
+ *
11
+ * - `VPN_UNAVAILABLE` — the connection exists but is not `active`;
12
+ * message `VPN connection <name> is <status>`.
13
+ * - `VPN_ROUTE_MISSING` — the connection or the route no longer exists;
14
+ * message `VPN route <connection>/<route> no longer exists`.
15
+ */
16
+ export declare const VPN_UNAVAILABLE: 'VPN_UNAVAILABLE';
17
+ export declare const VPN_ROUTE_MISSING: 'VPN_ROUTE_MISSING';
18
+ /** Every VPN failure code, for a reader that has to recognise one. */
19
+ export declare const VPN_ERROR_CODES: readonly ["VPN_UNAVAILABLE", "VPN_ROUTE_MISSING"];
20
+ /** One of {@link VPN_ERROR_CODES}. */
21
+ export type VpnErrorCode = (typeof VPN_ERROR_CODES)[number];
22
+ /**
23
+ * Whether `code` is a VPN failure code, narrowing it. `VPN_ERROR_CODES.includes`
24
+ * does not accept a plain `string`, which is what `NodeError.code` is.
25
+ */
26
+ export declare function isVpnErrorCode(code: string): code is VpnErrorCode;
package/dist/index.cjs CHANGED
@@ -50,6 +50,10 @@ __export(index_exports, {
50
50
  RetryableError: () => RetryableError,
51
51
  SimpleValueCredential: () => SimpleValueCredential,
52
52
  VALUELESS_OPERATORS: () => VALUELESS_OPERATORS,
53
+ VPN_ERROR_CODES: () => VPN_ERROR_CODES,
54
+ VPN_ROUTE_MISSING: () => VPN_ROUTE_MISSING,
55
+ VPN_UNAVAILABLE: () => VPN_UNAVAILABLE,
56
+ allowedTargets: () => allowedTargets,
53
57
  assertBranchNamed: () => assertBranchNamed,
54
58
  assertPublicUrl: () => assertPublicUrl,
55
59
  backoffDelay: () => backoffDelay,
@@ -64,11 +68,14 @@ __export(index_exports, {
64
68
  extractManifests: () => extractManifests,
65
69
  headerCrossesOrigin: () => headerCrossesOrigin,
66
70
  httpErrorResult: () => httpErrorResult,
71
+ installAllowedTargets: () => installAllowedTargets,
72
+ isAllowedTarget: () => isAllowedTarget,
67
73
  isBlockedAddress: () => isBlockedAddress,
68
74
  isMultiPort: () => isMultiPort,
69
75
  isNodeWithIteration: () => isNodeWithIteration,
70
76
  isOAuthAuthorizeCredential: () => isOAuthAuthorizeCredential,
71
77
  isOperator: () => isOperator,
78
+ isVpnErrorCode: () => isVpnErrorCode,
72
79
  maxBytesConfigField: () => maxBytesConfigField,
73
80
  normalizeCredentialType: () => normalizeCredentialType,
74
81
  normalizeLocalized: () => normalizeLocalized,
@@ -213,6 +220,12 @@ var NodeError = class extends Error {
213
220
  this.meta = meta;
214
221
  }
215
222
  };
223
+ var VPN_UNAVAILABLE = "VPN_UNAVAILABLE";
224
+ var VPN_ROUTE_MISSING = "VPN_ROUTE_MISSING";
225
+ var VPN_ERROR_CODES = Object.freeze([VPN_UNAVAILABLE, VPN_ROUTE_MISSING]);
226
+ function isVpnErrorCode(code) {
227
+ return VPN_ERROR_CODES.includes(code);
228
+ }
216
229
 
217
230
  // src/nodeErrors.ts
218
231
  function toErrorOutput(err) {
@@ -359,8 +372,123 @@ function assertBranchNamed(node, result, config) {
359
372
 
360
373
  // src/ssrf.ts
361
374
  var import_node_diagnostics_channel = require("diagnostics_channel");
375
+ var import_node_dns = __toESM(require("dns"), 1);
376
+ var import_node_net2 = require("net");
377
+ var import_ipaddr2 = __toESM(require("ipaddr.js"), 1);
378
+ var import_undici = require("undici");
379
+
380
+ // src/allowedTargets.ts
362
381
  var import_node_net = require("net");
363
382
  var import_ipaddr = __toESM(require("ipaddr.js"), 1);
383
+ var ALLOWED_TARGETS_SYMBOL = /* @__PURE__ */ Symbol.for("revenexx.ssrf.allowedTargets");
384
+ var EMPTY = Object.freeze([]);
385
+ var V4_ENTRY = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/;
386
+ var V6_ENTRY = /^\[([0-9A-Fa-f:.]+)\]:(\d{1,5})$/;
387
+ function canonicalAddress(ip) {
388
+ const bare = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
389
+ if ((0, import_node_net.isIP)(bare) === 0) return void 0;
390
+ try {
391
+ return import_ipaddr.default.process(bare).toString();
392
+ } catch {
393
+ return void 0;
394
+ }
395
+ }
396
+ function validPort(port) {
397
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
398
+ }
399
+ var TARGET_RANGES = /* @__PURE__ */ new Set(["private", "uniqueLocal"]);
400
+ function loopbackTargetsAllowed() {
401
+ const raw = process.env["RVNXX_SSRF_TARGETS_ALLOW_LOOPBACK"];
402
+ return raw != null && ["1", "true", "yes"].includes(raw.trim().toLowerCase());
403
+ }
404
+ function parseTarget(entry, allowLoopback) {
405
+ if (typeof entry !== "string") return void 0;
406
+ const v4 = V4_ENTRY.exec(entry);
407
+ const m = v4 ?? V6_ENTRY.exec(entry);
408
+ if (!m) return void 0;
409
+ const host = m[1];
410
+ const port = Number(m[2]);
411
+ if (v4 && !import_ipaddr.default.IPv4.isValidFourPartDecimal(host)) return void 0;
412
+ if (!validPort(port)) return void 0;
413
+ const address = canonicalAddress(host);
414
+ if (address === void 0) return void 0;
415
+ const range = import_ipaddr.default.parse(address).range();
416
+ if (!TARGET_RANGES.has(range) && !(allowLoopback && range === "loopback")) return void 0;
417
+ return `${address}:${port}`;
418
+ }
419
+ var allowListStats = { validations: 0 };
420
+ var keysByList = /* @__PURE__ */ new WeakMap();
421
+ function keysOf(list) {
422
+ const allowLoopback = loopbackTargetsAllowed();
423
+ let byMode = keysByList.get(list);
424
+ if (byMode === void 0) {
425
+ byMode = /* @__PURE__ */ new Map();
426
+ keysByList.set(list, byMode);
427
+ }
428
+ const cached = byMode.get(allowLoopback);
429
+ if (cached !== void 0) return cached;
430
+ allowListStats.validations++;
431
+ const keys = /* @__PURE__ */ new Set();
432
+ let result = keys;
433
+ for (const entry of list) {
434
+ const key = parseTarget(entry, allowLoopback);
435
+ if (key === void 0) {
436
+ result = null;
437
+ break;
438
+ }
439
+ keys.add(key);
440
+ }
441
+ byMode.set(allowLoopback, result);
442
+ return result;
443
+ }
444
+ function installedList(holder) {
445
+ const descriptor = Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL);
446
+ if (descriptor === void 0 || !("value" in descriptor)) return null;
447
+ if (descriptor.writable || descriptor.configurable) return null;
448
+ const value = descriptor.value;
449
+ if (!Array.isArray(value) || !Object.isFrozen(value)) return null;
450
+ const keys = keysOf(value);
451
+ return keys === null ? null : { list: value, keys };
452
+ }
453
+ function readAllowedTargets(holder) {
454
+ return installedList(holder)?.list ?? EMPTY;
455
+ }
456
+ function isAllowedTargetOn(holder, ip, port) {
457
+ const installed = installedList(holder);
458
+ if (installed === null || !validPort(port)) return false;
459
+ const address = canonicalAddress(ip);
460
+ return address !== void 0 && installed.keys.has(`${address}:${port}`);
461
+ }
462
+ function installAllowedTargetsOn(holder, targets) {
463
+ if (Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL) !== void 0) {
464
+ throw new Error("The SSRF allow-list is already installed, and is installed once per process");
465
+ }
466
+ if (!Array.isArray(targets)) throw new TypeError('The SSRF allow-list must be an array of "<ip>:<port>" strings');
467
+ for (const entry of targets) {
468
+ if (parseTarget(entry, loopbackTargetsAllowed()) === void 0) {
469
+ throw new TypeError(
470
+ `Not an allowed target: ${JSON.stringify(entry)} (expected "<ip>:<port>" or "[v6]:<port>" at an RFC 1918 or unique-local address)`
471
+ );
472
+ }
473
+ }
474
+ Object.defineProperty(holder, ALLOWED_TARGETS_SYMBOL, {
475
+ value: Object.freeze([...targets]),
476
+ writable: false,
477
+ configurable: false,
478
+ enumerable: false
479
+ });
480
+ }
481
+ function installAllowedTargets(targets) {
482
+ installAllowedTargetsOn(globalThis, targets);
483
+ }
484
+ function allowedTargets() {
485
+ return readAllowedTargets(globalThis);
486
+ }
487
+ function isAllowedTarget(ip, port) {
488
+ return isAllowedTargetOn(globalThis, ip, port);
489
+ }
490
+
491
+ // src/ssrf.ts
364
492
  var ssrfResolver = {
365
493
  lookup: async (hostname) => {
366
494
  const { lookup } = await import("dns/promises");
@@ -372,15 +500,15 @@ function isIPv6(addr) {
372
500
  return addr.kind() === "ipv6";
373
501
  }
374
502
  var PUBLIC_RANGE = "unicast";
375
- var GLOBAL_UNICAST_V6 = import_ipaddr.default.parseCIDR("2000::/3");
503
+ var GLOBAL_UNICAST_V6 = import_ipaddr2.default.parseCIDR("2000::/3");
376
504
  var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
377
505
  function embeddedIpv4(addr) {
378
- return import_ipaddr.default.fromByteArray(addr.toByteArray().slice(12));
506
+ return import_ipaddr2.default.fromByteArray(addr.toByteArray().slice(12));
379
507
  }
380
508
  function isBlockedAddress(ip) {
381
509
  let addr;
382
510
  try {
383
- addr = import_ipaddr.default.parse(ip);
511
+ addr = import_ipaddr2.default.parse(ip);
384
512
  } catch {
385
513
  return true;
386
514
  }
@@ -466,8 +594,73 @@ function judgeConnection(message) {
466
594
  if (guardRelaxedForLocalDev()) return;
467
595
  const peer = socket.remoteAddress;
468
596
  if (peer != null && !isBlockedAddress(peer)) return;
597
+ const remotePort = socket.remotePort;
598
+ if (peer != null && allowedTargets().length > 0 && !Number.isInteger(remotePort)) {
599
+ reportUnreadablePeerPort();
600
+ socket.destroy(unreadablePortError(normalizeHost(hostname)));
601
+ return;
602
+ }
603
+ if (peer != null && isAllowedTarget(peer, remotePort)) return;
469
604
  socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
470
605
  }
606
+ var unreadablePeerPortLogged = false;
607
+ function reportUnreadablePeerPort() {
608
+ if (unreadablePeerPortLogged) return;
609
+ unreadablePeerPortLogged = true;
610
+ console.warn(
611
+ "[ssrf] a connection reported no remote port: the SSRF allow-list cannot be consulted, so private peers are refused."
612
+ );
613
+ }
614
+ function unreadablePortError(host) {
615
+ return new NodeError(
616
+ "BLOCKED_ADDRESS",
617
+ `Blocked request to ${host}: the port the connection landed on could not be read, so the allow-list could not be consulted`,
618
+ { status: 0 }
619
+ );
620
+ }
621
+ function judgingLookup(port) {
622
+ return (hostname, options, callback) => {
623
+ import_node_dns.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
624
+ if (err) {
625
+ callback(err, "");
626
+ return;
627
+ }
628
+ if (addresses.length === 0) {
629
+ callback(new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }), "");
630
+ return;
631
+ }
632
+ if (!guardRelaxedForLocalDev()) {
633
+ for (const a of addresses) {
634
+ if (isBlockedAddress(a.address) && !isAllowedTarget(a.address, port)) {
635
+ callback(blockedError(normalizeHost(hostname), a.address), "");
636
+ return;
637
+ }
638
+ }
639
+ }
640
+ if (options.all) callback(null, addresses);
641
+ else callback(null, addresses[0].address, addresses[0].family);
642
+ });
643
+ };
644
+ }
645
+ var connectors = /* @__PURE__ */ new Map();
646
+ function connectorFor(port) {
647
+ let connector = connectors.get(port);
648
+ if (connector === void 0) {
649
+ connector = (0, import_undici.buildConnector)({ lookup: judgingLookup(port) });
650
+ connectors.set(port, connector);
651
+ }
652
+ return connector;
653
+ }
654
+ var dispatcher;
655
+ function guardedDispatcher() {
656
+ dispatcher ??= new import_undici.Agent({
657
+ connect: (opts, callback) => {
658
+ const port = opts.port === "" ? opts.protocol === "https:" ? 443 : 80 : Number(opts.port);
659
+ return connectorFor(port)(opts, callback);
660
+ }
661
+ });
662
+ return dispatcher;
663
+ }
471
664
  function guardConnectionsTo(url) {
472
665
  if (!connectGuardInstalled) {
473
666
  connectGuardInstalled = true;
@@ -520,8 +713,10 @@ async function assertPublicUrl(url, opts = {}) {
520
713
  if (lower === "localhost" || lower.endsWith(".localhost")) {
521
714
  throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
522
715
  }
523
- if ((0, import_node_net.isIP)(host) !== 0) {
524
- if (isBlockedAddress(host)) throw blockedError(host, host);
716
+ const port = u.port === "" ? u.protocol === "https:" ? 443 : 80 : Number(u.port);
717
+ const refused = (address) => isBlockedAddress(address) && !isAllowedTarget(address, port);
718
+ if ((0, import_node_net2.isIP)(host) !== 0) {
719
+ if (refused(host)) throw blockedError(host, host);
525
720
  return;
526
721
  }
527
722
  const lookup = opts.lookup ?? ssrfResolver.lookup;
@@ -530,7 +725,7 @@ async function assertPublicUrl(url, opts = {}) {
530
725
  throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
531
726
  }
532
727
  for (const a of addresses) {
533
- if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
728
+ if (refused(a.address)) throw blockedError(host, a.address);
534
729
  }
535
730
  }
536
731
 
@@ -630,7 +825,7 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
630
825
  const timer = setTimeout(() => ac.abort(), effectiveMs);
631
826
  const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
632
827
  try {
633
- return await fetch(url, { ...fetchOptions, signal });
828
+ return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
634
829
  } catch (err) {
635
830
  const blocked = connectionRefusal(err);
636
831
  if (blocked) throw blocked;
@@ -1298,6 +1493,10 @@ function base64Url(buf) {
1298
1493
  RetryableError,
1299
1494
  SimpleValueCredential,
1300
1495
  VALUELESS_OPERATORS,
1496
+ VPN_ERROR_CODES,
1497
+ VPN_ROUTE_MISSING,
1498
+ VPN_UNAVAILABLE,
1499
+ allowedTargets,
1301
1500
  assertBranchNamed,
1302
1501
  assertPublicUrl,
1303
1502
  backoffDelay,
@@ -1312,11 +1511,14 @@ function base64Url(buf) {
1312
1511
  extractManifests,
1313
1512
  headerCrossesOrigin,
1314
1513
  httpErrorResult,
1514
+ installAllowedTargets,
1515
+ isAllowedTarget,
1315
1516
  isBlockedAddress,
1316
1517
  isMultiPort,
1317
1518
  isNodeWithIteration,
1318
1519
  isOAuthAuthorizeCredential,
1319
1520
  isOperator,
1521
+ isVpnErrorCode,
1320
1522
  maxBytesConfigField,
1321
1523
  normalizeCredentialType,
1322
1524
  normalizeLocalized,
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from './branches.js';
10
10
  export * from './fetch.js';
11
11
  export { assertPublicUrl, isBlockedAddress } from './ssrf.js';
12
12
  export type { LookupAddress, LookupFn } from './ssrf.js';
13
+ export { allowedTargets, installAllowedTargets, isAllowedTarget } from './allowedTargets.js';
13
14
  export * from './retry.js';
14
15
  export * from './manifest.js';
15
16
  export * from './credentials.js';
package/dist/index.js CHANGED
@@ -135,6 +135,12 @@ var NodeError = class extends Error {
135
135
  this.meta = meta;
136
136
  }
137
137
  };
138
+ var VPN_UNAVAILABLE = "VPN_UNAVAILABLE";
139
+ var VPN_ROUTE_MISSING = "VPN_ROUTE_MISSING";
140
+ var VPN_ERROR_CODES = Object.freeze([VPN_UNAVAILABLE, VPN_ROUTE_MISSING]);
141
+ function isVpnErrorCode(code) {
142
+ return VPN_ERROR_CODES.includes(code);
143
+ }
138
144
 
139
145
  // src/nodeErrors.ts
140
146
  function toErrorOutput(err) {
@@ -208,8 +214,123 @@ function errorPort(opts = {}) {
208
214
 
209
215
  // src/ssrf.ts
210
216
  import { subscribe } from "diagnostics_channel";
217
+ import dns from "dns";
218
+ import { isIP as isIP2 } from "net";
219
+ import ipaddr2 from "ipaddr.js";
220
+ import { Agent, buildConnector } from "undici";
221
+
222
+ // src/allowedTargets.ts
211
223
  import { isIP } from "net";
212
224
  import ipaddr from "ipaddr.js";
225
+ var ALLOWED_TARGETS_SYMBOL = /* @__PURE__ */ Symbol.for("revenexx.ssrf.allowedTargets");
226
+ var EMPTY = Object.freeze([]);
227
+ var V4_ENTRY = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/;
228
+ var V6_ENTRY = /^\[([0-9A-Fa-f:.]+)\]:(\d{1,5})$/;
229
+ function canonicalAddress(ip) {
230
+ const bare = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
231
+ if (isIP(bare) === 0) return void 0;
232
+ try {
233
+ return ipaddr.process(bare).toString();
234
+ } catch {
235
+ return void 0;
236
+ }
237
+ }
238
+ function validPort(port) {
239
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
240
+ }
241
+ var TARGET_RANGES = /* @__PURE__ */ new Set(["private", "uniqueLocal"]);
242
+ function loopbackTargetsAllowed() {
243
+ const raw = process.env["RVNXX_SSRF_TARGETS_ALLOW_LOOPBACK"];
244
+ return raw != null && ["1", "true", "yes"].includes(raw.trim().toLowerCase());
245
+ }
246
+ function parseTarget(entry, allowLoopback) {
247
+ if (typeof entry !== "string") return void 0;
248
+ const v4 = V4_ENTRY.exec(entry);
249
+ const m = v4 ?? V6_ENTRY.exec(entry);
250
+ if (!m) return void 0;
251
+ const host = m[1];
252
+ const port = Number(m[2]);
253
+ if (v4 && !ipaddr.IPv4.isValidFourPartDecimal(host)) return void 0;
254
+ if (!validPort(port)) return void 0;
255
+ const address = canonicalAddress(host);
256
+ if (address === void 0) return void 0;
257
+ const range = ipaddr.parse(address).range();
258
+ if (!TARGET_RANGES.has(range) && !(allowLoopback && range === "loopback")) return void 0;
259
+ return `${address}:${port}`;
260
+ }
261
+ var allowListStats = { validations: 0 };
262
+ var keysByList = /* @__PURE__ */ new WeakMap();
263
+ function keysOf(list) {
264
+ const allowLoopback = loopbackTargetsAllowed();
265
+ let byMode = keysByList.get(list);
266
+ if (byMode === void 0) {
267
+ byMode = /* @__PURE__ */ new Map();
268
+ keysByList.set(list, byMode);
269
+ }
270
+ const cached = byMode.get(allowLoopback);
271
+ if (cached !== void 0) return cached;
272
+ allowListStats.validations++;
273
+ const keys = /* @__PURE__ */ new Set();
274
+ let result = keys;
275
+ for (const entry of list) {
276
+ const key = parseTarget(entry, allowLoopback);
277
+ if (key === void 0) {
278
+ result = null;
279
+ break;
280
+ }
281
+ keys.add(key);
282
+ }
283
+ byMode.set(allowLoopback, result);
284
+ return result;
285
+ }
286
+ function installedList(holder) {
287
+ const descriptor = Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL);
288
+ if (descriptor === void 0 || !("value" in descriptor)) return null;
289
+ if (descriptor.writable || descriptor.configurable) return null;
290
+ const value = descriptor.value;
291
+ if (!Array.isArray(value) || !Object.isFrozen(value)) return null;
292
+ const keys = keysOf(value);
293
+ return keys === null ? null : { list: value, keys };
294
+ }
295
+ function readAllowedTargets(holder) {
296
+ return installedList(holder)?.list ?? EMPTY;
297
+ }
298
+ function isAllowedTargetOn(holder, ip, port) {
299
+ const installed = installedList(holder);
300
+ if (installed === null || !validPort(port)) return false;
301
+ const address = canonicalAddress(ip);
302
+ return address !== void 0 && installed.keys.has(`${address}:${port}`);
303
+ }
304
+ function installAllowedTargetsOn(holder, targets) {
305
+ if (Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL) !== void 0) {
306
+ throw new Error("The SSRF allow-list is already installed, and is installed once per process");
307
+ }
308
+ if (!Array.isArray(targets)) throw new TypeError('The SSRF allow-list must be an array of "<ip>:<port>" strings');
309
+ for (const entry of targets) {
310
+ if (parseTarget(entry, loopbackTargetsAllowed()) === void 0) {
311
+ throw new TypeError(
312
+ `Not an allowed target: ${JSON.stringify(entry)} (expected "<ip>:<port>" or "[v6]:<port>" at an RFC 1918 or unique-local address)`
313
+ );
314
+ }
315
+ }
316
+ Object.defineProperty(holder, ALLOWED_TARGETS_SYMBOL, {
317
+ value: Object.freeze([...targets]),
318
+ writable: false,
319
+ configurable: false,
320
+ enumerable: false
321
+ });
322
+ }
323
+ function installAllowedTargets(targets) {
324
+ installAllowedTargetsOn(globalThis, targets);
325
+ }
326
+ function allowedTargets() {
327
+ return readAllowedTargets(globalThis);
328
+ }
329
+ function isAllowedTarget(ip, port) {
330
+ return isAllowedTargetOn(globalThis, ip, port);
331
+ }
332
+
333
+ // src/ssrf.ts
213
334
  var ssrfResolver = {
214
335
  lookup: async (hostname) => {
215
336
  const { lookup } = await import("dns/promises");
@@ -221,15 +342,15 @@ function isIPv6(addr) {
221
342
  return addr.kind() === "ipv6";
222
343
  }
223
344
  var PUBLIC_RANGE = "unicast";
224
- var GLOBAL_UNICAST_V6 = ipaddr.parseCIDR("2000::/3");
345
+ var GLOBAL_UNICAST_V6 = ipaddr2.parseCIDR("2000::/3");
225
346
  var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
226
347
  function embeddedIpv4(addr) {
227
- return ipaddr.fromByteArray(addr.toByteArray().slice(12));
348
+ return ipaddr2.fromByteArray(addr.toByteArray().slice(12));
228
349
  }
229
350
  function isBlockedAddress(ip) {
230
351
  let addr;
231
352
  try {
232
- addr = ipaddr.parse(ip);
353
+ addr = ipaddr2.parse(ip);
233
354
  } catch {
234
355
  return true;
235
356
  }
@@ -315,8 +436,73 @@ function judgeConnection(message) {
315
436
  if (guardRelaxedForLocalDev()) return;
316
437
  const peer = socket.remoteAddress;
317
438
  if (peer != null && !isBlockedAddress(peer)) return;
439
+ const remotePort = socket.remotePort;
440
+ if (peer != null && allowedTargets().length > 0 && !Number.isInteger(remotePort)) {
441
+ reportUnreadablePeerPort();
442
+ socket.destroy(unreadablePortError(normalizeHost(hostname)));
443
+ return;
444
+ }
445
+ if (peer != null && isAllowedTarget(peer, remotePort)) return;
318
446
  socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
319
447
  }
448
+ var unreadablePeerPortLogged = false;
449
+ function reportUnreadablePeerPort() {
450
+ if (unreadablePeerPortLogged) return;
451
+ unreadablePeerPortLogged = true;
452
+ console.warn(
453
+ "[ssrf] a connection reported no remote port: the SSRF allow-list cannot be consulted, so private peers are refused."
454
+ );
455
+ }
456
+ function unreadablePortError(host) {
457
+ return new NodeError(
458
+ "BLOCKED_ADDRESS",
459
+ `Blocked request to ${host}: the port the connection landed on could not be read, so the allow-list could not be consulted`,
460
+ { status: 0 }
461
+ );
462
+ }
463
+ function judgingLookup(port) {
464
+ return (hostname, options, callback) => {
465
+ dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
466
+ if (err) {
467
+ callback(err, "");
468
+ return;
469
+ }
470
+ if (addresses.length === 0) {
471
+ callback(new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }), "");
472
+ return;
473
+ }
474
+ if (!guardRelaxedForLocalDev()) {
475
+ for (const a of addresses) {
476
+ if (isBlockedAddress(a.address) && !isAllowedTarget(a.address, port)) {
477
+ callback(blockedError(normalizeHost(hostname), a.address), "");
478
+ return;
479
+ }
480
+ }
481
+ }
482
+ if (options.all) callback(null, addresses);
483
+ else callback(null, addresses[0].address, addresses[0].family);
484
+ });
485
+ };
486
+ }
487
+ var connectors = /* @__PURE__ */ new Map();
488
+ function connectorFor(port) {
489
+ let connector = connectors.get(port);
490
+ if (connector === void 0) {
491
+ connector = buildConnector({ lookup: judgingLookup(port) });
492
+ connectors.set(port, connector);
493
+ }
494
+ return connector;
495
+ }
496
+ var dispatcher;
497
+ function guardedDispatcher() {
498
+ dispatcher ??= new Agent({
499
+ connect: (opts, callback) => {
500
+ const port = opts.port === "" ? opts.protocol === "https:" ? 443 : 80 : Number(opts.port);
501
+ return connectorFor(port)(opts, callback);
502
+ }
503
+ });
504
+ return dispatcher;
505
+ }
320
506
  function guardConnectionsTo(url) {
321
507
  if (!connectGuardInstalled) {
322
508
  connectGuardInstalled = true;
@@ -369,8 +555,10 @@ async function assertPublicUrl(url, opts = {}) {
369
555
  if (lower === "localhost" || lower.endsWith(".localhost")) {
370
556
  throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
371
557
  }
372
- if (isIP(host) !== 0) {
373
- if (isBlockedAddress(host)) throw blockedError(host, host);
558
+ const port = u.port === "" ? u.protocol === "https:" ? 443 : 80 : Number(u.port);
559
+ const refused = (address) => isBlockedAddress(address) && !isAllowedTarget(address, port);
560
+ if (isIP2(host) !== 0) {
561
+ if (refused(host)) throw blockedError(host, host);
374
562
  return;
375
563
  }
376
564
  const lookup = opts.lookup ?? ssrfResolver.lookup;
@@ -379,7 +567,7 @@ async function assertPublicUrl(url, opts = {}) {
379
567
  throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
380
568
  }
381
569
  for (const a of addresses) {
382
- if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
570
+ if (refused(a.address)) throw blockedError(host, a.address);
383
571
  }
384
572
  }
385
573
 
@@ -479,7 +667,7 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
479
667
  const timer = setTimeout(() => ac.abort(), effectiveMs);
480
668
  const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
481
669
  try {
482
- return await fetch(url, { ...fetchOptions, signal });
670
+ return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
483
671
  } catch (err) {
484
672
  const blocked = connectionRefusal(err);
485
673
  if (blocked) throw blocked;
@@ -1111,6 +1299,10 @@ export {
1111
1299
  RetryableError,
1112
1300
  SimpleValueCredential,
1113
1301
  VALUELESS_OPERATORS,
1302
+ VPN_ERROR_CODES,
1303
+ VPN_ROUTE_MISSING,
1304
+ VPN_UNAVAILABLE,
1305
+ allowedTargets,
1114
1306
  assertBranchNamed,
1115
1307
  assertPublicUrl,
1116
1308
  backoffDelay,
@@ -1125,11 +1317,14 @@ export {
1125
1317
  extractManifests,
1126
1318
  headerCrossesOrigin,
1127
1319
  httpErrorResult,
1320
+ installAllowedTargets,
1321
+ isAllowedTarget,
1128
1322
  isBlockedAddress,
1129
1323
  isMultiPort,
1130
1324
  isNodeWithIteration,
1131
1325
  isOAuthAuthorizeCredential,
1132
1326
  isOperator,
1327
+ isVpnErrorCode,
1133
1328
  maxBytesConfigField,
1134
1329
  normalizeCredentialType,
1135
1330
  normalizeLocalized,
package/dist/ssrf.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Agent } from 'undici';
1
2
  import { NodeError } from './errors.js';
2
3
  /**
3
4
  * A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
@@ -39,6 +40,13 @@ export declare const ssrfResolver: {
39
40
  * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
40
41
  */
41
42
  export declare function isBlockedAddress(ip: string): boolean;
43
+ /**
44
+ * The dispatcher `safeFetch` hands every request to. One per process, so
45
+ * keep-alive survives across calls; its pool holds only connections a `safeFetch`
46
+ * call opened. Deliberately **not** re-exported from `index.ts`, for the reason
47
+ * {@link guardConnectionsTo} is not.
48
+ */
49
+ export declare function guardedDispatcher(): Agent;
42
50
  /**
43
51
  * Put `url`'s host **and port** under the connect-time guard and return the
44
52
  * release for it. Ref-counted, so concurrent calls to one target do not release
@@ -48,8 +56,8 @@ export declare function isBlockedAddress(ip: string): boolean;
48
56
  *
49
57
  * The window is the call, not the connection. A connect that completes after the
50
58
  * release — the fetch it belonged to having already timed out or aborted — finds
51
- * its target unregistered and is not judged; see the gap in
52
- * `specs/ssrf-guard.md`.
59
+ * its target unregistered and is not judged here; the address it connected to was
60
+ * judged before the socket opened, by {@link guardedDispatcher}.
53
61
  *
54
62
  * Deliberately **not** re-exported from `index.ts`. The guard is engaged by
55
63
  * `safeFetch`, which is the one sanctioned way out to the network (PO-185); an
@@ -78,6 +86,11 @@ export declare function connectionRefusal(err: unknown): NodeError | undefined;
78
86
  * actually reached (PO-184). A caller that opens its own request after
79
87
  * `assertPublicUrl` gets the check and not that second half.
80
88
  *
89
+ * A private address passes only where exactly that address **and** the URL's
90
+ * effective port are on the run's allow-list (PO-828, see `isAllowedTarget`) —
91
+ * every other answer still decides as before, so one allowed address among
92
+ * private ones does not carry the rest.
93
+ *
81
94
  * Pass `signal` (the per-request timeout/cancellation budget) so a hung or
82
95
  * hostile DNS resolve cannot block the guard past that budget — see
83
96
  * {@link resolveHost}.
package/dist/types.d.ts CHANGED
@@ -3,7 +3,31 @@ export type LocalizedString = string | Record<string, string>;
3
3
  export type DataType = 'any' | 'object' | 'array' | 'string' | 'number' | 'boolean';
4
4
  export type OutputKind = 'default' | 'branch' | 'error';
5
5
  export type NodeCategory = 'trigger' | 'action' | 'transform' | 'control' | 'io';
6
- export type ConfigType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref' | 'state-ref' | 'dynamic-schema';
6
+ export type ConfigType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref' | 'state-ref' | 'dynamic-schema' | 'vpn-route';
7
+ /**
8
+ * What a node receives at run time for a `vpn-route` setting (PO-831).
9
+ *
10
+ * The editor stores `{ connection: string; route: string }`, or `null` (or no
11
+ * value) for the public internet. Before `execute`, the worker replaces a stored
12
+ * reference with the route as it stands now, so the node never resolves it
13
+ * itself; a connection that is not active fails the step as `VPN_UNAVAILABLE`,
14
+ * one that is gone as `VPN_ROUTE_MISSING` (see `VPN_ERROR_CODES`). A setting left
15
+ * on the public internet stays `null` or absent.
16
+ */
17
+ export interface ResolvedVpnRoute {
18
+ /** The VPN connection's name. */
19
+ connection: string;
20
+ /** The route's name within that connection. */
21
+ route: string;
22
+ kind: 'http' | 'tcp';
23
+ /**
24
+ * Where the run reaches the route: for `http` a base URL
25
+ * (`http://10.31.0.7:8080/sap-odata/`), for `tcp` a `host:port`
26
+ * (`10.31.0.7:20001`). The address is on the run's allow-list, so
27
+ * `safeFetch` reaches it although it is private.
28
+ */
29
+ endpoint: string;
30
+ }
7
31
  /**
8
32
  * Semantic category of an image, driving how the registry/CDN organises and
9
33
  * displays it.
@@ -186,6 +210,15 @@ export interface IConfigFieldBase {
186
210
  * the node whose config names it.
187
211
  */
188
212
  stateRole?: 'mapping' | 'cursor' | 'dedupe' | 'digest';
213
+ /**
214
+ * Only meaningful when `type === 'vpn-route'`: which kind of route the picker
215
+ * offers (PO-831) — `http` for a node that sends HTTP requests through the
216
+ * relay's proxy, `tcp` for one that opens a socket. Defaults to `'http'`.
217
+ *
218
+ * **Accepted by the registry only once integrations#406 is deployed** — until
219
+ * then a package declaring it is refused on upload.
220
+ */
221
+ routeKind?: 'http' | 'tcp';
189
222
  }
190
223
  export interface IConfigField extends IConfigFieldBase {
191
224
  /**
@@ -504,6 +537,29 @@ export interface ICredentialDescription {
504
537
  images?: IImage[];
505
538
  authKind: CredentialAuthKind;
506
539
  fields: ICredentialField[];
540
+ /**
541
+ * Which of this type's fields a VPN route replaces (PO-829). A type without it
542
+ * cannot be routed through a VPN, and the studio offers no choice.
543
+ *
544
+ * - `tcp` — the route's relay address and port replace the `hostField` and
545
+ * `portField` values (both keys of `fields`).
546
+ * - `http` — the route's endpoint replaces the base of the `urlField` value,
547
+ * keeping any path the author typed after it.
548
+ *
549
+ * The stored configuration is never rewritten; the platform substitutes the
550
+ * route's current endpoint each time the credential is tested or resolved.
551
+ *
552
+ * **Accepted by the registry only once integrations#406 is deployed** — until
553
+ * then a package declaring it is refused on upload.
554
+ */
555
+ network?: {
556
+ kind: 'tcp';
557
+ hostField: string;
558
+ portField: string;
559
+ } | {
560
+ kind: 'http';
561
+ urlField: string;
562
+ };
507
563
  }
508
564
  /**
509
565
  * Runtime context handed to a credential's `test`/`resolve`. Runs in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "TypeScript interfaces and utilities for Revenexx integration nodes",
5
5
  "license": "MIT",
6
6
  "author": "revenexx GmbH",
@@ -63,6 +63,7 @@
63
63
  "typescript": "^7.0.2"
64
64
  },
65
65
  "dependencies": {
66
- "ipaddr.js": "2.5.0"
66
+ "ipaddr.js": "2.5.0",
67
+ "undici": "6.29.1"
67
68
  }
68
69
  }