@revenexx/integrations-node-sdk 1.1.0 → 1.2.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/README.md +5 -4
- package/dist/index.cjs +118 -60
- package/dist/index.d.cts +24 -6
- package/dist/index.d.ts +24 -6
- package/dist/index.js +118 -60
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -4,9 +4,10 @@ The shared TypeScript contract library for **Revenexx integration nodes**. It
|
|
|
4
4
|
defines the interfaces every integration node and credential must implement, and
|
|
5
5
|
ships the small helper toolchain the node registry uses to build manifests.
|
|
6
6
|
|
|
7
|
-
It
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
It carries **one runtime dependency** — `ipaddr.js`, which the SSRF guard defers
|
|
8
|
+
its address classification to (PO-183) — and no business logic beyond the manifest
|
|
9
|
+
helpers: it exists purely to share one contract between the node packages and the
|
|
10
|
+
workflow engine that runs them.
|
|
10
11
|
|
|
11
12
|
```
|
|
12
13
|
integrations-node-sdk ← this package (types & helpers only)
|
|
@@ -34,7 +35,7 @@ configuration or auth token needed.
|
|
|
34
35
|
| `credentialType` | `normalizeCredentialType` — normalise a credential-type reference (`string \| string[] \| undefined`) to `string[]`. |
|
|
35
36
|
| `errors` | `NodeError` — typed error class for unexpected/system-level failures thrown inside `execute`. |
|
|
36
37
|
| `fetch` | `safeFetch` and the `read*` body helpers — timeout, retry, response size-cap, unified error form, and an always-on **SSRF guard** (blocks private/loopback/link-local/metadata targets, re-checked on every redirect hop). Prefer it over raw `fetch`. See [`docs/overview.md`](docs/overview.md#safefetch). |
|
|
37
|
-
| `ssrf` | `assertPublicUrl` / `isBlockedAddress` — the SSRF guard that backs `safeFetch`, exported for reuse.
|
|
38
|
+
| `ssrf` | `assertPublicUrl` / `isBlockedAddress` — the SSRF guard that backs `safeFetch`, exported for reuse. An address is refused unless it is public unicast — `ipaddr.js` classifies the range, and for IPv6 the address must also sit inside IANA's allocated `2000::/3`, since that classification's *default* verdict is `unicast`. So reserved and unallocated bands nobody enumerated are refused too (PO-183). These two are the pre-flight check; `safeFetch` additionally judges the address each connection lands on, so a DNS answer that changes between check and connect gets no request (PO-184). Errors surface as `NodeError('BLOCKED_ADDRESS')`. Set `RVNXX_SSRF_ALLOW_PRIVATE=1` (off by default; local dev only) to reach `localhost`/internal targets while testing — see [`docs/overview.md`](docs/overview.md#ssrf-guard). |
|
|
38
39
|
| `extract` | `extractManifest` / `extractManifests` (nodes) and `extractCredentialManifest` / `extractCredentialManifests` (credentials) — pull descriptions off instances without executing them. |
|
|
39
40
|
| `manifest` | `buildManifest` / `MANIFEST_VERSION` — wrap node, credential and template descriptions in the `{ manifestVersion, nodes, credentials, templates }` envelope the registry expects. |
|
|
40
41
|
|
package/dist/index.cjs
CHANGED
|
@@ -218,7 +218,9 @@ function extractCredentialManifests(credentials) {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
// src/ssrf.ts
|
|
221
|
+
var import_node_diagnostics_channel = require("diagnostics_channel");
|
|
221
222
|
var import_node_net = require("net");
|
|
223
|
+
var import_ipaddr = __toESM(require("ipaddr.js"), 1);
|
|
222
224
|
var ssrfResolver = {
|
|
223
225
|
lookup: async (hostname) => {
|
|
224
226
|
const { lookup } = await import("dns/promises");
|
|
@@ -226,66 +228,27 @@ var ssrfResolver = {
|
|
|
226
228
|
return results.map((r) => ({ address: r.address, family: r.family }));
|
|
227
229
|
}
|
|
228
230
|
};
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
let s = input;
|
|
238
|
-
const zone = s.indexOf("%");
|
|
239
|
-
if (zone !== -1) s = s.slice(0, zone);
|
|
240
|
-
if (s.includes(".")) {
|
|
241
|
-
const idx = s.lastIndexOf(":");
|
|
242
|
-
if (idx === -1) return null;
|
|
243
|
-
const v4 = parseIpv4(s.slice(idx + 1));
|
|
244
|
-
if (!v4) return null;
|
|
245
|
-
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
246
|
-
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
247
|
-
s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
|
|
248
|
-
}
|
|
249
|
-
const halves = s.split("::");
|
|
250
|
-
if (halves.length > 2) return null;
|
|
251
|
-
const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
|
|
252
|
-
const head = parseGroups(halves[0] ?? "");
|
|
253
|
-
const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
|
|
254
|
-
const declared = [...head, ...back ?? []];
|
|
255
|
-
if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
|
|
256
|
-
let hextets;
|
|
257
|
-
if (back === null) {
|
|
258
|
-
hextets = head;
|
|
259
|
-
} else {
|
|
260
|
-
const zeros = 8 - (head.length + back.length);
|
|
261
|
-
if (zeros < 1) return null;
|
|
262
|
-
hextets = [...head, ...new Array(zeros).fill(0), ...back];
|
|
263
|
-
}
|
|
264
|
-
return hextets.length === 8 ? hextets : null;
|
|
265
|
-
}
|
|
266
|
-
function isBlockedIpv4(o) {
|
|
267
|
-
const [a, b] = o;
|
|
268
|
-
return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
|
|
269
|
-
a === 127 || // 127.0.0.0/8 loopback
|
|
270
|
-
a === 10 || // 10.0.0.0/8 private
|
|
271
|
-
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
|
|
272
|
-
a === 192 && b === 168 || // 192.168.0.0/16 private
|
|
273
|
-
a === 169 && b === 254;
|
|
231
|
+
function isIPv6(addr) {
|
|
232
|
+
return addr.kind() === "ipv6";
|
|
233
|
+
}
|
|
234
|
+
var PUBLIC_RANGE = "unicast";
|
|
235
|
+
var GLOBAL_UNICAST_V6 = import_ipaddr.default.parseCIDR("2000::/3");
|
|
236
|
+
var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
|
|
237
|
+
function embeddedIpv4(addr) {
|
|
238
|
+
return import_ipaddr.default.fromByteArray(addr.toByteArray().slice(12));
|
|
274
239
|
}
|
|
275
240
|
function isBlockedAddress(ip) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if ((h[0] & 65472) === 65152) return true;
|
|
288
|
-
return false;
|
|
241
|
+
let addr;
|
|
242
|
+
try {
|
|
243
|
+
addr = import_ipaddr.default.parse(ip);
|
|
244
|
+
} catch {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (isIPv6(addr)) {
|
|
248
|
+
if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
|
|
249
|
+
if (!addr.match(GLOBAL_UNICAST_V6)) return true;
|
|
250
|
+
}
|
|
251
|
+
return addr.range() !== PUBLIC_RANGE;
|
|
289
252
|
}
|
|
290
253
|
var bypassNoticeLogged = false;
|
|
291
254
|
function guardRelaxedForLocalDev() {
|
|
@@ -310,6 +273,82 @@ function blockedError(host, address) {
|
|
|
310
273
|
status: 0
|
|
311
274
|
});
|
|
312
275
|
}
|
|
276
|
+
var guardedTargets = /* @__PURE__ */ new Map();
|
|
277
|
+
var connectGuardInstalled = false;
|
|
278
|
+
function normalizeHost(hostname) {
|
|
279
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
280
|
+
return host.toLowerCase();
|
|
281
|
+
}
|
|
282
|
+
function targetKey(hostname, port, protocol) {
|
|
283
|
+
const host = normalizeHost(hostname);
|
|
284
|
+
const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
|
|
285
|
+
return `${host}:${effective}`;
|
|
286
|
+
}
|
|
287
|
+
var unreadableMessageLogged = false;
|
|
288
|
+
function reportUnreadableMessage() {
|
|
289
|
+
if (unreadableMessageLogged) return;
|
|
290
|
+
unreadableMessageLogged = true;
|
|
291
|
+
console.warn(
|
|
292
|
+
"[ssrf] undici:client:connected published a message this guard cannot read: the connect-time half of the SSRF guard is not judging connections. The pre-flight check still applies."
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
function connectBlockedError(host, address) {
|
|
296
|
+
if (address == null) {
|
|
297
|
+
console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
|
|
298
|
+
return new NodeError(
|
|
299
|
+
"BLOCKED_ADDRESS",
|
|
300
|
+
`Blocked request to ${host}: the address the connection landed on could not be read`,
|
|
301
|
+
{ status: 0 }
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (host === address) {
|
|
305
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
|
|
306
|
+
status: 0
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
|
|
310
|
+
return new NodeError(
|
|
311
|
+
"BLOCKED_ADDRESS",
|
|
312
|
+
`Blocked request to ${host}: the connection landed on a private or reserved address`,
|
|
313
|
+
{ status: 0 }
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
function judgeConnection(message) {
|
|
317
|
+
const { connectParams, socket } = message ?? {};
|
|
318
|
+
const hostname = connectParams?.hostname;
|
|
319
|
+
const port = connectParams?.port;
|
|
320
|
+
const protocol = connectParams?.protocol;
|
|
321
|
+
if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
|
|
322
|
+
reportUnreadableMessage();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
|
|
326
|
+
if (guardRelaxedForLocalDev()) return;
|
|
327
|
+
const peer = socket.remoteAddress;
|
|
328
|
+
if (peer != null && !isBlockedAddress(peer)) return;
|
|
329
|
+
socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
|
|
330
|
+
}
|
|
331
|
+
function guardConnectionsTo(url) {
|
|
332
|
+
if (!connectGuardInstalled) {
|
|
333
|
+
connectGuardInstalled = true;
|
|
334
|
+
(0, import_node_diagnostics_channel.subscribe)("undici:client:connected", judgeConnection);
|
|
335
|
+
}
|
|
336
|
+
const key = targetKey(url.hostname, url.port, url.protocol);
|
|
337
|
+
guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
|
|
338
|
+
let released = false;
|
|
339
|
+
return () => {
|
|
340
|
+
if (released) return;
|
|
341
|
+
released = true;
|
|
342
|
+
const count = (guardedTargets.get(key) ?? 1) - 1;
|
|
343
|
+
if (count > 0) guardedTargets.set(key, count);
|
|
344
|
+
else guardedTargets.delete(key);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function connectionRefusal(err) {
|
|
348
|
+
const cause = err?.cause;
|
|
349
|
+
if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
|
|
350
|
+
return void 0;
|
|
351
|
+
}
|
|
313
352
|
async function resolveHost(lookup, host, signal) {
|
|
314
353
|
if (!signal) return lookup(host);
|
|
315
354
|
if (signal.aborted) throw signal.reason;
|
|
@@ -376,6 +415,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
|
376
415
|
try {
|
|
377
416
|
return await fetch(url, { ...fetchOptions, signal });
|
|
378
417
|
} catch (err) {
|
|
418
|
+
const blocked = connectionRefusal(err);
|
|
419
|
+
if (blocked) throw blocked;
|
|
379
420
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
380
421
|
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
|
|
381
422
|
throw err;
|
|
@@ -402,7 +443,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
|
402
443
|
let currentInit = { ...fetchOptions, redirect: "manual" };
|
|
403
444
|
await guardUrl(currentUrl, ctxSignal, effectiveMs);
|
|
404
445
|
for (let hop = 0; ; hop++) {
|
|
405
|
-
const
|
|
446
|
+
const release = guardConnectionsTo(
|
|
447
|
+
currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
|
|
448
|
+
);
|
|
449
|
+
let res;
|
|
450
|
+
try {
|
|
451
|
+
res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
|
|
452
|
+
} finally {
|
|
453
|
+
release();
|
|
454
|
+
}
|
|
406
455
|
const location = res.headers.get("location");
|
|
407
456
|
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
408
457
|
const redirectStatus = res.status;
|
|
@@ -486,6 +535,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
|
|
|
486
535
|
const cap = clampResponseBytes(maxBytes);
|
|
487
536
|
const declared = Number(res.headers.get("content-length"));
|
|
488
537
|
if (Number.isFinite(declared) && declared > cap) {
|
|
538
|
+
await res.body?.cancel().catch(() => {
|
|
539
|
+
});
|
|
489
540
|
throw tooLargeError(res.status, declared, cap);
|
|
490
541
|
}
|
|
491
542
|
const body = res.body;
|
|
@@ -694,6 +745,7 @@ function expiryFromExpiresIn(expiresIn) {
|
|
|
694
745
|
}
|
|
695
746
|
return new Date(Date.now() + seconds * 1e3).toISOString();
|
|
696
747
|
}
|
|
748
|
+
var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
|
|
697
749
|
var BaseCredential = class {
|
|
698
750
|
async test(_ctx, config) {
|
|
699
751
|
for (const field of this.description.fields) {
|
|
@@ -721,6 +773,12 @@ var BaseCredential = class {
|
|
|
721
773
|
* Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
|
|
722
774
|
* per the redirect rules, not a quirk of ours. Real token endpoints do not
|
|
723
775
|
* redirect; one that does was never going to complete the exchange anyway.
|
|
776
|
+
*
|
|
777
|
+
* The third protection is the caller's own: safeFetch hands back a `Response`
|
|
778
|
+
* and reading it is not its job, so the answer goes through {@link readText}
|
|
779
|
+
* under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
|
|
780
|
+
* Both branches below read that same string, so the cap covers the refusal as
|
|
781
|
+
* well as the token — an error body is the less predictable of the two.
|
|
724
782
|
*/
|
|
725
783
|
async postForm(ctx, url, form, headers = {}) {
|
|
726
784
|
const body = new URLSearchParams();
|
|
@@ -735,7 +793,7 @@ var BaseCredential = class {
|
|
|
735
793
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
|
|
736
794
|
body
|
|
737
795
|
});
|
|
738
|
-
const text = await res
|
|
796
|
+
const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
739
797
|
if (!res.ok) {
|
|
740
798
|
throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
|
|
741
799
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -694,7 +694,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
|
|
|
694
694
|
* The `Content-Length` header is used as a fast-reject (bail before downloading
|
|
695
695
|
* anything), but the limit is *also* enforced while streaming, since the header
|
|
696
696
|
* can be absent or lie. On overrun the stream is cancelled and a
|
|
697
|
-
* `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
|
|
697
|
+
* `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
|
|
698
|
+
* cancel the body first, so neither leaves the connection held.
|
|
698
699
|
*/
|
|
699
700
|
declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
|
|
700
701
|
/** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
|
|
@@ -738,10 +739,19 @@ interface LookupAddress {
|
|
|
738
739
|
type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
|
|
739
740
|
/**
|
|
740
741
|
* Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
|
|
741
|
-
* loopback, link-local
|
|
742
|
-
*
|
|
743
|
-
*
|
|
742
|
+
* loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
|
|
743
|
+
* unallocated or otherwise non-public target that a server-side fetch must never be
|
|
744
|
+
* steered to.
|
|
745
|
+
*
|
|
746
|
+
* The verdict is an allow-list: the address is refused unless the classification
|
|
747
|
+
* calls its range public unicast and — for IPv6, where that verdict is a default
|
|
748
|
+
* rather than a determination — it also sits inside the block IANA has allocated
|
|
749
|
+
* (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
|
|
750
|
+
* unwrapped and the embedded address judged in its place, so a public host stays
|
|
751
|
+
* reachable when it is written that way. An address we cannot parse is treated as
|
|
744
752
|
* blocked (fail-closed).
|
|
753
|
+
*
|
|
754
|
+
* Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
|
|
745
755
|
*/
|
|
746
756
|
declare function isBlockedAddress(ip: string): boolean;
|
|
747
757
|
/**
|
|
@@ -752,8 +762,10 @@ declare function isBlockedAddress(ip: string): boolean;
|
|
|
752
762
|
* rejects if **any** resolved address is private/reserved. Throws
|
|
753
763
|
* `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
|
|
754
764
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
765
|
+
* This is the pre-flight half of the guard: the connection that follows resolves
|
|
766
|
+
* the name again on its own, so `safeFetch` also judges the address the socket
|
|
767
|
+
* actually reached (PO-184). A caller that opens its own request after
|
|
768
|
+
* `assertPublicUrl` gets the check and not that second half.
|
|
757
769
|
*
|
|
758
770
|
* Pass `signal` (the per-request timeout/cancellation budget) so a hung or
|
|
759
771
|
* hostile DNS resolve cannot block the guard past that budget — see
|
|
@@ -952,6 +964,12 @@ declare abstract class BaseCredential implements ICredential {
|
|
|
952
964
|
* Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
|
|
953
965
|
* per the redirect rules, not a quirk of ours. Real token endpoints do not
|
|
954
966
|
* redirect; one that does was never going to complete the exchange anyway.
|
|
967
|
+
*
|
|
968
|
+
* The third protection is the caller's own: safeFetch hands back a `Response`
|
|
969
|
+
* and reading it is not its job, so the answer goes through {@link readText}
|
|
970
|
+
* under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
|
|
971
|
+
* Both branches below read that same string, so the cap covers the refusal as
|
|
972
|
+
* well as the token — an error body is the less predictable of the two.
|
|
955
973
|
*/
|
|
956
974
|
protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
|
|
957
975
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -694,7 +694,8 @@ declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promi
|
|
|
694
694
|
* The `Content-Length` header is used as a fast-reject (bail before downloading
|
|
695
695
|
* anything), but the limit is *also* enforced while streaming, since the header
|
|
696
696
|
* can be absent or lie. On overrun the stream is cancelled and a
|
|
697
|
-
* `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown.
|
|
697
|
+
* `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
|
|
698
|
+
* cancel the body first, so neither leaves the connection held.
|
|
698
699
|
*/
|
|
699
700
|
declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
|
|
700
701
|
/** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
|
|
@@ -738,10 +739,19 @@ interface LookupAddress {
|
|
|
738
739
|
type LookupFn = (hostname: string) => Promise<LookupAddress[]>;
|
|
739
740
|
/**
|
|
740
741
|
* Return `true` when `ip` (a literal IPv4/IPv6 address) points at a private,
|
|
741
|
-
* loopback, link-local
|
|
742
|
-
*
|
|
743
|
-
*
|
|
742
|
+
* loopback, link-local, carrier-grade NAT, multicast, broadcast, reserved,
|
|
743
|
+
* unallocated or otherwise non-public target that a server-side fetch must never be
|
|
744
|
+
* steered to.
|
|
745
|
+
*
|
|
746
|
+
* The verdict is an allow-list: the address is refused unless the classification
|
|
747
|
+
* calls its range public unicast and — for IPv6, where that verdict is a default
|
|
748
|
+
* rather than a determination — it also sits inside the block IANA has allocated
|
|
749
|
+
* (see {@link GLOBAL_UNICAST_V6}). The IPv6 forms that embed an IPv4 address are
|
|
750
|
+
* unwrapped and the embedded address judged in its place, so a public host stays
|
|
751
|
+
* reachable when it is written that way. An address we cannot parse is treated as
|
|
744
752
|
* blocked (fail-closed).
|
|
753
|
+
*
|
|
754
|
+
* Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
|
|
745
755
|
*/
|
|
746
756
|
declare function isBlockedAddress(ip: string): boolean;
|
|
747
757
|
/**
|
|
@@ -752,8 +762,10 @@ declare function isBlockedAddress(ip: string): boolean;
|
|
|
752
762
|
* rejects if **any** resolved address is private/reserved. Throws
|
|
753
763
|
* `NodeError('BLOCKED_ADDRESS', …, { status: 0 })` on rejection.
|
|
754
764
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
765
|
+
* This is the pre-flight half of the guard: the connection that follows resolves
|
|
766
|
+
* the name again on its own, so `safeFetch` also judges the address the socket
|
|
767
|
+
* actually reached (PO-184). A caller that opens its own request after
|
|
768
|
+
* `assertPublicUrl` gets the check and not that second half.
|
|
757
769
|
*
|
|
758
770
|
* Pass `signal` (the per-request timeout/cancellation budget) so a hung or
|
|
759
771
|
* hostile DNS resolve cannot block the guard past that budget — see
|
|
@@ -952,6 +964,12 @@ declare abstract class BaseCredential implements ICredential {
|
|
|
952
964
|
* Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
|
|
953
965
|
* per the redirect rules, not a quirk of ours. Real token endpoints do not
|
|
954
966
|
* redirect; one that does was never going to complete the exchange anyway.
|
|
967
|
+
*
|
|
968
|
+
* The third protection is the caller's own: safeFetch hands back a `Response`
|
|
969
|
+
* and reading it is not its job, so the answer goes through {@link readText}
|
|
970
|
+
* under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
|
|
971
|
+
* Both branches below read that same string, so the cap covers the refusal as
|
|
972
|
+
* well as the token — an error body is the less predictable of the two.
|
|
955
973
|
*/
|
|
956
974
|
protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
|
|
957
975
|
}
|
package/dist/index.js
CHANGED
|
@@ -132,7 +132,9 @@ var NodeError = class extends Error {
|
|
|
132
132
|
};
|
|
133
133
|
|
|
134
134
|
// src/ssrf.ts
|
|
135
|
+
import { subscribe } from "diagnostics_channel";
|
|
135
136
|
import { isIP } from "net";
|
|
137
|
+
import ipaddr from "ipaddr.js";
|
|
136
138
|
var ssrfResolver = {
|
|
137
139
|
lookup: async (hostname) => {
|
|
138
140
|
const { lookup } = await import("dns/promises");
|
|
@@ -140,66 +142,27 @@ var ssrfResolver = {
|
|
|
140
142
|
return results.map((r) => ({ address: r.address, family: r.family }));
|
|
141
143
|
}
|
|
142
144
|
};
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
let s = input;
|
|
152
|
-
const zone = s.indexOf("%");
|
|
153
|
-
if (zone !== -1) s = s.slice(0, zone);
|
|
154
|
-
if (s.includes(".")) {
|
|
155
|
-
const idx = s.lastIndexOf(":");
|
|
156
|
-
if (idx === -1) return null;
|
|
157
|
-
const v4 = parseIpv4(s.slice(idx + 1));
|
|
158
|
-
if (!v4) return null;
|
|
159
|
-
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
160
|
-
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
161
|
-
s = `${s.slice(0, idx + 1)}${hi}:${lo}`;
|
|
162
|
-
}
|
|
163
|
-
const halves = s.split("::");
|
|
164
|
-
if (halves.length > 2) return null;
|
|
165
|
-
const parseGroups = (part) => part === "" ? [] : part.split(":").map((h) => /^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : Number.NaN);
|
|
166
|
-
const head = parseGroups(halves[0] ?? "");
|
|
167
|
-
const back = halves.length === 2 ? parseGroups(halves[1] ?? "") : null;
|
|
168
|
-
const declared = [...head, ...back ?? []];
|
|
169
|
-
if (declared.some((h) => !Number.isInteger(h) || h < 0 || h > 65535)) return null;
|
|
170
|
-
let hextets;
|
|
171
|
-
if (back === null) {
|
|
172
|
-
hextets = head;
|
|
173
|
-
} else {
|
|
174
|
-
const zeros = 8 - (head.length + back.length);
|
|
175
|
-
if (zeros < 1) return null;
|
|
176
|
-
hextets = [...head, ...new Array(zeros).fill(0), ...back];
|
|
177
|
-
}
|
|
178
|
-
return hextets.length === 8 ? hextets : null;
|
|
179
|
-
}
|
|
180
|
-
function isBlockedIpv4(o) {
|
|
181
|
-
const [a, b] = o;
|
|
182
|
-
return a === 0 || // 0.0.0.0/8 "this network" (incl. 0.0.0.0)
|
|
183
|
-
a === 127 || // 127.0.0.0/8 loopback
|
|
184
|
-
a === 10 || // 10.0.0.0/8 private
|
|
185
|
-
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
|
|
186
|
-
a === 192 && b === 168 || // 192.168.0.0/16 private
|
|
187
|
-
a === 169 && b === 254;
|
|
145
|
+
function isIPv6(addr) {
|
|
146
|
+
return addr.kind() === "ipv6";
|
|
147
|
+
}
|
|
148
|
+
var PUBLIC_RANGE = "unicast";
|
|
149
|
+
var GLOBAL_UNICAST_V6 = ipaddr.parseCIDR("2000::/3");
|
|
150
|
+
var V4_EMBEDDING_RANGES = /* @__PURE__ */ new Set(["ipv4Mapped", "rfc6052"]);
|
|
151
|
+
function embeddedIpv4(addr) {
|
|
152
|
+
return ipaddr.fromByteArray(addr.toByteArray().slice(12));
|
|
188
153
|
}
|
|
189
154
|
function isBlockedAddress(ip) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if ((h[0] & 65472) === 65152) return true;
|
|
202
|
-
return false;
|
|
155
|
+
let addr;
|
|
156
|
+
try {
|
|
157
|
+
addr = ipaddr.parse(ip);
|
|
158
|
+
} catch {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (isIPv6(addr)) {
|
|
162
|
+
if (V4_EMBEDDING_RANGES.has(addr.range())) return embeddedIpv4(addr).range() !== PUBLIC_RANGE;
|
|
163
|
+
if (!addr.match(GLOBAL_UNICAST_V6)) return true;
|
|
164
|
+
}
|
|
165
|
+
return addr.range() !== PUBLIC_RANGE;
|
|
203
166
|
}
|
|
204
167
|
var bypassNoticeLogged = false;
|
|
205
168
|
function guardRelaxedForLocalDev() {
|
|
@@ -224,6 +187,82 @@ function blockedError(host, address) {
|
|
|
224
187
|
status: 0
|
|
225
188
|
});
|
|
226
189
|
}
|
|
190
|
+
var guardedTargets = /* @__PURE__ */ new Map();
|
|
191
|
+
var connectGuardInstalled = false;
|
|
192
|
+
function normalizeHost(hostname) {
|
|
193
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
194
|
+
return host.toLowerCase();
|
|
195
|
+
}
|
|
196
|
+
function targetKey(hostname, port, protocol) {
|
|
197
|
+
const host = normalizeHost(hostname);
|
|
198
|
+
const effective = port === "" ? protocol === "https:" ? "443" : "80" : port;
|
|
199
|
+
return `${host}:${effective}`;
|
|
200
|
+
}
|
|
201
|
+
var unreadableMessageLogged = false;
|
|
202
|
+
function reportUnreadableMessage() {
|
|
203
|
+
if (unreadableMessageLogged) return;
|
|
204
|
+
unreadableMessageLogged = true;
|
|
205
|
+
console.warn(
|
|
206
|
+
"[ssrf] undici:client:connected published a message this guard cannot read: the connect-time half of the SSRF guard is not judging connections. The pre-flight check still applies."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
function connectBlockedError(host, address) {
|
|
210
|
+
if (address == null) {
|
|
211
|
+
console.warn(`[ssrf] dropped connection to ${host}: the address it landed on could not be read`);
|
|
212
|
+
return new NodeError(
|
|
213
|
+
"BLOCKED_ADDRESS",
|
|
214
|
+
`Blocked request to ${host}: the address the connection landed on could not be read`,
|
|
215
|
+
{ status: 0 }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (host === address) {
|
|
219
|
+
return new NodeError("BLOCKED_ADDRESS", `Blocked connection to private or reserved address ${address}`, {
|
|
220
|
+
status: 0
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
console.warn(`[ssrf] dropped connection to ${host}: connected to private/reserved address ${address}`);
|
|
224
|
+
return new NodeError(
|
|
225
|
+
"BLOCKED_ADDRESS",
|
|
226
|
+
`Blocked request to ${host}: the connection landed on a private or reserved address`,
|
|
227
|
+
{ status: 0 }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
function judgeConnection(message) {
|
|
231
|
+
const { connectParams, socket } = message ?? {};
|
|
232
|
+
const hostname = connectParams?.hostname;
|
|
233
|
+
const port = connectParams?.port;
|
|
234
|
+
const protocol = connectParams?.protocol;
|
|
235
|
+
if (hostname == null || port == null || protocol == null || typeof socket?.destroy !== "function") {
|
|
236
|
+
reportUnreadableMessage();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!guardedTargets.has(targetKey(hostname, port, protocol))) return;
|
|
240
|
+
if (guardRelaxedForLocalDev()) return;
|
|
241
|
+
const peer = socket.remoteAddress;
|
|
242
|
+
if (peer != null && !isBlockedAddress(peer)) return;
|
|
243
|
+
socket.destroy(connectBlockedError(normalizeHost(hostname), peer));
|
|
244
|
+
}
|
|
245
|
+
function guardConnectionsTo(url) {
|
|
246
|
+
if (!connectGuardInstalled) {
|
|
247
|
+
connectGuardInstalled = true;
|
|
248
|
+
subscribe("undici:client:connected", judgeConnection);
|
|
249
|
+
}
|
|
250
|
+
const key = targetKey(url.hostname, url.port, url.protocol);
|
|
251
|
+
guardedTargets.set(key, (guardedTargets.get(key) ?? 0) + 1);
|
|
252
|
+
let released = false;
|
|
253
|
+
return () => {
|
|
254
|
+
if (released) return;
|
|
255
|
+
released = true;
|
|
256
|
+
const count = (guardedTargets.get(key) ?? 1) - 1;
|
|
257
|
+
if (count > 0) guardedTargets.set(key, count);
|
|
258
|
+
else guardedTargets.delete(key);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function connectionRefusal(err) {
|
|
262
|
+
const cause = err?.cause;
|
|
263
|
+
if (cause instanceof NodeError && cause.code === "BLOCKED_ADDRESS") return cause;
|
|
264
|
+
return void 0;
|
|
265
|
+
}
|
|
227
266
|
async function resolveHost(lookup, host, signal) {
|
|
228
267
|
if (!signal) return lookup(host);
|
|
229
268
|
if (signal.aborted) throw signal.reason;
|
|
@@ -290,6 +329,8 @@ async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
|
290
329
|
try {
|
|
291
330
|
return await fetch(url, { ...fetchOptions, signal });
|
|
292
331
|
} catch (err) {
|
|
332
|
+
const blocked = connectionRefusal(err);
|
|
333
|
+
if (blocked) throw blocked;
|
|
293
334
|
if (ctxSignal?.aborted) throw ctxSignal.reason;
|
|
294
335
|
if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
|
|
295
336
|
throw err;
|
|
@@ -316,7 +357,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
|
|
|
316
357
|
let currentInit = { ...fetchOptions, redirect: "manual" };
|
|
317
358
|
await guardUrl(currentUrl, ctxSignal, effectiveMs);
|
|
318
359
|
for (let hop = 0; ; hop++) {
|
|
319
|
-
const
|
|
360
|
+
const release = guardConnectionsTo(
|
|
361
|
+
currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
|
|
362
|
+
);
|
|
363
|
+
let res;
|
|
364
|
+
try {
|
|
365
|
+
res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
|
|
366
|
+
} finally {
|
|
367
|
+
release();
|
|
368
|
+
}
|
|
320
369
|
const location = res.headers.get("location");
|
|
321
370
|
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
322
371
|
const redirectStatus = res.status;
|
|
@@ -400,6 +449,8 @@ async function readArrayBuffer(res, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
|
|
|
400
449
|
const cap = clampResponseBytes(maxBytes);
|
|
401
450
|
const declared = Number(res.headers.get("content-length"));
|
|
402
451
|
if (Number.isFinite(declared) && declared > cap) {
|
|
452
|
+
await res.body?.cancel().catch(() => {
|
|
453
|
+
});
|
|
403
454
|
throw tooLargeError(res.status, declared, cap);
|
|
404
455
|
}
|
|
405
456
|
const body = res.body;
|
|
@@ -581,6 +632,7 @@ function expiryFromExpiresIn(expiresIn) {
|
|
|
581
632
|
}
|
|
582
633
|
return new Date(Date.now() + seconds * 1e3).toISOString();
|
|
583
634
|
}
|
|
635
|
+
var MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
|
|
584
636
|
var BaseCredential = class {
|
|
585
637
|
async test(_ctx, config) {
|
|
586
638
|
for (const field of this.description.fields) {
|
|
@@ -608,6 +660,12 @@ var BaseCredential = class {
|
|
|
608
660
|
* Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
|
|
609
661
|
* per the redirect rules, not a quirk of ours. Real token endpoints do not
|
|
610
662
|
* redirect; one that does was never going to complete the exchange anyway.
|
|
663
|
+
*
|
|
664
|
+
* The third protection is the caller's own: safeFetch hands back a `Response`
|
|
665
|
+
* and reading it is not its job, so the answer goes through {@link readText}
|
|
666
|
+
* under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
|
|
667
|
+
* Both branches below read that same string, so the cap covers the refusal as
|
|
668
|
+
* well as the token — an error body is the less predictable of the two.
|
|
611
669
|
*/
|
|
612
670
|
async postForm(ctx, url, form, headers = {}) {
|
|
613
671
|
const body = new URLSearchParams();
|
|
@@ -622,7 +680,7 @@ var BaseCredential = class {
|
|
|
622
680
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
|
|
623
681
|
body
|
|
624
682
|
});
|
|
625
|
-
const text = await res
|
|
683
|
+
const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
626
684
|
if (!res.ok) {
|
|
627
685
|
throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
|
|
628
686
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/integrations-node-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "TypeScript interfaces and utilities for Revenexx integration nodes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "revenexx GmbH",
|
|
@@ -61,5 +61,8 @@
|
|
|
61
61
|
"tsup": "^8.0.0",
|
|
62
62
|
"tsx": "^4.0.0",
|
|
63
63
|
"typescript": "^5.0.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"ipaddr.js": "2.5.0"
|
|
64
67
|
}
|
|
65
68
|
}
|