@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/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-XO73F62E.js";
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 = ipaddr.parseCIDR("2000::/3");
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 ipaddr.fromByteArray(addr.toByteArray().slice(12));
353
+ return ipaddr2.fromByteArray(addr.toByteArray().slice(12));
228
354
  }
229
355
  function isBlockedAddress(ip) {
230
356
  let addr;
231
357
  try {
232
- addr = ipaddr.parse(ip);
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 fetch targets. Intended for local development only."
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
- console.warn(`[ssrf] blocked request to ${host}: resolves to private/reserved address ${address}`);
261
- return new NodeError("BLOCKED_ADDRESS", `Blocked request to ${host}: resolves to a private or reserved address`, {
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
- if (isIP(host) !== 0) {
373
- if (isBlockedAddress(host)) throw blockedError(host, host);
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 (isBlockedAddress(a.address)) throw blockedError(host, a.address);
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
- async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
689
+ function attemptDeadline(ctxSignal, effectiveMs) {
478
690
  const ac = new AbortController();
479
- const timer = setTimeout(() => ac.abort(), effectiveMs);
480
- const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
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
- if (ctxSignal?.aborted) throw ctxSignal.reason;
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 (ac.signal.aborted) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
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, effectiveMs);
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 take before it fails as a timeout. Counted per attempt and per redirect hop, not across the whole call.",
726
- de: "Wie lange ein einzelner Versuch dauern darf, bevor er als Zeit\xFCberschreitung fehlschl\xE4gt. Gilt je Versuch und je Weiterleitung, nicht f\xFCr den gesamten Aufruf."
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,
@@ -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; see the gap in
52
- * `specs/ssrf-guard.md`.
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}.