@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.
- package/dist/allowedTargets.d.ts +67 -0
- package/dist/errors.d.ts +21 -0
- package/dist/index.cjs +209 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +202 -7
- package/dist/ssrf.d.ts +15 -2
- package/dist/types.d.ts +57 -1
- package/package.json +3 -2
|
@@ -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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
524
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
348
|
+
return ipaddr2.fromByteArray(addr.toByteArray().slice(12));
|
|
228
349
|
}
|
|
229
350
|
function isBlockedAddress(ip) {
|
|
230
351
|
let addr;
|
|
231
352
|
try {
|
|
232
|
-
addr =
|
|
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
|
-
|
|
373
|
-
|
|
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 (
|
|
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;
|
|
52
|
-
*
|
|
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.
|
|
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
|
}
|