@revenexx/integrations-node-sdk 1.10.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;
@@ -0,0 +1,47 @@
1
+ import type { INode, INodeDescription, INodeResult, IOutputPort } from './types.js';
2
+ /**
3
+ * A node's output ports are alternatives: exactly one fires. A node with a
4
+ * single port has nothing to choose between, so its result may leave `branch`
5
+ * out; a node with several must say which one it took, because the worker
6
+ * otherwise activates every outgoing connection (PO-751).
7
+ *
8
+ * A node counts as multi-port when EITHER holds:
9
+ * - more than one port is declared (an `error` port counts: `out` + `error` is two);
10
+ * - a port is marked `resolveOutputs`, because that set is only known while the
11
+ * author edits and may hold several.
12
+ *
13
+ * A single port is one way out however it is declared — of kind `branch`, or named
14
+ * from a setting by `sourceFromConfig` (one name, with its `fallback`).
15
+ */
16
+ export declare function isMultiPort(outputs: readonly IOutputPort[] | undefined): boolean;
17
+ /** A result that names its branch — what every multi-port node returns. */
18
+ export interface IBranchedNodeResult extends INodeResult {
19
+ branch: string;
20
+ }
21
+ /**
22
+ * Declaration problems of a node's output ports that the manifest build can see.
23
+ * Returns one sentence per problem, empty when there is none.
24
+ *
25
+ * A node with several ports must give each fixed port a unique `name`, since `branch`
26
+ * names one of them, and a generated port's `fallback` name must not repeat a fixed
27
+ * one. (Whether `execute` really returns a `branch` cannot be read from a declaration
28
+ * — see {@link assertBranchNamed}.)
29
+ */
30
+ export declare function outputProblems(description: INodeDescription): string[];
31
+ /**
32
+ * The warnings `rvnxx-nodes manifest` prints for a package's nodes: every
33
+ * {@link outputProblems} finding, worded for the log. In 1.x they are warnings and the
34
+ * build continues; the next major turns them into errors.
35
+ */
36
+ export declare function outputWarnings(nodes: readonly INode[]): string[];
37
+ /**
38
+ * Check a result against the node's declaration. For a node's own tests:
39
+ * `assertBranchNamed(node, await node.execute(ctx, inputs), config)`.
40
+ *
41
+ * Throws when a multi-port node returned no `branch`, and when the branch is none of
42
+ * the names the node can produce: a fixed name is always accepted; beyond that, a
43
+ * `resolveOutputs` port accepts any name (it is unknown here), and a `sourceFromConfig`
44
+ * port accepts its fallback name and — when `config` is passed — the value it holds
45
+ * (with no `config`, any name). A single-port node may leave `branch` out.
46
+ */
47
+ export declare function assertBranchNamed(node: INode | INodeDescription, result: INodeResult, config?: Record<string, unknown>): void;
@@ -0,0 +1,122 @@
1
+ // src/branches.ts
2
+ function isMultiPort(outputs) {
3
+ if (!outputs) return false;
4
+ return outputs.length > 1 || outputs.some((p) => p.resolveOutputs === true);
5
+ }
6
+ var isGenerated = (p) => p.sourceFromConfig !== void 0 || p.resolveOutputs === true;
7
+ function outputProblems(description) {
8
+ const outputs = description.outputs ?? [];
9
+ if (!isMultiPort(outputs)) return [];
10
+ const problems = [];
11
+ const seen = /* @__PURE__ */ new Set();
12
+ for (const [i, port] of outputs.entries()) {
13
+ if (isGenerated(port)) continue;
14
+ if (port.name === void 0 || port.name === "") {
15
+ problems.push(
16
+ `${description.slug}: output ${i + 1} has no name, so a branch cannot choose it \u2014 a node with several outputs must name each one.`
17
+ );
18
+ } else if (seen.has(port.name)) {
19
+ problems.push(`${description.slug}: output name "${port.name}" is declared twice.`);
20
+ } else {
21
+ seen.add(port.name);
22
+ }
23
+ }
24
+ for (const port of outputs) {
25
+ const fb = port.fallback?.name;
26
+ if (isGenerated(port) && fb !== void 0 && seen.has(fb)) {
27
+ problems.push(`${description.slug}: the fallback name "${fb}" is also the name of a fixed output, so a branch "${fb}" is ambiguous.`);
28
+ }
29
+ }
30
+ return problems;
31
+ }
32
+ function outputWarnings(nodes) {
33
+ return nodes.flatMap((n) => outputProblems(n.description));
34
+ }
35
+ function assertBranchNamed(node, result, config) {
36
+ const description = "execute" in node ? node.description : node;
37
+ const outputs = description.outputs ?? [];
38
+ if (!isMultiPort(outputs)) return;
39
+ const branch = result.branch;
40
+ if (typeof branch !== "string" || branch === "") {
41
+ throw new Error(
42
+ `${description.slug} has several outputs, so its result must name the branch it took; without it every output fires.`
43
+ );
44
+ }
45
+ if (outputs.some((p) => !isGenerated(p) && p.name === branch)) return;
46
+ const producible = outputs.some((p) => {
47
+ if (p.resolveOutputs === true) return true;
48
+ if (p.sourceFromConfig === void 0) return false;
49
+ if (p.fallback?.name === branch) return true;
50
+ return config === void 0 || config[p.sourceFromConfig] === branch;
51
+ });
52
+ if (!producible) {
53
+ const names = outputs.map((p) => p.name ?? p.fallback?.name).filter((n) => n);
54
+ throw new Error(
55
+ `${description.slug} returned branch "${branch}", which none of its outputs can produce (${names.join(", ")}).`
56
+ );
57
+ }
58
+ }
59
+
60
+ // src/extract.ts
61
+ function extractManifest(node) {
62
+ return node.description;
63
+ }
64
+ function extractManifests(nodes) {
65
+ return nodes.map(extractManifest);
66
+ }
67
+ function extractCredentialManifest(credential) {
68
+ return credential.description;
69
+ }
70
+ function extractCredentialManifests(credentials) {
71
+ return credentials.map(extractCredentialManifest);
72
+ }
73
+
74
+ // src/manifest.ts
75
+ var MANIFEST_VERSION = "v0-draft";
76
+ var PACKAGE_ICON = /^lucide:[a-z0-9]+(-[a-z0-9]+)*$/;
77
+ var PHP_TRIM = /^[ \t\n\r\0\v]+|[ \t\n\r\0\v]+$/g;
78
+ function packageIconProblem(icon) {
79
+ if (icon === void 0 || PACKAGE_ICON.test(icon)) return void 0;
80
+ return `package.json "revenexx.icon" must be a Lucide icon name such as "lucide:bell", got "${icon}".`;
81
+ }
82
+ function parsePackageMeta(raw) {
83
+ const obj = raw && typeof raw === "object" ? raw : {};
84
+ const str = (v) => typeof v === "string" ? v.trim() : "";
85
+ const revenexx = obj.revenexx && typeof obj.revenexx === "object" ? obj.revenexx : {};
86
+ const displayName = str(revenexx.displayName);
87
+ const icon = typeof revenexx.icon === "string" ? revenexx.icon.replace(PHP_TRIM, "") : "";
88
+ return {
89
+ name: str(obj.name),
90
+ version: str(obj.version),
91
+ displayName: displayName !== "" ? displayName : void 0,
92
+ icon: icon !== "" ? icon : void 0
93
+ };
94
+ }
95
+ function buildManifest(nodes, credentials = [], templates = []) {
96
+ const manifest = {
97
+ manifestVersion: MANIFEST_VERSION,
98
+ nodes: extractManifests(nodes)
99
+ };
100
+ if (credentials.length > 0) {
101
+ manifest.credentials = extractCredentialManifests(credentials);
102
+ }
103
+ if (templates.length > 0) {
104
+ manifest.templates = templates;
105
+ }
106
+ return manifest;
107
+ }
108
+
109
+ export {
110
+ isMultiPort,
111
+ outputProblems,
112
+ outputWarnings,
113
+ assertBranchNamed,
114
+ extractManifest,
115
+ extractManifests,
116
+ extractCredentialManifest,
117
+ extractCredentialManifests,
118
+ MANIFEST_VERSION,
119
+ packageIconProblem,
120
+ parsePackageMeta,
121
+ buildManifest
122
+ };
package/dist/cli.cjs CHANGED
@@ -28,6 +28,41 @@ var fs2 = __toESM(require("fs"), 1);
28
28
  var import_node_path2 = require("path");
29
29
  var import_node_url = require("url");
30
30
 
31
+ // src/branches.ts
32
+ function isMultiPort(outputs) {
33
+ if (!outputs) return false;
34
+ return outputs.length > 1 || outputs.some((p) => p.resolveOutputs === true);
35
+ }
36
+ var isGenerated = (p) => p.sourceFromConfig !== void 0 || p.resolveOutputs === true;
37
+ function outputProblems(description) {
38
+ const outputs = description.outputs ?? [];
39
+ if (!isMultiPort(outputs)) return [];
40
+ const problems = [];
41
+ const seen = /* @__PURE__ */ new Set();
42
+ for (const [i, port] of outputs.entries()) {
43
+ if (isGenerated(port)) continue;
44
+ if (port.name === void 0 || port.name === "") {
45
+ problems.push(
46
+ `${description.slug}: output ${i + 1} has no name, so a branch cannot choose it \u2014 a node with several outputs must name each one.`
47
+ );
48
+ } else if (seen.has(port.name)) {
49
+ problems.push(`${description.slug}: output name "${port.name}" is declared twice.`);
50
+ } else {
51
+ seen.add(port.name);
52
+ }
53
+ }
54
+ for (const port of outputs) {
55
+ const fb = port.fallback?.name;
56
+ if (isGenerated(port) && fb !== void 0 && seen.has(fb)) {
57
+ problems.push(`${description.slug}: the fallback name "${fb}" is also the name of a fixed output, so a branch "${fb}" is ambiguous.`);
58
+ }
59
+ }
60
+ return problems;
61
+ }
62
+ function outputWarnings(nodes) {
63
+ return nodes.flatMap((n) => outputProblems(n.description));
64
+ }
65
+
31
66
  // src/images.ts
32
67
  var fs = __toESM(require("fs"), 1);
33
68
  var import_node_path = require("path");
@@ -174,6 +209,9 @@ async function runManifest() {
174
209
  '\u26A0 package.json has no "revenexx.displayName" \u2014 the node palette will fall back to the raw package name.'
175
210
  );
176
211
  }
212
+ for (const w of outputWarnings(mod.NODES)) {
213
+ console.warn(`\u26A0 ${w} This will be an error in the next major version.`);
214
+ }
177
215
  const manifest = buildManifest(mod.NODES, credentials, templates);
178
216
  const outDir = (0, import_node_path2.resolve)(projectRoot, "dist");
179
217
  const outFile = (0, import_node_path2.resolve)(outDir, "manifest.json");
package/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildManifest,
4
+ outputWarnings,
4
5
  packageIconProblem,
5
6
  parsePackageMeta
6
- } from "./chunk-5BCAXRU3.js";
7
+ } from "./chunk-XO73F62E.js";
7
8
 
8
9
  // src/cli.ts
9
10
  import * as fs2 from "fs";
@@ -107,6 +108,9 @@ async function runManifest() {
107
108
  '\u26A0 package.json has no "revenexx.displayName" \u2014 the node palette will fall back to the raw package name.'
108
109
  );
109
110
  }
111
+ for (const w of outputWarnings(mod.NODES)) {
112
+ console.warn(`\u26A0 ${w} This will be an error in the next major version.`);
113
+ }
110
114
  const manifest = buildManifest(mod.NODES, credentials, templates);
111
115
  const outDir = resolve2(projectRoot, "dist");
112
116
  const outFile = resolve2(outDir, "manifest.json");
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,11 @@ __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,
57
+ assertBranchNamed: () => assertBranchNamed,
53
58
  assertPublicUrl: () => assertPublicUrl,
54
59
  backoffDelay: () => backoffDelay,
55
60
  buildManifest: () => buildManifest,
@@ -63,13 +68,19 @@ __export(index_exports, {
63
68
  extractManifests: () => extractManifests,
64
69
  headerCrossesOrigin: () => headerCrossesOrigin,
65
70
  httpErrorResult: () => httpErrorResult,
71
+ installAllowedTargets: () => installAllowedTargets,
72
+ isAllowedTarget: () => isAllowedTarget,
66
73
  isBlockedAddress: () => isBlockedAddress,
74
+ isMultiPort: () => isMultiPort,
67
75
  isNodeWithIteration: () => isNodeWithIteration,
68
76
  isOAuthAuthorizeCredential: () => isOAuthAuthorizeCredential,
69
77
  isOperator: () => isOperator,
78
+ isVpnErrorCode: () => isVpnErrorCode,
70
79
  maxBytesConfigField: () => maxBytesConfigField,
71
80
  normalizeCredentialType: () => normalizeCredentialType,
72
81
  normalizeLocalized: () => normalizeLocalized,
82
+ outputProblems: () => outputProblems,
83
+ outputWarnings: () => outputWarnings,
73
84
  packageIconProblem: () => packageIconProblem,
74
85
  parsePackageMeta: () => parsePackageMeta,
75
86
  readArrayBuffer: () => readArrayBuffer,
@@ -209,6 +220,12 @@ var NodeError = class extends Error {
209
220
  this.meta = meta;
210
221
  }
211
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
+ }
212
229
 
213
230
  // src/nodeErrors.ts
214
231
  function toErrorOutput(err) {
@@ -294,10 +311,184 @@ function extractCredentialManifests(credentials) {
294
311
  return credentials.map(extractCredentialManifest);
295
312
  }
296
313
 
314
+ // src/branches.ts
315
+ function isMultiPort(outputs) {
316
+ if (!outputs) return false;
317
+ return outputs.length > 1 || outputs.some((p) => p.resolveOutputs === true);
318
+ }
319
+ var isGenerated = (p) => p.sourceFromConfig !== void 0 || p.resolveOutputs === true;
320
+ function outputProblems(description) {
321
+ const outputs = description.outputs ?? [];
322
+ if (!isMultiPort(outputs)) return [];
323
+ const problems = [];
324
+ const seen = /* @__PURE__ */ new Set();
325
+ for (const [i, port] of outputs.entries()) {
326
+ if (isGenerated(port)) continue;
327
+ if (port.name === void 0 || port.name === "") {
328
+ problems.push(
329
+ `${description.slug}: output ${i + 1} has no name, so a branch cannot choose it \u2014 a node with several outputs must name each one.`
330
+ );
331
+ } else if (seen.has(port.name)) {
332
+ problems.push(`${description.slug}: output name "${port.name}" is declared twice.`);
333
+ } else {
334
+ seen.add(port.name);
335
+ }
336
+ }
337
+ for (const port of outputs) {
338
+ const fb = port.fallback?.name;
339
+ if (isGenerated(port) && fb !== void 0 && seen.has(fb)) {
340
+ problems.push(`${description.slug}: the fallback name "${fb}" is also the name of a fixed output, so a branch "${fb}" is ambiguous.`);
341
+ }
342
+ }
343
+ return problems;
344
+ }
345
+ function outputWarnings(nodes) {
346
+ return nodes.flatMap((n) => outputProblems(n.description));
347
+ }
348
+ function assertBranchNamed(node, result, config) {
349
+ const description = "execute" in node ? node.description : node;
350
+ const outputs = description.outputs ?? [];
351
+ if (!isMultiPort(outputs)) return;
352
+ const branch = result.branch;
353
+ if (typeof branch !== "string" || branch === "") {
354
+ throw new Error(
355
+ `${description.slug} has several outputs, so its result must name the branch it took; without it every output fires.`
356
+ );
357
+ }
358
+ if (outputs.some((p) => !isGenerated(p) && p.name === branch)) return;
359
+ const producible = outputs.some((p) => {
360
+ if (p.resolveOutputs === true) return true;
361
+ if (p.sourceFromConfig === void 0) return false;
362
+ if (p.fallback?.name === branch) return true;
363
+ return config === void 0 || config[p.sourceFromConfig] === branch;
364
+ });
365
+ if (!producible) {
366
+ const names = outputs.map((p) => p.name ?? p.fallback?.name).filter((n) => n);
367
+ throw new Error(
368
+ `${description.slug} returned branch "${branch}", which none of its outputs can produce (${names.join(", ")}).`
369
+ );
370
+ }
371
+ }
372
+
297
373
  // src/ssrf.ts
298
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
299
381
  var import_node_net = require("net");
300
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
301
492
  var ssrfResolver = {
302
493
  lookup: async (hostname) => {
303
494
  const { lookup } = await import("dns/promises");
@@ -309,15 +500,15 @@ function isIPv6(addr) {
309
500
  return addr.kind() === "ipv6";
310
501
  }
311
502
  var PUBLIC_RANGE = "unicast";
312
- var GLOBAL_UNICAST_V6 = import_ipaddr.default.parseCIDR("2000::/3");
503
+ var GLOBAL_UNICAST_V6 = import_ipaddr2.default.parseCIDR("2000::/3");
313
504
  var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
314
505
  function embeddedIpv4(addr) {
315
- return import_ipaddr.default.fromByteArray(addr.toByteArray().slice(12));
506
+ return import_ipaddr2.default.fromByteArray(addr.toByteArray().slice(12));
316
507
  }
317
508
  function isBlockedAddress(ip) {
318
509
  let addr;
319
510
  try {
320
- addr = import_ipaddr.default.parse(ip);
511
+ addr = import_ipaddr2.default.parse(ip);
321
512
  } catch {
322
513
  return true;
323
514
  }
@@ -403,8 +594,73 @@ function judgeConnection(message) {
403
594
  if (guardRelaxedForLocalDev()) return;
404
595
  const peer = socket.remoteAddress;
405
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;
406
604
  socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
407
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
+ }
408
664
  function guardConnectionsTo(url) {
409
665
  if (!connectGuardInstalled) {
410
666
  connectGuardInstalled = true;
@@ -457,8 +713,10 @@ async function assertPublicUrl(url, opts = {}) {
457
713
  if (lower === "localhost" || lower.endsWith(".localhost")) {
458
714
  throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
459
715
  }
460
- if ((0, import_node_net.isIP)(host) !== 0) {
461
- 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);
462
720
  return;
463
721
  }
464
722
  const lookup = opts.lookup ?? ssrfResolver.lookup;
@@ -467,7 +725,7 @@ async function assertPublicUrl(url, opts = {}) {
467
725
  throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
468
726
  }
469
727
  for (const a of addresses) {
470
- if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
728
+ if (refused(a.address)) throw blockedError(host, a.address);
471
729
  }
472
730
  }
473
731
 
@@ -567,7 +825,7 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
567
825
  const timer = setTimeout(() => ac.abort(), effectiveMs);
568
826
  const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
569
827
  try {
570
- return await fetch(url, { ...fetchOptions, signal });
828
+ return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
571
829
  } catch (err) {
572
830
  const blocked = connectionRefusal(err);
573
831
  if (blocked) throw blocked;
@@ -1235,6 +1493,11 @@ function base64Url(buf) {
1235
1493
  RetryableError,
1236
1494
  SimpleValueCredential,
1237
1495
  VALUELESS_OPERATORS,
1496
+ VPN_ERROR_CODES,
1497
+ VPN_ROUTE_MISSING,
1498
+ VPN_UNAVAILABLE,
1499
+ allowedTargets,
1500
+ assertBranchNamed,
1238
1501
  assertPublicUrl,
1239
1502
  backoffDelay,
1240
1503
  buildManifest,
@@ -1248,13 +1511,19 @@ function base64Url(buf) {
1248
1511
  extractManifests,
1249
1512
  headerCrossesOrigin,
1250
1513
  httpErrorResult,
1514
+ installAllowedTargets,
1515
+ isAllowedTarget,
1251
1516
  isBlockedAddress,
1517
+ isMultiPort,
1252
1518
  isNodeWithIteration,
1253
1519
  isOAuthAuthorizeCredential,
1254
1520
  isOperator,
1521
+ isVpnErrorCode,
1255
1522
  maxBytesConfigField,
1256
1523
  normalizeCredentialType,
1257
1524
  normalizeLocalized,
1525
+ outputProblems,
1526
+ outputWarnings,
1258
1527
  packageIconProblem,
1259
1528
  parsePackageMeta,
1260
1529
  readArrayBuffer,
package/dist/index.d.ts CHANGED
@@ -6,9 +6,11 @@ export * from './credentialType.js';
6
6
  export * from './errors.js';
7
7
  export * from './nodeErrors.js';
8
8
  export * from './extract.js';
9
+ export * from './branches.js';
9
10
  export * from './fetch.js';
10
11
  export { assertPublicUrl, isBlockedAddress } from './ssrf.js';
11
12
  export type { LookupAddress, LookupFn } from './ssrf.js';
13
+ export { allowedTargets, installAllowedTargets, isAllowedTarget } from './allowedTargets.js';
12
14
  export * from './retry.js';
13
15
  export * from './manifest.js';
14
16
  export * from './credentials.js';
package/dist/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  MANIFEST_VERSION,
3
+ assertBranchNamed,
3
4
  buildManifest,
4
5
  extractCredentialManifest,
5
6
  extractCredentialManifests,
6
7
  extractManifest,
7
8
  extractManifests,
9
+ isMultiPort,
10
+ outputProblems,
11
+ outputWarnings,
8
12
  packageIconProblem,
9
13
  parsePackageMeta
10
- } from "./chunk-5BCAXRU3.js";
14
+ } from "./chunk-XO73F62E.js";
11
15
 
12
16
  // src/types.ts
13
17
  function isNodeWithIteration(node) {
@@ -131,6 +135,12 @@ var NodeError = class extends Error {
131
135
  this.meta = meta;
132
136
  }
133
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
+ }
134
144
 
135
145
  // src/nodeErrors.ts
136
146
  function toErrorOutput(err) {
@@ -204,8 +214,123 @@ function errorPort(opts = {}) {
204
214
 
205
215
  // src/ssrf.ts
206
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
207
223
  import { isIP } from "net";
208
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
209
334
  var ssrfResolver = {
210
335
  lookup: async (hostname) => {
211
336
  const { lookup } = await import("dns/promises");
@@ -217,15 +342,15 @@ function isIPv6(addr) {
217
342
  return addr.kind() === "ipv6";
218
343
  }
219
344
  var PUBLIC_RANGE = "unicast";
220
- var GLOBAL_UNICAST_V6 = ipaddr.parseCIDR("2000::/3");
345
+ var GLOBAL_UNICAST_V6 = ipaddr2.parseCIDR("2000::/3");
221
346
  var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
222
347
  function embeddedIpv4(addr) {
223
- return ipaddr.fromByteArray(addr.toByteArray().slice(12));
348
+ return ipaddr2.fromByteArray(addr.toByteArray().slice(12));
224
349
  }
225
350
  function isBlockedAddress(ip) {
226
351
  let addr;
227
352
  try {
228
- addr = ipaddr.parse(ip);
353
+ addr = ipaddr2.parse(ip);
229
354
  } catch {
230
355
  return true;
231
356
  }
@@ -311,8 +436,73 @@ function judgeConnection(message) {
311
436
  if (guardRelaxedForLocalDev()) return;
312
437
  const peer = socket.remoteAddress;
313
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;
314
446
  socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
315
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
+ }
316
506
  function guardConnectionsTo(url) {
317
507
  if (!connectGuardInstalled) {
318
508
  connectGuardInstalled = true;
@@ -365,8 +555,10 @@ async function assertPublicUrl(url, opts = {}) {
365
555
  if (lower === "localhost" || lower.endsWith(".localhost")) {
366
556
  throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
367
557
  }
368
- if (isIP(host) !== 0) {
369
- 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);
370
562
  return;
371
563
  }
372
564
  const lookup = opts.lookup ?? ssrfResolver.lookup;
@@ -375,7 +567,7 @@ async function assertPublicUrl(url, opts = {}) {
375
567
  throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
376
568
  }
377
569
  for (const a of addresses) {
378
- if (isBlockedAddress(a.address)) throw blockedError(host, a.address);
570
+ if (refused(a.address)) throw blockedError(host, a.address);
379
571
  }
380
572
  }
381
573
 
@@ -475,7 +667,7 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
475
667
  const timer = setTimeout(() => ac.abort(), effectiveMs);
476
668
  const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
477
669
  try {
478
- return await fetch(url, { ...fetchOptions, signal });
670
+ return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
479
671
  } catch (err) {
480
672
  const blocked = connectionRefusal(err);
481
673
  if (blocked) throw blocked;
@@ -1107,6 +1299,11 @@ export {
1107
1299
  RetryableError,
1108
1300
  SimpleValueCredential,
1109
1301
  VALUELESS_OPERATORS,
1302
+ VPN_ERROR_CODES,
1303
+ VPN_ROUTE_MISSING,
1304
+ VPN_UNAVAILABLE,
1305
+ allowedTargets,
1306
+ assertBranchNamed,
1110
1307
  assertPublicUrl,
1111
1308
  backoffDelay,
1112
1309
  buildManifest,
@@ -1120,13 +1317,19 @@ export {
1120
1317
  extractManifests,
1121
1318
  headerCrossesOrigin,
1122
1319
  httpErrorResult,
1320
+ installAllowedTargets,
1321
+ isAllowedTarget,
1123
1322
  isBlockedAddress,
1323
+ isMultiPort,
1124
1324
  isNodeWithIteration,
1125
1325
  isOAuthAuthorizeCredential,
1126
1326
  isOperator,
1327
+ isVpnErrorCode,
1127
1328
  maxBytesConfigField,
1128
1329
  normalizeCredentialType,
1129
1330
  normalizeLocalized,
1331
+ outputProblems,
1332
+ outputWarnings,
1130
1333
  packageIconProblem,
1131
1334
  parsePackageMeta,
1132
1335
  readArrayBuffer,
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
  /**
@@ -368,6 +401,14 @@ export interface INodeState {
368
401
  }
369
402
  export interface INodeResult {
370
403
  outputs: Record<string, unknown>;
404
+ /**
405
+ * The output port the node took. Output ports are alternatives — exactly one
406
+ * fires — so a node with more than one output (an `error` port counts) must
407
+ * name it; only a node with a single output may omit it. The worker fails such
408
+ * a step from the integrations release that carries PO-751; until then a result
409
+ * without `branch` fires every output. See `isMultiPort` for what counts as
410
+ * several, and `assertBranchNamed` to check a result in the node's own tests.
411
+ */
371
412
  branch?: string;
372
413
  }
373
414
  /**
@@ -496,6 +537,29 @@ export interface ICredentialDescription {
496
537
  images?: IImage[];
497
538
  authKind: CredentialAuthKind;
498
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
+ };
499
563
  }
500
564
  /**
501
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.10.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
  }
@@ -1,59 +0,0 @@
1
- // src/extract.ts
2
- function extractManifest(node) {
3
- return node.description;
4
- }
5
- function extractManifests(nodes) {
6
- return nodes.map(extractManifest);
7
- }
8
- function extractCredentialManifest(credential) {
9
- return credential.description;
10
- }
11
- function extractCredentialManifests(credentials) {
12
- return credentials.map(extractCredentialManifest);
13
- }
14
-
15
- // src/manifest.ts
16
- var MANIFEST_VERSION = "v0-draft";
17
- var PACKAGE_ICON = /^lucide:[a-z0-9]+(-[a-z0-9]+)*$/;
18
- var PHP_TRIM = /^[ \t\n\r\0\v]+|[ \t\n\r\0\v]+$/g;
19
- function packageIconProblem(icon) {
20
- if (icon === void 0 || PACKAGE_ICON.test(icon)) return void 0;
21
- return `package.json "revenexx.icon" must be a Lucide icon name such as "lucide:bell", got "${icon}".`;
22
- }
23
- function parsePackageMeta(raw) {
24
- const obj = raw && typeof raw === "object" ? raw : {};
25
- const str = (v) => typeof v === "string" ? v.trim() : "";
26
- const revenexx = obj.revenexx && typeof obj.revenexx === "object" ? obj.revenexx : {};
27
- const displayName = str(revenexx.displayName);
28
- const icon = typeof revenexx.icon === "string" ? revenexx.icon.replace(PHP_TRIM, "") : "";
29
- return {
30
- name: str(obj.name),
31
- version: str(obj.version),
32
- displayName: displayName !== "" ? displayName : void 0,
33
- icon: icon !== "" ? icon : void 0
34
- };
35
- }
36
- function buildManifest(nodes, credentials = [], templates = []) {
37
- const manifest = {
38
- manifestVersion: MANIFEST_VERSION,
39
- nodes: extractManifests(nodes)
40
- };
41
- if (credentials.length > 0) {
42
- manifest.credentials = extractCredentialManifests(credentials);
43
- }
44
- if (templates.length > 0) {
45
- manifest.templates = templates;
46
- }
47
- return manifest;
48
- }
49
-
50
- export {
51
- extractManifest,
52
- extractManifests,
53
- extractCredentialManifest,
54
- extractCredentialManifests,
55
- MANIFEST_VERSION,
56
- packageIconProblem,
57
- parsePackageMeta,
58
- buildManifest
59
- };