@juspay/neurolink 9.87.0 → 9.87.2
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +321 -321
- package/dist/lib/types/safeFetch.d.ts +9 -0
- package/dist/lib/utils/safeFetch.d.ts +3 -3
- package/dist/lib/utils/safeFetch.js +29 -11
- package/dist/lib/utils/ssrfGuard.d.ts +13 -5
- package/dist/lib/utils/ssrfGuard.js +48 -13
- package/dist/types/safeFetch.d.ts +9 -0
- package/dist/utils/safeFetch.d.ts +3 -3
- package/dist/utils/safeFetch.js +29 -11
- package/dist/utils/ssrfGuard.d.ts +13 -5
- package/dist/utils/ssrfGuard.js +48 -13
- package/package.json +1 -1
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runtime helper lives in `src/lib/utils/safeFetch.ts`.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* One validated address the pinned connect layer is allowed to dial.
|
|
8
|
+
* Produced by `ssrfGuard.ts:validateAndResolveUrl`, consumed by
|
|
9
|
+
* `safeFetch.ts:buildPinnedAgent`.
|
|
10
|
+
*/
|
|
11
|
+
export type PinnedAddress = {
|
|
12
|
+
ip: string;
|
|
13
|
+
family: 4 | 6;
|
|
14
|
+
};
|
|
6
15
|
export type SafeDownloadOptions = {
|
|
7
16
|
/** Hard cap on response size in bytes. Pass MAX_VIDEO_BYTES/MAX_AUDIO_BYTES/MAX_IMAGE_BYTES from sizeGuard. */
|
|
8
17
|
maxBytes: number;
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Combines:
|
|
5
5
|
* - `assertSafeUrl` (validates and rejects blocked IPs)
|
|
6
6
|
* - undici `Agent` with custom `connect.lookup` so the actual connection
|
|
7
|
-
*
|
|
8
|
-
* resolver returns a public IP for the guard but a private IP
|
|
9
|
-
* real request).
|
|
7
|
+
* only ever dials addresses we validated (closes the DNS-rebinding window
|
|
8
|
+
* where the resolver returns a public IP for the guard but a private IP
|
|
9
|
+
* for the real request).
|
|
10
10
|
* - `readBoundedBuffer` for size cap.
|
|
11
11
|
* - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
|
|
12
12
|
* the guard.
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Combines:
|
|
5
5
|
* - `assertSafeUrl` (validates and rejects blocked IPs)
|
|
6
6
|
* - undici `Agent` with custom `connect.lookup` so the actual connection
|
|
7
|
-
*
|
|
8
|
-
* resolver returns a public IP for the guard but a private IP
|
|
9
|
-
* real request).
|
|
7
|
+
* only ever dials addresses we validated (closes the DNS-rebinding window
|
|
8
|
+
* where the resolver returns a public IP for the guard but a private IP
|
|
9
|
+
* for the real request).
|
|
10
10
|
* - `readBoundedBuffer` for size cap.
|
|
11
11
|
* - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
|
|
12
12
|
* the guard.
|
|
@@ -21,21 +21,39 @@ import { readBoundedBuffer } from "./sizeGuard.js";
|
|
|
21
21
|
import { validateAndResolveUrl } from "./ssrfGuard.js";
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
23
|
/**
|
|
24
|
-
* Build a once-off undici Agent whose connect lookup resolves `hostname` to
|
|
25
|
-
*
|
|
26
|
-
* removing the DNS-rebinding window.
|
|
24
|
+
* Build a once-off undici Agent whose connect lookup resolves `hostname` to
|
|
25
|
+
* the validated address set. The actual TCP connection can only ever go to
|
|
26
|
+
* an address the SSRF guard cleared, removing the DNS-rebinding window.
|
|
27
|
+
*
|
|
28
|
+
* The full set (IPv4 first) matters: pinning a single address turns one
|
|
29
|
+
* unroutable family into a hard connect timeout. On IPv4-only networks where
|
|
30
|
+
* the OS resolver prefers AAAA, that broke every safeDownload (Replicate /
|
|
31
|
+
* Runway / Kling asset fetches) while plain curl of the same URL succeeded —
|
|
32
|
+
* curl races both families (Happy Eyeballs); a one-address pin cannot.
|
|
27
33
|
*/
|
|
28
|
-
function buildPinnedAgent(hostname,
|
|
34
|
+
function buildPinnedAgent(hostname, addresses) {
|
|
35
|
+
const primary = addresses[0];
|
|
36
|
+
if (!primary) {
|
|
37
|
+
throw new Error(`safeFetch: no validated addresses to pin for "${hostname}"`);
|
|
38
|
+
}
|
|
29
39
|
return new Agent({
|
|
30
40
|
connect: {
|
|
31
|
-
lookup: (host,
|
|
41
|
+
lookup: (host, options, callback) => {
|
|
32
42
|
if (host.toLowerCase() !== hostname.toLowerCase()) {
|
|
33
43
|
// The host the connect layer asks for differs from the URL host —
|
|
34
44
|
// this happens for absolute Host headers etc. Reject defensively.
|
|
35
45
|
callback(new Error(`safeFetch: refusing to resolve "${host}" — expected "${hostname}"`), "", 0);
|
|
36
46
|
return;
|
|
37
47
|
}
|
|
38
|
-
|
|
48
|
+
// Node ≥20 enables autoSelectFamily (Happy Eyeballs) by default, and
|
|
49
|
+
// its lookup contract passes `all: true` expecting an address ARRAY.
|
|
50
|
+
// Hand it every validated address so it can race families and fall
|
|
51
|
+
// back when one is unroutable.
|
|
52
|
+
if (options?.all) {
|
|
53
|
+
callback(null, addresses.map((a) => ({ address: a.ip, family: a.family })));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
callback(null, primary.ip, primary.family);
|
|
39
57
|
},
|
|
40
58
|
},
|
|
41
59
|
});
|
|
@@ -47,10 +65,10 @@ function buildPinnedAgent(hostname, ip, family) {
|
|
|
47
65
|
* is encountered, or the HTTP status indicates failure.
|
|
48
66
|
*/
|
|
49
67
|
export async function safeDownload(url, options) {
|
|
50
|
-
const { url: validatedUrl,
|
|
68
|
+
const { url: validatedUrl, addresses } = await validateAndResolveUrl(url);
|
|
51
69
|
const parsed = new URL(validatedUrl);
|
|
52
70
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
53
|
-
const agent = buildPinnedAgent(hostname,
|
|
71
|
+
const agent = buildPinnedAgent(hostname, addresses);
|
|
54
72
|
const timeoutCtrl = new AbortController();
|
|
55
73
|
const timeoutId = setTimeout(() => timeoutCtrl.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
56
74
|
const composedSignal = options.signal
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* @module utils/ssrfGuard
|
|
26
26
|
*/
|
|
27
|
+
import type { PinnedAddress } from "../types/index.js";
|
|
27
28
|
/**
|
|
28
29
|
* Assert that `url` is safe to fetch server-side.
|
|
29
30
|
*
|
|
@@ -35,12 +36,18 @@
|
|
|
35
36
|
*/
|
|
36
37
|
export declare function assertSafeUrl(url: string): Promise<void>;
|
|
37
38
|
/**
|
|
38
|
-
* Validate `url` and return the resolved
|
|
39
|
-
* actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
39
|
+
* Validate `url` and return the resolved address set that should be used for
|
|
40
|
+
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
40
41
|
*
|
|
41
|
-
* For IP-literal hosts,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
|
|
43
|
+
* hostnames it is **every** validated address, IPv4 first, so the connect
|
|
44
|
+
* layer can race families (Happy Eyeballs). Pinning a single OS-preferred
|
|
45
|
+
* address breaks every download on IPv4-only networks whenever the resolver
|
|
46
|
+
* prefers AAAA: the pinned connection has no second address to fall back to,
|
|
47
|
+
* so it times out where plain `curl` (which races both families) succeeds.
|
|
48
|
+
* `ip`/`family` mirror the first entry for callers that need a single pin.
|
|
49
|
+
*
|
|
50
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
44
51
|
*
|
|
45
52
|
* This is the canonical entry point for binary downloads where DNS-rebinding
|
|
46
53
|
* pinning matters — see `safeFetch.ts`.
|
|
@@ -49,4 +56,5 @@ export declare function validateAndResolveUrl(url: string): Promise<{
|
|
|
49
56
|
url: string;
|
|
50
57
|
ip: string;
|
|
51
58
|
family: 4 | 6;
|
|
59
|
+
addresses: readonly PinnedAddress[];
|
|
52
60
|
}>;
|
|
@@ -306,6 +306,17 @@ export async function assertSafeUrl(url) {
|
|
|
306
306
|
}
|
|
307
307
|
// Hostname — resolve BOTH A and AAAA. Reject if either family yields a
|
|
308
308
|
// blocked address (closes off the "publish AAAA public, A private" attack).
|
|
309
|
+
await resolveAndValidateHost(url, host);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Resolve `host` (both A and AAAA) and validate every returned address
|
|
313
|
+
* against the block lists. Returns the full validated set so callers that
|
|
314
|
+
* pin connections can offer the connect layer more than one address.
|
|
315
|
+
*
|
|
316
|
+
* @throws {Error} when resolution fails entirely or any address is blocked
|
|
317
|
+
* (all-must-pass — the caller may connect to any address in the set).
|
|
318
|
+
*/
|
|
319
|
+
async function resolveAndValidateHost(url, host) {
|
|
309
320
|
const [a, aaaa] = await Promise.allSettled([
|
|
310
321
|
lookup(host, { family: 4, all: true }),
|
|
311
322
|
lookup(host, { family: 6, all: true }),
|
|
@@ -354,14 +365,21 @@ export async function assertSafeUrl(url) {
|
|
|
354
365
|
throw new Error(`URL "${url}" rejected: hostname ${host} resolves to ${addr} (IPv6 ${reason})`);
|
|
355
366
|
}
|
|
356
367
|
}
|
|
368
|
+
return { v4: v4Addresses, v6: v6Addresses };
|
|
357
369
|
}
|
|
358
370
|
/**
|
|
359
|
-
* Validate `url` and return the resolved
|
|
360
|
-
* actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
371
|
+
* Validate `url` and return the resolved address set that should be used for
|
|
372
|
+
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
373
|
+
*
|
|
374
|
+
* For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
|
|
375
|
+
* hostnames it is **every** validated address, IPv4 first, so the connect
|
|
376
|
+
* layer can race families (Happy Eyeballs). Pinning a single OS-preferred
|
|
377
|
+
* address breaks every download on IPv4-only networks whenever the resolver
|
|
378
|
+
* prefers AAAA: the pinned connection has no second address to fall back to,
|
|
379
|
+
* so it times out where plain `curl` (which races both families) succeeds.
|
|
380
|
+
* `ip`/`family` mirror the first entry for callers that need a single pin.
|
|
361
381
|
*
|
|
362
|
-
*
|
|
363
|
-
* returns the first acceptable IP from the resolver. Same throw semantics as
|
|
364
|
-
* {@link assertSafeUrl}.
|
|
382
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
365
383
|
*
|
|
366
384
|
* This is the canonical entry point for binary downloads where DNS-rebinding
|
|
367
385
|
* pinning matters — see `safeFetch.ts`.
|
|
@@ -384,7 +402,7 @@ export async function validateAndResolveUrl(url) {
|
|
|
384
402
|
if (isBlockedIPv4(v4)) {
|
|
385
403
|
throw new Error(`URL "${url}" rejected: IPv4 ${host} → ${v4} is in a blocked range`);
|
|
386
404
|
}
|
|
387
|
-
return { url, ip: v4, family: 4 };
|
|
405
|
+
return { url, ip: v4, family: 4, addresses: [{ ip: v4, family: 4 }] };
|
|
388
406
|
}
|
|
389
407
|
if (host.includes(":")) {
|
|
390
408
|
const v4FromMapped = extractIPv4FromMapped(host);
|
|
@@ -392,7 +410,12 @@ export async function validateAndResolveUrl(url) {
|
|
|
392
410
|
if (isBlockedIPv4(v4FromMapped)) {
|
|
393
411
|
throw new Error(`URL "${url}" rejected: IPv4-mapped IPv6 ${host} → ${v4FromMapped} is in a blocked range`);
|
|
394
412
|
}
|
|
395
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
url,
|
|
415
|
+
ip: v4FromMapped,
|
|
416
|
+
family: 4,
|
|
417
|
+
addresses: [{ ip: v4FromMapped, family: 4 }],
|
|
418
|
+
};
|
|
396
419
|
}
|
|
397
420
|
const expanded = expandIPv6(host);
|
|
398
421
|
if (!expanded) {
|
|
@@ -401,11 +424,23 @@ export async function validateAndResolveUrl(url) {
|
|
|
401
424
|
if (isBlockedIPv6(expanded)) {
|
|
402
425
|
throw new Error(`URL "${url}" rejected: IPv6 ${host} is in a blocked range`);
|
|
403
426
|
}
|
|
404
|
-
return { url, ip: host, family: 6 };
|
|
405
|
-
}
|
|
406
|
-
// Hostname — resolve and
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
427
|
+
return { url, ip: host, family: 6, addresses: [{ ip: host, family: 6 }] };
|
|
428
|
+
}
|
|
429
|
+
// Hostname — resolve once, validate every address, and return the whole
|
|
430
|
+
// set. Validating the same answer we hand to the connect layer also
|
|
431
|
+
// removes the rebinding window the previous separate `lookup()` left
|
|
432
|
+
// between validation and pinning.
|
|
433
|
+
const { v4: v4Addresses, v6: v6Addresses } = await resolveAndValidateHost(url, host);
|
|
434
|
+
const addresses = [
|
|
435
|
+
...v4Addresses.map((ip) => ({ ip, family: 4 })),
|
|
436
|
+
...v6Addresses.map((ip) => ({ ip, family: 6 })),
|
|
437
|
+
];
|
|
438
|
+
const first = addresses[0];
|
|
439
|
+
if (!first) {
|
|
440
|
+
// Both lookups "succeeded" but returned zero addresses — treat exactly
|
|
441
|
+
// like a resolution failure rather than pinning nothing.
|
|
442
|
+
throw new Error(`URL "${url}" rejected: hostname ${host} resolved to an empty address set`);
|
|
443
|
+
}
|
|
444
|
+
return { url, ip: first.ip, family: first.family, addresses };
|
|
410
445
|
}
|
|
411
446
|
//# sourceMappingURL=ssrfGuard.js.map
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runtime helper lives in `src/lib/utils/safeFetch.ts`.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* One validated address the pinned connect layer is allowed to dial.
|
|
8
|
+
* Produced by `ssrfGuard.ts:validateAndResolveUrl`, consumed by
|
|
9
|
+
* `safeFetch.ts:buildPinnedAgent`.
|
|
10
|
+
*/
|
|
11
|
+
export type PinnedAddress = {
|
|
12
|
+
ip: string;
|
|
13
|
+
family: 4 | 6;
|
|
14
|
+
};
|
|
6
15
|
export type SafeDownloadOptions = {
|
|
7
16
|
/** Hard cap on response size in bytes. Pass MAX_VIDEO_BYTES/MAX_AUDIO_BYTES/MAX_IMAGE_BYTES from sizeGuard. */
|
|
8
17
|
maxBytes: number;
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Combines:
|
|
5
5
|
* - `assertSafeUrl` (validates and rejects blocked IPs)
|
|
6
6
|
* - undici `Agent` with custom `connect.lookup` so the actual connection
|
|
7
|
-
*
|
|
8
|
-
* resolver returns a public IP for the guard but a private IP
|
|
9
|
-
* real request).
|
|
7
|
+
* only ever dials addresses we validated (closes the DNS-rebinding window
|
|
8
|
+
* where the resolver returns a public IP for the guard but a private IP
|
|
9
|
+
* for the real request).
|
|
10
10
|
* - `readBoundedBuffer` for size cap.
|
|
11
11
|
* - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
|
|
12
12
|
* the guard.
|
package/dist/utils/safeFetch.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Combines:
|
|
5
5
|
* - `assertSafeUrl` (validates and rejects blocked IPs)
|
|
6
6
|
* - undici `Agent` with custom `connect.lookup` so the actual connection
|
|
7
|
-
*
|
|
8
|
-
* resolver returns a public IP for the guard but a private IP
|
|
9
|
-
* real request).
|
|
7
|
+
* only ever dials addresses we validated (closes the DNS-rebinding window
|
|
8
|
+
* where the resolver returns a public IP for the guard but a private IP
|
|
9
|
+
* for the real request).
|
|
10
10
|
* - `readBoundedBuffer` for size cap.
|
|
11
11
|
* - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
|
|
12
12
|
* the guard.
|
|
@@ -21,21 +21,39 @@ import { readBoundedBuffer } from "./sizeGuard.js";
|
|
|
21
21
|
import { validateAndResolveUrl } from "./ssrfGuard.js";
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
23
|
/**
|
|
24
|
-
* Build a once-off undici Agent whose connect lookup resolves `hostname` to
|
|
25
|
-
*
|
|
26
|
-
* removing the DNS-rebinding window.
|
|
24
|
+
* Build a once-off undici Agent whose connect lookup resolves `hostname` to
|
|
25
|
+
* the validated address set. The actual TCP connection can only ever go to
|
|
26
|
+
* an address the SSRF guard cleared, removing the DNS-rebinding window.
|
|
27
|
+
*
|
|
28
|
+
* The full set (IPv4 first) matters: pinning a single address turns one
|
|
29
|
+
* unroutable family into a hard connect timeout. On IPv4-only networks where
|
|
30
|
+
* the OS resolver prefers AAAA, that broke every safeDownload (Replicate /
|
|
31
|
+
* Runway / Kling asset fetches) while plain curl of the same URL succeeded —
|
|
32
|
+
* curl races both families (Happy Eyeballs); a one-address pin cannot.
|
|
27
33
|
*/
|
|
28
|
-
function buildPinnedAgent(hostname,
|
|
34
|
+
function buildPinnedAgent(hostname, addresses) {
|
|
35
|
+
const primary = addresses[0];
|
|
36
|
+
if (!primary) {
|
|
37
|
+
throw new Error(`safeFetch: no validated addresses to pin for "${hostname}"`);
|
|
38
|
+
}
|
|
29
39
|
return new Agent({
|
|
30
40
|
connect: {
|
|
31
|
-
lookup: (host,
|
|
41
|
+
lookup: (host, options, callback) => {
|
|
32
42
|
if (host.toLowerCase() !== hostname.toLowerCase()) {
|
|
33
43
|
// The host the connect layer asks for differs from the URL host —
|
|
34
44
|
// this happens for absolute Host headers etc. Reject defensively.
|
|
35
45
|
callback(new Error(`safeFetch: refusing to resolve "${host}" — expected "${hostname}"`), "", 0);
|
|
36
46
|
return;
|
|
37
47
|
}
|
|
38
|
-
|
|
48
|
+
// Node ≥20 enables autoSelectFamily (Happy Eyeballs) by default, and
|
|
49
|
+
// its lookup contract passes `all: true` expecting an address ARRAY.
|
|
50
|
+
// Hand it every validated address so it can race families and fall
|
|
51
|
+
// back when one is unroutable.
|
|
52
|
+
if (options?.all) {
|
|
53
|
+
callback(null, addresses.map((a) => ({ address: a.ip, family: a.family })));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
callback(null, primary.ip, primary.family);
|
|
39
57
|
},
|
|
40
58
|
},
|
|
41
59
|
});
|
|
@@ -47,10 +65,10 @@ function buildPinnedAgent(hostname, ip, family) {
|
|
|
47
65
|
* is encountered, or the HTTP status indicates failure.
|
|
48
66
|
*/
|
|
49
67
|
export async function safeDownload(url, options) {
|
|
50
|
-
const { url: validatedUrl,
|
|
68
|
+
const { url: validatedUrl, addresses } = await validateAndResolveUrl(url);
|
|
51
69
|
const parsed = new URL(validatedUrl);
|
|
52
70
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
53
|
-
const agent = buildPinnedAgent(hostname,
|
|
71
|
+
const agent = buildPinnedAgent(hostname, addresses);
|
|
54
72
|
const timeoutCtrl = new AbortController();
|
|
55
73
|
const timeoutId = setTimeout(() => timeoutCtrl.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
56
74
|
const composedSignal = options.signal
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* @module utils/ssrfGuard
|
|
26
26
|
*/
|
|
27
|
+
import type { PinnedAddress } from "../types/index.js";
|
|
27
28
|
/**
|
|
28
29
|
* Assert that `url` is safe to fetch server-side.
|
|
29
30
|
*
|
|
@@ -35,12 +36,18 @@
|
|
|
35
36
|
*/
|
|
36
37
|
export declare function assertSafeUrl(url: string): Promise<void>;
|
|
37
38
|
/**
|
|
38
|
-
* Validate `url` and return the resolved
|
|
39
|
-
* actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
39
|
+
* Validate `url` and return the resolved address set that should be used for
|
|
40
|
+
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
40
41
|
*
|
|
41
|
-
* For IP-literal hosts,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
|
|
43
|
+
* hostnames it is **every** validated address, IPv4 first, so the connect
|
|
44
|
+
* layer can race families (Happy Eyeballs). Pinning a single OS-preferred
|
|
45
|
+
* address breaks every download on IPv4-only networks whenever the resolver
|
|
46
|
+
* prefers AAAA: the pinned connection has no second address to fall back to,
|
|
47
|
+
* so it times out where plain `curl` (which races both families) succeeds.
|
|
48
|
+
* `ip`/`family` mirror the first entry for callers that need a single pin.
|
|
49
|
+
*
|
|
50
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
44
51
|
*
|
|
45
52
|
* This is the canonical entry point for binary downloads where DNS-rebinding
|
|
46
53
|
* pinning matters — see `safeFetch.ts`.
|
|
@@ -49,4 +56,5 @@ export declare function validateAndResolveUrl(url: string): Promise<{
|
|
|
49
56
|
url: string;
|
|
50
57
|
ip: string;
|
|
51
58
|
family: 4 | 6;
|
|
59
|
+
addresses: readonly PinnedAddress[];
|
|
52
60
|
}>;
|
package/dist/utils/ssrfGuard.js
CHANGED
|
@@ -306,6 +306,17 @@ export async function assertSafeUrl(url) {
|
|
|
306
306
|
}
|
|
307
307
|
// Hostname — resolve BOTH A and AAAA. Reject if either family yields a
|
|
308
308
|
// blocked address (closes off the "publish AAAA public, A private" attack).
|
|
309
|
+
await resolveAndValidateHost(url, host);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Resolve `host` (both A and AAAA) and validate every returned address
|
|
313
|
+
* against the block lists. Returns the full validated set so callers that
|
|
314
|
+
* pin connections can offer the connect layer more than one address.
|
|
315
|
+
*
|
|
316
|
+
* @throws {Error} when resolution fails entirely or any address is blocked
|
|
317
|
+
* (all-must-pass — the caller may connect to any address in the set).
|
|
318
|
+
*/
|
|
319
|
+
async function resolveAndValidateHost(url, host) {
|
|
309
320
|
const [a, aaaa] = await Promise.allSettled([
|
|
310
321
|
lookup(host, { family: 4, all: true }),
|
|
311
322
|
lookup(host, { family: 6, all: true }),
|
|
@@ -354,14 +365,21 @@ export async function assertSafeUrl(url) {
|
|
|
354
365
|
throw new Error(`URL "${url}" rejected: hostname ${host} resolves to ${addr} (IPv6 ${reason})`);
|
|
355
366
|
}
|
|
356
367
|
}
|
|
368
|
+
return { v4: v4Addresses, v6: v6Addresses };
|
|
357
369
|
}
|
|
358
370
|
/**
|
|
359
|
-
* Validate `url` and return the resolved
|
|
360
|
-
* actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
371
|
+
* Validate `url` and return the resolved address set that should be used for
|
|
372
|
+
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
|
373
|
+
*
|
|
374
|
+
* For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
|
|
375
|
+
* hostnames it is **every** validated address, IPv4 first, so the connect
|
|
376
|
+
* layer can race families (Happy Eyeballs). Pinning a single OS-preferred
|
|
377
|
+
* address breaks every download on IPv4-only networks whenever the resolver
|
|
378
|
+
* prefers AAAA: the pinned connection has no second address to fall back to,
|
|
379
|
+
* so it times out where plain `curl` (which races both families) succeeds.
|
|
380
|
+
* `ip`/`family` mirror the first entry for callers that need a single pin.
|
|
361
381
|
*
|
|
362
|
-
*
|
|
363
|
-
* returns the first acceptable IP from the resolver. Same throw semantics as
|
|
364
|
-
* {@link assertSafeUrl}.
|
|
382
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
365
383
|
*
|
|
366
384
|
* This is the canonical entry point for binary downloads where DNS-rebinding
|
|
367
385
|
* pinning matters — see `safeFetch.ts`.
|
|
@@ -384,7 +402,7 @@ export async function validateAndResolveUrl(url) {
|
|
|
384
402
|
if (isBlockedIPv4(v4)) {
|
|
385
403
|
throw new Error(`URL "${url}" rejected: IPv4 ${host} → ${v4} is in a blocked range`);
|
|
386
404
|
}
|
|
387
|
-
return { url, ip: v4, family: 4 };
|
|
405
|
+
return { url, ip: v4, family: 4, addresses: [{ ip: v4, family: 4 }] };
|
|
388
406
|
}
|
|
389
407
|
if (host.includes(":")) {
|
|
390
408
|
const v4FromMapped = extractIPv4FromMapped(host);
|
|
@@ -392,7 +410,12 @@ export async function validateAndResolveUrl(url) {
|
|
|
392
410
|
if (isBlockedIPv4(v4FromMapped)) {
|
|
393
411
|
throw new Error(`URL "${url}" rejected: IPv4-mapped IPv6 ${host} → ${v4FromMapped} is in a blocked range`);
|
|
394
412
|
}
|
|
395
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
url,
|
|
415
|
+
ip: v4FromMapped,
|
|
416
|
+
family: 4,
|
|
417
|
+
addresses: [{ ip: v4FromMapped, family: 4 }],
|
|
418
|
+
};
|
|
396
419
|
}
|
|
397
420
|
const expanded = expandIPv6(host);
|
|
398
421
|
if (!expanded) {
|
|
@@ -401,10 +424,22 @@ export async function validateAndResolveUrl(url) {
|
|
|
401
424
|
if (isBlockedIPv6(expanded)) {
|
|
402
425
|
throw new Error(`URL "${url}" rejected: IPv6 ${host} is in a blocked range`);
|
|
403
426
|
}
|
|
404
|
-
return { url, ip: host, family: 6 };
|
|
405
|
-
}
|
|
406
|
-
// Hostname — resolve and
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
427
|
+
return { url, ip: host, family: 6, addresses: [{ ip: host, family: 6 }] };
|
|
428
|
+
}
|
|
429
|
+
// Hostname — resolve once, validate every address, and return the whole
|
|
430
|
+
// set. Validating the same answer we hand to the connect layer also
|
|
431
|
+
// removes the rebinding window the previous separate `lookup()` left
|
|
432
|
+
// between validation and pinning.
|
|
433
|
+
const { v4: v4Addresses, v6: v6Addresses } = await resolveAndValidateHost(url, host);
|
|
434
|
+
const addresses = [
|
|
435
|
+
...v4Addresses.map((ip) => ({ ip, family: 4 })),
|
|
436
|
+
...v6Addresses.map((ip) => ({ ip, family: 6 })),
|
|
437
|
+
];
|
|
438
|
+
const first = addresses[0];
|
|
439
|
+
if (!first) {
|
|
440
|
+
// Both lookups "succeeded" but returned zero addresses — treat exactly
|
|
441
|
+
// like a resolution failure rather than pinning nothing.
|
|
442
|
+
throw new Error(`URL "${url}" rejected: hostname ${host} resolved to an empty address set`);
|
|
443
|
+
}
|
|
444
|
+
return { url, ip: first.ip, family: first.family, addresses };
|
|
410
445
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "9.87.
|
|
3
|
+
"version": "9.87.2",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (58+ servers), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|