@revenexx/integrations-node-sdk 1.11.0 → 1.14.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/{chunk-XO73F62E.js → chunk-CA6JVLLF.js} +52 -0
- package/dist/cli.cjs +49 -0
- package/dist/cli.js +4 -1
- package/dist/errors.d.ts +21 -0
- package/dist/index.cjs +374 -31
- package/dist/index.d.ts +3 -2
- package/dist/index.js +319 -32
- package/dist/manifest.d.ts +25 -0
- package/dist/ssrf.d.ts +54 -2
- package/dist/types.d.ts +64 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CONFIG_KEY_PATTERN,
|
|
2
3
|
MANIFEST_VERSION,
|
|
4
|
+
NAMESPACED_SLUG_PATTERN,
|
|
5
|
+
SEMVER_PATTERN,
|
|
6
|
+
SLUG_PATTERN,
|
|
3
7
|
assertBranchNamed,
|
|
4
8
|
buildManifest,
|
|
5
9
|
extractCredentialManifest,
|
|
@@ -7,11 +11,12 @@ import {
|
|
|
7
11
|
extractManifest,
|
|
8
12
|
extractManifests,
|
|
9
13
|
isMultiPort,
|
|
14
|
+
nodeManifestProblems,
|
|
10
15
|
outputProblems,
|
|
11
16
|
outputWarnings,
|
|
12
17
|
packageIconProblem,
|
|
13
18
|
parsePackageMeta
|
|
14
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-CA6JVLLF.js";
|
|
15
20
|
|
|
16
21
|
// src/types.ts
|
|
17
22
|
function isNodeWithIteration(node) {
|
|
@@ -135,6 +140,12 @@ var NodeError = class extends Error {
|
|
|
135
140
|
this.meta = meta;
|
|
136
141
|
}
|
|
137
142
|
};
|
|
143
|
+
var VPN_UNAVAILABLE = "VPN_UNAVAILABLE";
|
|
144
|
+
var VPN_ROUTE_MISSING = "VPN_ROUTE_MISSING";
|
|
145
|
+
var VPN_ERROR_CODES = Object.freeze([VPN_UNAVAILABLE, VPN_ROUTE_MISSING]);
|
|
146
|
+
function isVpnErrorCode(code) {
|
|
147
|
+
return VPN_ERROR_CODES.includes(code);
|
|
148
|
+
}
|
|
138
149
|
|
|
139
150
|
// src/nodeErrors.ts
|
|
140
151
|
function toErrorOutput(err) {
|
|
@@ -208,8 +219,123 @@ function errorPort(opts = {}) {
|
|
|
208
219
|
|
|
209
220
|
// src/ssrf.ts
|
|
210
221
|
import { subscribe } from "diagnostics_channel";
|
|
222
|
+
import dns from "dns";
|
|
223
|
+
import { isIP as isIP2 } from "net";
|
|
224
|
+
import ipaddr2 from "ipaddr.js";
|
|
225
|
+
import { Agent, buildConnector } from "undici";
|
|
226
|
+
|
|
227
|
+
// src/allowedTargets.ts
|
|
211
228
|
import { isIP } from "net";
|
|
212
229
|
import ipaddr from "ipaddr.js";
|
|
230
|
+
var ALLOWED_TARGETS_SYMBOL = /* @__PURE__ */ Symbol.for("revenexx.ssrf.allowedTargets");
|
|
231
|
+
var EMPTY = Object.freeze([]);
|
|
232
|
+
var V4_ENTRY = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/;
|
|
233
|
+
var V6_ENTRY = /^\[([0-9A-Fa-f:.]+)\]:(\d{1,5})$/;
|
|
234
|
+
function canonicalAddress(ip) {
|
|
235
|
+
const bare = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
|
|
236
|
+
if (isIP(bare) === 0) return void 0;
|
|
237
|
+
try {
|
|
238
|
+
return ipaddr.process(bare).toString();
|
|
239
|
+
} catch {
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function validPort(port) {
|
|
244
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
245
|
+
}
|
|
246
|
+
var TARGET_RANGES = /* @__PURE__ */ new Set(["private", "uniqueLocal"]);
|
|
247
|
+
function loopbackTargetsAllowed() {
|
|
248
|
+
const raw = process.env["RVNXX_SSRF_TARGETS_ALLOW_LOOPBACK"];
|
|
249
|
+
return raw != null && ["1", "true", "yes"].includes(raw.trim().toLowerCase());
|
|
250
|
+
}
|
|
251
|
+
function parseTarget(entry, allowLoopback) {
|
|
252
|
+
if (typeof entry !== "string") return void 0;
|
|
253
|
+
const v4 = V4_ENTRY.exec(entry);
|
|
254
|
+
const m = v4 ?? V6_ENTRY.exec(entry);
|
|
255
|
+
if (!m) return void 0;
|
|
256
|
+
const host = m[1];
|
|
257
|
+
const port = Number(m[2]);
|
|
258
|
+
if (v4 && !ipaddr.IPv4.isValidFourPartDecimal(host)) return void 0;
|
|
259
|
+
if (!validPort(port)) return void 0;
|
|
260
|
+
const address = canonicalAddress(host);
|
|
261
|
+
if (address === void 0) return void 0;
|
|
262
|
+
const range = ipaddr.parse(address).range();
|
|
263
|
+
if (!TARGET_RANGES.has(range) && !(allowLoopback && range === "loopback")) return void 0;
|
|
264
|
+
return `${address}:${port}`;
|
|
265
|
+
}
|
|
266
|
+
var allowListStats = { validations: 0 };
|
|
267
|
+
var keysByList = /* @__PURE__ */ new WeakMap();
|
|
268
|
+
function keysOf(list) {
|
|
269
|
+
const allowLoopback = loopbackTargetsAllowed();
|
|
270
|
+
let byMode = keysByList.get(list);
|
|
271
|
+
if (byMode === void 0) {
|
|
272
|
+
byMode = /* @__PURE__ */ new Map();
|
|
273
|
+
keysByList.set(list, byMode);
|
|
274
|
+
}
|
|
275
|
+
const cached = byMode.get(allowLoopback);
|
|
276
|
+
if (cached !== void 0) return cached;
|
|
277
|
+
allowListStats.validations++;
|
|
278
|
+
const keys = /* @__PURE__ */ new Set();
|
|
279
|
+
let result = keys;
|
|
280
|
+
for (const entry of list) {
|
|
281
|
+
const key = parseTarget(entry, allowLoopback);
|
|
282
|
+
if (key === void 0) {
|
|
283
|
+
result = null;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
keys.add(key);
|
|
287
|
+
}
|
|
288
|
+
byMode.set(allowLoopback, result);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
function installedList(holder) {
|
|
292
|
+
const descriptor = Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL);
|
|
293
|
+
if (descriptor === void 0 || !("value" in descriptor)) return null;
|
|
294
|
+
if (descriptor.writable || descriptor.configurable) return null;
|
|
295
|
+
const value = descriptor.value;
|
|
296
|
+
if (!Array.isArray(value) || !Object.isFrozen(value)) return null;
|
|
297
|
+
const keys = keysOf(value);
|
|
298
|
+
return keys === null ? null : { list: value, keys };
|
|
299
|
+
}
|
|
300
|
+
function readAllowedTargets(holder) {
|
|
301
|
+
return installedList(holder)?.list ?? EMPTY;
|
|
302
|
+
}
|
|
303
|
+
function isAllowedTargetOn(holder, ip, port) {
|
|
304
|
+
const installed = installedList(holder);
|
|
305
|
+
if (installed === null || !validPort(port)) return false;
|
|
306
|
+
const address = canonicalAddress(ip);
|
|
307
|
+
return address !== void 0 && installed.keys.has(`${address}:${port}`);
|
|
308
|
+
}
|
|
309
|
+
function installAllowedTargetsOn(holder, targets) {
|
|
310
|
+
if (Object.getOwnPropertyDescriptor(holder, ALLOWED_TARGETS_SYMBOL) !== void 0) {
|
|
311
|
+
throw new Error("The SSRF allow-list is already installed, and is installed once per process");
|
|
312
|
+
}
|
|
313
|
+
if (!Array.isArray(targets)) throw new TypeError('The SSRF allow-list must be an array of "<ip>:<port>" strings');
|
|
314
|
+
for (const entry of targets) {
|
|
315
|
+
if (parseTarget(entry, loopbackTargetsAllowed()) === void 0) {
|
|
316
|
+
throw new TypeError(
|
|
317
|
+
`Not an allowed target: ${JSON.stringify(entry)} (expected "<ip>:<port>" or "[v6]:<port>" at an RFC 1918 or unique-local address)`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
Object.defineProperty(holder, ALLOWED_TARGETS_SYMBOL, {
|
|
322
|
+
value: Object.freeze([...targets]),
|
|
323
|
+
writable: false,
|
|
324
|
+
configurable: false,
|
|
325
|
+
enumerable: false
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function installAllowedTargets(targets) {
|
|
329
|
+
installAllowedTargetsOn(globalThis, targets);
|
|
330
|
+
}
|
|
331
|
+
function allowedTargets() {
|
|
332
|
+
return readAllowedTargets(globalThis);
|
|
333
|
+
}
|
|
334
|
+
function isAllowedTarget(ip, port) {
|
|
335
|
+
return isAllowedTargetOn(globalThis, ip, port);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/ssrf.ts
|
|
213
339
|
var ssrfResolver = {
|
|
214
340
|
lookup: async (hostname) => {
|
|
215
341
|
const { lookup } = await import("dns/promises");
|
|
@@ -221,15 +347,15 @@ function isIPv6(addr) {
|
|
|
221
347
|
return addr.kind() === "ipv6";
|
|
222
348
|
}
|
|
223
349
|
var PUBLIC_RANGE = "unicast";
|
|
224
|
-
var GLOBAL_UNICAST_V6 =
|
|
350
|
+
var GLOBAL_UNICAST_V6 = ipaddr2.parseCIDR("2000::/3");
|
|
225
351
|
var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
|
|
226
352
|
function embeddedIpv4(addr) {
|
|
227
|
-
return
|
|
353
|
+
return ipaddr2.fromByteArray(addr.toByteArray().slice(12));
|
|
228
354
|
}
|
|
229
355
|
function isBlockedAddress(ip) {
|
|
230
356
|
let addr;
|
|
231
357
|
try {
|
|
232
|
-
addr =
|
|
358
|
+
addr = ipaddr2.parse(ip);
|
|
233
359
|
} catch {
|
|
234
360
|
return true;
|
|
235
361
|
}
|
|
@@ -246,19 +372,25 @@ function guardRelaxedForLocalDev() {
|
|
|
246
372
|
if (relaxed && !bypassNoticeLogged) {
|
|
247
373
|
bypassNoticeLogged = true;
|
|
248
374
|
console.warn(
|
|
249
|
-
"[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback
|
|
375
|
+
"[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback targets for web requests and connections. Intended for local development only."
|
|
250
376
|
);
|
|
251
377
|
}
|
|
252
378
|
return relaxed;
|
|
253
379
|
}
|
|
380
|
+
function logBlocked(noun, host, address) {
|
|
381
|
+
console.warn(`[ssrf] blocked ${noun} to ${host}: resolves to private/reserved address ${address}`);
|
|
382
|
+
}
|
|
254
383
|
function blockedError(host, address) {
|
|
255
384
|
if (host === address) {
|
|
256
385
|
return new NodeError("BLOCKED_ADDRESS", `Blocked request to private or reserved address ${address}`, {
|
|
257
386
|
status: 0
|
|
258
387
|
});
|
|
259
388
|
}
|
|
260
|
-
|
|
261
|
-
return
|
|
389
|
+
logBlocked("request", host, address);
|
|
390
|
+
return refusedName(host);
|
|
391
|
+
}
|
|
392
|
+
function refusedName(host, noun = "request") {
|
|
393
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked ${noun} to ${host}: resolves to a private or reserved address`, {
|
|
262
394
|
status: 0
|
|
263
395
|
});
|
|
264
396
|
}
|
|
@@ -315,8 +447,86 @@ function judgeConnection(message) {
|
|
|
315
447
|
if (guardRelaxedForLocalDev()) return;
|
|
316
448
|
const peer = socket.remoteAddress;
|
|
317
449
|
if (peer != null && !isBlockedAddress(peer)) return;
|
|
450
|
+
const remotePort = socket.remotePort;
|
|
451
|
+
if (peer != null && allowedTargets().length > 0 && !Number.isInteger(remotePort)) {
|
|
452
|
+
reportUnreadablePeerPort();
|
|
453
|
+
socket.destroy(unreadablePortError(normalizeHost(hostname)));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (peer != null && isAllowedTarget(peer, remotePort)) return;
|
|
318
457
|
socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
|
|
319
458
|
}
|
|
459
|
+
var unreadablePeerPortLogged = false;
|
|
460
|
+
function reportUnreadablePeerPort() {
|
|
461
|
+
if (unreadablePeerPortLogged) return;
|
|
462
|
+
unreadablePeerPortLogged = true;
|
|
463
|
+
console.warn(
|
|
464
|
+
"[ssrf] a connection reported no remote port: the SSRF allow-list cannot be consulted, so private peers are refused."
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
function unreadablePortError(host) {
|
|
468
|
+
return new NodeError(
|
|
469
|
+
"BLOCKED_ADDRESS",
|
|
470
|
+
`Blocked request to ${host}: the port the connection landed on could not be read, so the allow-list could not be consulted`,
|
|
471
|
+
{ status: 0 }
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
function judgingLookup(port) {
|
|
475
|
+
return lookupJudgedFor(port, { noun: "request" }, (hostname) => new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }));
|
|
476
|
+
}
|
|
477
|
+
function guardedLookup(port, options = {}) {
|
|
478
|
+
return lookupJudgedFor(
|
|
479
|
+
port,
|
|
480
|
+
options,
|
|
481
|
+
(hostname) => Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND", hostname })
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
function lookupJudgedFor(port, { refusal, unroutable, noun = "connection" }, emptyAnswer) {
|
|
485
|
+
return (hostname, options, callback) => {
|
|
486
|
+
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
487
|
+
if (err) {
|
|
488
|
+
callback(err, "");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (addresses.length === 0) {
|
|
492
|
+
callback(emptyAnswer(hostname), "");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!guardRelaxedForLocalDev()) {
|
|
496
|
+
const host = hostname;
|
|
497
|
+
for (const a of addresses) {
|
|
498
|
+
if (!isBlockedAddress(a.address)) continue;
|
|
499
|
+
const allowed = isAllowedTarget(a.address, port);
|
|
500
|
+
if (allowed && !unroutable) continue;
|
|
501
|
+
logBlocked(noun, host, a.address);
|
|
502
|
+
callback(allowed && unroutable ? unroutable(host) : refusal ? refusal(host) : refusedName(host, noun), "");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (options.all) callback(null, addresses);
|
|
507
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
var connectors = /* @__PURE__ */ new Map();
|
|
512
|
+
function connectorFor(port) {
|
|
513
|
+
let connector = connectors.get(port);
|
|
514
|
+
if (connector === void 0) {
|
|
515
|
+
connector = buildConnector({ lookup: judgingLookup(port) });
|
|
516
|
+
connectors.set(port, connector);
|
|
517
|
+
}
|
|
518
|
+
return connector;
|
|
519
|
+
}
|
|
520
|
+
var dispatcher;
|
|
521
|
+
function guardedDispatcher() {
|
|
522
|
+
dispatcher ??= new Agent({
|
|
523
|
+
connect: (opts, callback) => {
|
|
524
|
+
const port = opts.port === "" ? opts.protocol === "https:" ? 443 : 80 : Number(opts.port);
|
|
525
|
+
return connectorFor(port)(opts, callback);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
return dispatcher;
|
|
529
|
+
}
|
|
320
530
|
function guardConnectionsTo(url) {
|
|
321
531
|
if (!connectGuardInstalled) {
|
|
322
532
|
connectGuardInstalled = true;
|
|
@@ -369,8 +579,10 @@ async function assertPublicUrl(url, opts = {}) {
|
|
|
369
579
|
if (lower === "localhost" || lower.endsWith(".localhost")) {
|
|
370
580
|
throw new NodeError("BLOCKED_ADDRESS", `Blocked loopback host: ${host}`, { status: 0 });
|
|
371
581
|
}
|
|
372
|
-
|
|
373
|
-
|
|
582
|
+
const port = u.port === "" ? u.protocol === "https:" ? 443 : 80 : Number(u.port);
|
|
583
|
+
const refused = (address) => isBlockedAddress(address) && !isAllowedTarget(address, port);
|
|
584
|
+
if (isIP2(host) !== 0) {
|
|
585
|
+
if (refused(host)) throw blockedError(host, host);
|
|
374
586
|
return;
|
|
375
587
|
}
|
|
376
588
|
const lookup = opts.lookup ?? ssrfResolver.lookup;
|
|
@@ -379,7 +591,7 @@ async function assertPublicUrl(url, opts = {}) {
|
|
|
379
591
|
throw new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${host}`, { status: 0 });
|
|
380
592
|
}
|
|
381
593
|
for (const a of addresses) {
|
|
382
|
-
if (
|
|
594
|
+
if (refused(a.address)) throw blockedError(host, a.address);
|
|
383
595
|
}
|
|
384
596
|
}
|
|
385
597
|
|
|
@@ -474,34 +686,84 @@ function credentialOriginRefusal(target, bound, hop) {
|
|
|
474
686
|
{ status: 0 }
|
|
475
687
|
);
|
|
476
688
|
}
|
|
477
|
-
|
|
689
|
+
function attemptDeadline(ctxSignal, effectiveMs) {
|
|
478
690
|
const ac = new AbortController();
|
|
479
|
-
|
|
480
|
-
const
|
|
691
|
+
let timer;
|
|
692
|
+
const arm = (message) => {
|
|
693
|
+
clearTimeout(timer);
|
|
694
|
+
timer = setTimeout(() => ac.abort(new NodeError("TIMEOUT", message)), effectiveMs);
|
|
695
|
+
};
|
|
696
|
+
arm(`Request timed out after ${effectiveMs}ms`);
|
|
697
|
+
return {
|
|
698
|
+
signal: ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal,
|
|
699
|
+
expired: () => ac.signal.aborted,
|
|
700
|
+
reason: () => ac.signal.reason,
|
|
701
|
+
clear: () => clearTimeout(timer),
|
|
702
|
+
awaitChunk: () => arm(`The answer sent nothing for ${effectiveMs}ms`)
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function attemptFailure(err, ctxSignal, deadline) {
|
|
706
|
+
if (ctxSignal?.aborted) return ctxSignal.reason;
|
|
707
|
+
if (deadline.expired()) return deadline.reason();
|
|
708
|
+
return err;
|
|
709
|
+
}
|
|
710
|
+
function answerUnderDeadline(res, ctxSignal, deadline) {
|
|
711
|
+
if (!res.body) return res;
|
|
712
|
+
let reader;
|
|
713
|
+
const body = new ReadableStream(
|
|
714
|
+
{
|
|
715
|
+
async pull(controller) {
|
|
716
|
+
reader ??= res.body.getReader();
|
|
717
|
+
deadline.awaitChunk();
|
|
718
|
+
try {
|
|
719
|
+
const { done, value } = await reader.read();
|
|
720
|
+
if (done) controller.close();
|
|
721
|
+
else controller.enqueue(value);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
controller.error(attemptFailure(err, ctxSignal, deadline));
|
|
724
|
+
} finally {
|
|
725
|
+
deadline.clear();
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
cancel(reason) {
|
|
729
|
+
deadline.clear();
|
|
730
|
+
return reader ? reader.cancel(reason) : res.body?.cancel(reason);
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
// Read nothing ahead of the caller: the limit times the caller's waits, not
|
|
734
|
+
// a buffer filling while nobody reads.
|
|
735
|
+
{ highWaterMark: 0 }
|
|
736
|
+
);
|
|
737
|
+
let answer;
|
|
738
|
+
try {
|
|
739
|
+
answer = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
740
|
+
} catch {
|
|
741
|
+
return res;
|
|
742
|
+
}
|
|
743
|
+
return withUrl(answer, res.url);
|
|
744
|
+
}
|
|
745
|
+
function withUrl(answer, url) {
|
|
746
|
+
const clone = answer.clone.bind(answer);
|
|
747
|
+
Object.defineProperty(answer, "url", { value: url });
|
|
748
|
+
Object.defineProperty(answer, "clone", { value: () => withUrl(clone(), url) });
|
|
749
|
+
return answer;
|
|
750
|
+
}
|
|
751
|
+
async function timedFetch(url, fetchOptions, ctxSignal, deadline) {
|
|
481
752
|
try {
|
|
482
|
-
return await fetch(url, { ...fetchOptions, signal });
|
|
753
|
+
return await fetch(url, { ...fetchOptions, signal: deadline.signal, dispatcher: guardedDispatcher() });
|
|
483
754
|
} catch (err) {
|
|
484
755
|
const blocked = connectionRefusal(err);
|
|
485
756
|
if (blocked) throw blocked;
|
|
486
|
-
|
|
487
|
-
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
|
|
488
|
-
throw err;
|
|
489
|
-
} finally {
|
|
490
|
-
clearTimeout(timer);
|
|
757
|
+
throw attemptFailure(err, ctxSignal, deadline);
|
|
491
758
|
}
|
|
492
759
|
}
|
|
493
|
-
async function guardUrl(url, ctxSignal, effectiveMs) {
|
|
494
|
-
const ac = new AbortController();
|
|
495
|
-
const timer = setTimeout(() => ac.abort(), effectiveMs);
|
|
496
|
-
const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
|
|
760
|
+
async function guardUrl(url, ctxSignal, deadline, effectiveMs) {
|
|
497
761
|
try {
|
|
498
|
-
await assertPublicUrl(url, { signal });
|
|
762
|
+
await assertPublicUrl(url, { signal: deadline.signal });
|
|
499
763
|
} catch (err) {
|
|
500
764
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
501
|
-
if (
|
|
765
|
+
if (deadline.expired()) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
|
|
502
766
|
throw err;
|
|
503
|
-
} finally {
|
|
504
|
-
clearTimeout(timer);
|
|
505
767
|
}
|
|
506
768
|
}
|
|
507
769
|
function hasContent(body) {
|
|
@@ -523,16 +785,27 @@ function refuseUserinfo(url, status = 0) {
|
|
|
523
785
|
}
|
|
524
786
|
}
|
|
525
787
|
async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
|
|
788
|
+
const deadline = attemptDeadline(ctxSignal, effectiveMs);
|
|
789
|
+
try {
|
|
790
|
+
const res = await followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, binding);
|
|
791
|
+
deadline.clear();
|
|
792
|
+
return answerUnderDeadline(res, ctxSignal, deadline);
|
|
793
|
+
} catch (err) {
|
|
794
|
+
deadline.clear();
|
|
795
|
+
throw err;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, binding) {
|
|
526
799
|
let currentUrl = url;
|
|
527
800
|
let currentInit = { ...fetchOptions, redirect: "manual" };
|
|
528
|
-
await guardUrl(currentUrl, ctxSignal, effectiveMs);
|
|
801
|
+
await guardUrl(currentUrl, ctxSignal, deadline, effectiveMs);
|
|
529
802
|
for (let hop = 0; ; hop++) {
|
|
530
803
|
const release = guardConnectionsTo(
|
|
531
804
|
currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
|
|
532
805
|
);
|
|
533
806
|
let res;
|
|
534
807
|
try {
|
|
535
|
-
res = await timedFetch(currentUrl, currentInit, ctxSignal,
|
|
808
|
+
res = await timedFetch(currentUrl, currentInit, ctxSignal, deadline);
|
|
536
809
|
} finally {
|
|
537
810
|
release();
|
|
538
811
|
}
|
|
@@ -574,7 +847,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding)
|
|
|
574
847
|
{ status: 0 }
|
|
575
848
|
);
|
|
576
849
|
}
|
|
577
|
-
await guardUrl(nextUrl, ctxSignal, effectiveMs);
|
|
850
|
+
await guardUrl(nextUrl, ctxSignal, deadline, effectiveMs);
|
|
578
851
|
if (binding && nextUrl.origin !== binding.origin) {
|
|
579
852
|
for (const name of [...headers.keys()]) headers.delete(name);
|
|
580
853
|
} else if (nextUrl.origin !== base.origin) {
|
|
@@ -722,8 +995,8 @@ function timeoutConfigField(opts) {
|
|
|
722
995
|
key: "timeoutMs",
|
|
723
996
|
label: { en: "Timeout (ms)", de: "Zeitlimit (ms)" },
|
|
724
997
|
description: {
|
|
725
|
-
en: "How long one attempt may
|
|
726
|
-
de: "Wie lange ein einzelner Versuch
|
|
998
|
+
en: "How long one attempt may wait for an answer to start, redirects included, and how long the answer may then go silent before it fails as a timeout. An answer that keeps arriving is not cut off. Each retry gets the time again.",
|
|
999
|
+
de: "Wie lange ein einzelner Versuch auf den Beginn einer Antwort warten darf, Weiterleitungen eingeschlossen, und wie lange die Antwort danach stocken darf, bevor sie als Zeit\xFCberschreitung fehlschl\xE4gt. Eine Antwort, die weiter eintrifft, wird nicht abgebrochen. Jede Wiederholung bekommt die Zeit erneut."
|
|
727
1000
|
},
|
|
728
1001
|
type: "number",
|
|
729
1002
|
default: opts?.default ?? DEFAULT_TIMEOUT_MS,
|
|
@@ -1094,6 +1367,7 @@ export {
|
|
|
1094
1367
|
ApiKeyCredential,
|
|
1095
1368
|
BaseCredential,
|
|
1096
1369
|
BasicAuthCredential,
|
|
1370
|
+
CONFIG_KEY_PATTERN,
|
|
1097
1371
|
DEFAULT_MAX_RESPONSE_BYTES,
|
|
1098
1372
|
DEFAULT_RETRY_ATTEMPTS,
|
|
1099
1373
|
DEFAULT_RETRY_DELAY_MS,
|
|
@@ -1104,13 +1378,20 @@ export {
|
|
|
1104
1378
|
MAX_RESPONSE_BYTES,
|
|
1105
1379
|
MAX_RETRY_ATTEMPTS,
|
|
1106
1380
|
MAX_TIMEOUT_MS,
|
|
1381
|
+
NAMESPACED_SLUG_PATTERN,
|
|
1107
1382
|
NodeError,
|
|
1108
1383
|
OAuth2AuthCodeCredential,
|
|
1109
1384
|
OAuth2ClientCredentialsCredential,
|
|
1110
1385
|
OPERATORS,
|
|
1111
1386
|
RetryableError,
|
|
1387
|
+
SEMVER_PATTERN,
|
|
1388
|
+
SLUG_PATTERN,
|
|
1112
1389
|
SimpleValueCredential,
|
|
1113
1390
|
VALUELESS_OPERATORS,
|
|
1391
|
+
VPN_ERROR_CODES,
|
|
1392
|
+
VPN_ROUTE_MISSING,
|
|
1393
|
+
VPN_UNAVAILABLE,
|
|
1394
|
+
allowedTargets,
|
|
1114
1395
|
assertBranchNamed,
|
|
1115
1396
|
assertPublicUrl,
|
|
1116
1397
|
backoffDelay,
|
|
@@ -1123,14 +1404,20 @@ export {
|
|
|
1123
1404
|
extractCredentialManifests,
|
|
1124
1405
|
extractManifest,
|
|
1125
1406
|
extractManifests,
|
|
1407
|
+
guardRelaxedForLocalDev,
|
|
1408
|
+
guardedLookup,
|
|
1126
1409
|
headerCrossesOrigin,
|
|
1127
1410
|
httpErrorResult,
|
|
1411
|
+
installAllowedTargets,
|
|
1412
|
+
isAllowedTarget,
|
|
1128
1413
|
isBlockedAddress,
|
|
1129
1414
|
isMultiPort,
|
|
1130
1415
|
isNodeWithIteration,
|
|
1131
1416
|
isOAuthAuthorizeCredential,
|
|
1132
1417
|
isOperator,
|
|
1418
|
+
isVpnErrorCode,
|
|
1133
1419
|
maxBytesConfigField,
|
|
1420
|
+
nodeManifestProblems,
|
|
1134
1421
|
normalizeCredentialType,
|
|
1135
1422
|
normalizeLocalized,
|
|
1136
1423
|
outputProblems,
|
package/dist/manifest.d.ts
CHANGED
|
@@ -73,6 +73,31 @@ export declare function packageIconProblem(icon: string | undefined): string | u
|
|
|
73
73
|
* enforces that on upload; this is a typed, lenient read for tooling.
|
|
74
74
|
*/
|
|
75
75
|
export declare function parsePackageMeta(raw: unknown): NodePackageMeta;
|
|
76
|
+
/**
|
|
77
|
+
* The name patterns the registry enforces on a node's manifest when a package is
|
|
78
|
+
* uploaded (PO-744), as written in the integrations service's node schema
|
|
79
|
+
* (`app/Support/Schema/Versions/Node/V0DraftSchema.php`, read at `973b28c2`, 27
|
|
80
|
+
* October 2026), under the same names. A copy, not a shared source: a change on
|
|
81
|
+
* either side has to be made on both. They are exported as the schema's text, so
|
|
82
|
+
* a test on the other side can compare them byte for byte.
|
|
83
|
+
*/
|
|
84
|
+
/** A port name, `sourceFromConfig` and `fallback.name`: lowercase kebab-case. */
|
|
85
|
+
export declare const SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}$";
|
|
86
|
+
/** A node's `slug`, and a credential type a setting names: `<namespace>:<slug>`. */
|
|
87
|
+
export declare const NAMESPACED_SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}:[a-z][a-z0-9-]{0,62}$";
|
|
88
|
+
/** A node's `version`. */
|
|
89
|
+
export declare const SEMVER_PATTERN = "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$";
|
|
90
|
+
/** A setting's `key`, `dependsOn[]` and `showIf.key`: camelCase is allowed here, unlike in a port name. */
|
|
91
|
+
export declare const CONFIG_KEY_PATTERN = "^[a-zA-Z][a-zA-Z0-9-]*$";
|
|
92
|
+
/**
|
|
93
|
+
* Why the registry would refuse these nodes' names on upload, one line per value,
|
|
94
|
+
* or `[]` when it would accept them (PO-744). Each line names the node, the path
|
|
95
|
+
* of the field, the value and the pattern. Only values that are present are
|
|
96
|
+
* judged: a missing slug or version is the schema's other rule, and a port may
|
|
97
|
+
* leave its name out. Settings are walked into `properties` and `items` at any
|
|
98
|
+
* depth; the schema allows less, so none is missed.
|
|
99
|
+
*/
|
|
100
|
+
export declare function nodeManifestProblems(nodes: readonly INodeDescription[]): string[];
|
|
76
101
|
/**
|
|
77
102
|
* Builds the manifest envelope from a package's `NODES` (and optional
|
|
78
103
|
* `CREDENTIALS` / `TEMPLATES`) exports. The result is what gets written to
|
package/dist/ssrf.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type LookupFunction } from 'node:net';
|
|
2
|
+
import { Agent } from 'undici';
|
|
1
3
|
import { NodeError } from './errors.js';
|
|
2
4
|
/**
|
|
3
5
|
* A resolved DNS address, mirroring the shape of Node's `dns.LookupAddress`.
|
|
@@ -39,6 +41,51 @@ export declare const ssrfResolver: {
|
|
|
39
41
|
* Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
|
|
40
42
|
*/
|
|
41
43
|
export declare function isBlockedAddress(ip: string): boolean;
|
|
44
|
+
export declare function guardRelaxedForLocalDev(): boolean;
|
|
45
|
+
/** How a connection that `safeFetch` does not make reports what {@link guardedLookup} decides. */
|
|
46
|
+
export interface GuardedLookupOptions {
|
|
47
|
+
/**
|
|
48
|
+
* The error a refused name fails with. It is handed to the caller and may reach
|
|
49
|
+
* a workflow author, so it names the host and never an address it resolved to.
|
|
50
|
+
* Default: `Blocked <noun> to <host>: resolves to a private or reserved
|
|
51
|
+
* address`, code `BLOCKED_ADDRESS` — `safeFetch`'s words with this lookup's noun.
|
|
52
|
+
*/
|
|
53
|
+
refusal?: (host: string) => Error;
|
|
54
|
+
/**
|
|
55
|
+
* For a client a VPN route cannot carry: a private address on the run's
|
|
56
|
+
* allow-list is refused with this error instead of passing. Absent, the
|
|
57
|
+
* allow-list applies as it does to `safeFetch`.
|
|
58
|
+
*/
|
|
59
|
+
unroutable?: (host: string) => Error;
|
|
60
|
+
/** The noun of the operator's log line, `[ssrf] blocked <noun> to …`, and of the default refusal. Default `'connection'`. */
|
|
61
|
+
noun?: 'request' | 'connection';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A socket lookup, shaped like `net.LookupFunction`, that judges every address a
|
|
65
|
+
* name resolves to by the rule `safeFetch` connects under (PO-698) — for a client
|
|
66
|
+
* that opens its own socket (FTP, SFTP, a plain `node:http` stream) and so never
|
|
67
|
+
* meets `safeFetch`. Pass it as the socket's `lookup` with the port it connects to.
|
|
68
|
+
*
|
|
69
|
+
* The check runs inside the socket's own name resolution, so the socket can only
|
|
70
|
+
* reach addresses that were judged, with no second lookup a changed DNS answer
|
|
71
|
+
* could slip through, and Node still tries every approved address in turn. A
|
|
72
|
+
* name is refused when any address is private or reserved and not on the run's
|
|
73
|
+
* allow-list for `port` (PO-828); the refusal is logged for the operator; the
|
|
74
|
+
* local-development switch ({@link guardRelaxedForLocalDev}) relaxes it. A name
|
|
75
|
+
* that does not resolve fails as the resolver said, not as a refusal.
|
|
76
|
+
*
|
|
77
|
+
* **Node calls no lookup for a host written as an address**, so a literal never
|
|
78
|
+
* reaches this. A caller judges a literal itself, with `isBlockedAddress` and
|
|
79
|
+
* `isAllowedTarget`, before it opens the socket.
|
|
80
|
+
*/
|
|
81
|
+
export declare function guardedLookup(port: number, options?: GuardedLookupOptions): LookupFunction;
|
|
82
|
+
/**
|
|
83
|
+
* The dispatcher `safeFetch` hands every request to. One per process, so
|
|
84
|
+
* keep-alive survives across calls; its pool holds only connections a `safeFetch`
|
|
85
|
+
* call opened. Deliberately **not** re-exported from `index.ts`, for the reason
|
|
86
|
+
* {@link guardConnectionsTo} is not.
|
|
87
|
+
*/
|
|
88
|
+
export declare function guardedDispatcher(): Agent;
|
|
42
89
|
/**
|
|
43
90
|
* Put `url`'s host **and port** under the connect-time guard and return the
|
|
44
91
|
* release for it. Ref-counted, so concurrent calls to one target do not release
|
|
@@ -48,8 +95,8 @@ export declare function isBlockedAddress(ip: string): boolean;
|
|
|
48
95
|
*
|
|
49
96
|
* The window is the call, not the connection. A connect that completes after the
|
|
50
97
|
* release — the fetch it belonged to having already timed out or aborted — finds
|
|
51
|
-
* its target unregistered and is not judged;
|
|
52
|
-
*
|
|
98
|
+
* its target unregistered and is not judged here; the address it connected to was
|
|
99
|
+
* judged before the socket opened, by {@link guardedDispatcher}.
|
|
53
100
|
*
|
|
54
101
|
* Deliberately **not** re-exported from `index.ts`. The guard is engaged by
|
|
55
102
|
* `safeFetch`, which is the one sanctioned way out to the network (PO-185); an
|
|
@@ -78,6 +125,11 @@ export declare function connectionRefusal(err: unknown): NodeError | undefined;
|
|
|
78
125
|
* actually reached (PO-184). A caller that opens its own request after
|
|
79
126
|
* `assertPublicUrl` gets the check and not that second half.
|
|
80
127
|
*
|
|
128
|
+
* A private address passes only where exactly that address **and** the URL's
|
|
129
|
+
* effective port are on the run's allow-list (PO-828, see `isAllowedTarget`) —
|
|
130
|
+
* every other answer still decides as before, so one allowed address among
|
|
131
|
+
* private ones does not carry the rest.
|
|
132
|
+
*
|
|
81
133
|
* Pass `signal` (the per-request timeout/cancellation budget) so a hung or
|
|
82
134
|
* hostile DNS resolve cannot block the guard past that budget — see
|
|
83
135
|
* {@link resolveHost}.
|