@juspay/neurolink 12.47.9 → 12.47.10
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 +2 -2
- package/dist/browser/neurolink.min.js +377 -377
- package/dist/providers/openAI/client.js +12 -27
- package/dist/proxy/proxyFetch.d.ts +12 -0
- package/dist/proxy/proxyFetch.js +33 -10
- package/dist/utils/safeFetch.d.ts +6 -0
- package/dist/utils/safeFetch.js +54 -22
- package/dist/utils/ssrfGuard.d.ts +16 -0
- package/dist/utils/ssrfGuard.js +55 -0
- package/package.json +1 -1
|
@@ -7,8 +7,8 @@ import { redactUrlCredentials } from "../../utils/logSanitize.js";
|
|
|
7
7
|
import { calculateCost } from "../../utils/pricing.js";
|
|
8
8
|
import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
|
|
9
9
|
import { createOpenAIConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { safeDownload } from "../../utils/safeFetch.js";
|
|
11
|
+
import { MAX_IMAGE_BYTES } from "../../utils/sizeGuard.js";
|
|
12
12
|
import { createTimeoutController } from "../../utils/timeout.js";
|
|
13
13
|
import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
|
|
14
14
|
import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
|
|
@@ -453,31 +453,16 @@ export class OpenAIProvider extends OpenAIChatCompletionsProvider {
|
|
|
453
453
|
// b64_json. If a hosted URL came back instead (e.g. older keys, or
|
|
454
454
|
// url-mode), download it inline so callers always get base64.
|
|
455
455
|
if (!base64 && first.url) {
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
catch (err) {
|
|
467
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
468
|
-
throw new Error("OpenAI image URL download timed out after 60s", {
|
|
469
|
-
cause: err,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
throw err;
|
|
473
|
-
}
|
|
474
|
-
finally {
|
|
475
|
-
clearTimeout(dlTimeoutId);
|
|
476
|
-
}
|
|
477
|
-
if (!imgResp.ok) {
|
|
478
|
-
throw new Error(`OpenAI image generation: failed to fetch hosted URL ${first.url} (${imgResp.status})`);
|
|
479
|
-
}
|
|
480
|
-
const buf = await readBoundedBuffer(imgResp, MAX_IMAGE_BYTES, "OpenAI image fallback");
|
|
456
|
+
// The API-returned URL carries the same SSRF risk as a caller-supplied
|
|
457
|
+
// one, so it is fetched through `safeDownload`: vetted and pinned (or,
|
|
458
|
+
// when the environment configures a proxy, sent through it), no
|
|
459
|
+
// redirects, size-capped. Vetting and fetching in two separate steps
|
|
460
|
+
// left a DNS-rebinding window between them.
|
|
461
|
+
const buf = await safeDownload(first.url, {
|
|
462
|
+
maxBytes: MAX_IMAGE_BYTES,
|
|
463
|
+
label: "OpenAI image fallback",
|
|
464
|
+
timeoutMs: 60_000,
|
|
465
|
+
});
|
|
481
466
|
base64 = buf.toString("base64");
|
|
482
467
|
}
|
|
483
468
|
if (!base64) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
|
|
4
4
|
* Lightweight implementation extracted from research of major proxy packages
|
|
5
5
|
*/
|
|
6
|
+
import type { ProxyAgent } from "undici";
|
|
6
7
|
import type { GoogleGenAIHttpOptions } from "../types/index.js";
|
|
7
8
|
/**
|
|
8
9
|
* Classify a fetch failure as a transient network error worth retrying.
|
|
@@ -21,6 +22,17 @@ import type { GoogleGenAIHttpOptions } from "../types/index.js";
|
|
|
21
22
|
* Exported for direct coverage by the no-API test suite.
|
|
22
23
|
*/
|
|
23
24
|
export declare function isTransientNetworkError(error: unknown): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The dispatcher a request to `targetUrl` must use to go through the proxy the
|
|
27
|
+
* environment configures, or `null` when none applies (no proxy variable for
|
|
28
|
+
* the URL's scheme, or the host is listed in NO_PROXY).
|
|
29
|
+
*
|
|
30
|
+
* Unlike the fetch from {@link createProxyFetch}, a caller that needs a
|
|
31
|
+
* request to be proxied or refused (a download whose destination was vetted
|
|
32
|
+
* under proxy rules) gets no fallback to a direct connection from this: a proxy
|
|
33
|
+
* that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getProxyDispatcherForUrl(targetUrl: string): Promise<ProxyAgent | null>;
|
|
24
36
|
/**
|
|
25
37
|
* Create a proxy-aware fetch function with enhanced capabilities
|
|
26
38
|
* Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
|
package/dist/proxy/proxyFetch.js
CHANGED
|
@@ -362,6 +362,38 @@ async function createProxyAgent(proxyUrl) {
|
|
|
362
362
|
throw new Error(`Unsupported proxy protocol: ${parsed.protocol}`);
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* One ProxyAgent per proxy URL for the whole process, so connections to the
|
|
367
|
+
* proxy are pooled and an agent is not rebuilt per request. The cache key is
|
|
368
|
+
* a hash of the masked URL: credentials never sit in the map.
|
|
369
|
+
*/
|
|
370
|
+
async function getOrCreateProxyAgent(proxyUrl) {
|
|
371
|
+
const globalWithCache = globalThis;
|
|
372
|
+
if (!globalWithCache.__NL_PROXY_AGENT_CACHE__) {
|
|
373
|
+
globalWithCache.__NL_PROXY_AGENT_CACHE__ = new Map();
|
|
374
|
+
}
|
|
375
|
+
const agentCache = globalWithCache.__NL_PROXY_AGENT_CACHE__;
|
|
376
|
+
const cacheKey = createHash("sha256")
|
|
377
|
+
.update(maskProxyUrl(proxyUrl) ?? proxyUrl)
|
|
378
|
+
.digest("hex");
|
|
379
|
+
const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl));
|
|
380
|
+
agentCache.set(cacheKey, dispatcher);
|
|
381
|
+
return dispatcher;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The dispatcher a request to `targetUrl` must use to go through the proxy the
|
|
385
|
+
* environment configures, or `null` when none applies (no proxy variable for
|
|
386
|
+
* the URL's scheme, or the host is listed in NO_PROXY).
|
|
387
|
+
*
|
|
388
|
+
* Unlike the fetch from {@link createProxyFetch}, a caller that needs a
|
|
389
|
+
* request to be proxied or refused (a download whose destination was vetted
|
|
390
|
+
* under proxy rules) gets no fallback to a direct connection from this: a proxy
|
|
391
|
+
* that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
|
|
392
|
+
*/
|
|
393
|
+
export async function getProxyDispatcherForUrl(targetUrl) {
|
|
394
|
+
const proxyUrl = selectProxyUrl(targetUrl);
|
|
395
|
+
return proxyUrl ? getOrCreateProxyAgent(proxyUrl) : null;
|
|
396
|
+
}
|
|
365
397
|
function sanitizeProxyUrl(url) {
|
|
366
398
|
return maskProxyUrl(url) ?? "NOT_SET";
|
|
367
399
|
}
|
|
@@ -469,16 +501,7 @@ async function executeProxiedFetch(input, init, proxyEnv) {
|
|
|
469
501
|
aboutToCreateProxyAgent: true,
|
|
470
502
|
timestamp: new Date().toISOString(),
|
|
471
503
|
});
|
|
472
|
-
const
|
|
473
|
-
if (!globalWithCache.__NL_PROXY_AGENT_CACHE__) {
|
|
474
|
-
globalWithCache.__NL_PROXY_AGENT_CACHE__ = new Map();
|
|
475
|
-
}
|
|
476
|
-
const agentCache = globalWithCache.__NL_PROXY_AGENT_CACHE__;
|
|
477
|
-
const cacheKey = createHash("sha256")
|
|
478
|
-
.update(maskProxyUrl(proxyUrl) ?? proxyUrl)
|
|
479
|
-
.digest("hex");
|
|
480
|
-
const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl));
|
|
481
|
-
agentCache.set(cacheKey, dispatcher);
|
|
504
|
+
const dispatcher = await getOrCreateProxyAgent(proxyUrl);
|
|
482
505
|
logger.debug(`[Proxy Fetch] ✅ ENHANCED PROXY AGENT CREATED`, {
|
|
483
506
|
requestId,
|
|
484
507
|
hasDispatcher: !!dispatcher,
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* Use this for **every** download of an external (caller-supplied or
|
|
15
15
|
* third-party-returned) URL. Direct `fetch(url)` of such URLs is unsafe.
|
|
16
16
|
*
|
|
17
|
+
* When the environment configures a proxy for the URL (HTTPS_PROXY / ALL_PROXY,
|
|
18
|
+
* and the host is not in NO_PROXY) the download goes through that proxy and
|
|
19
|
+
* nowhere else: there is no fallback to a direct connection. The proxy resolves
|
|
20
|
+
* the host name, so DNS pinning cannot apply; the URL is vetted by
|
|
21
|
+
* `assertSafeUrlForProxy` instead (see there for what that covers).
|
|
22
|
+
*
|
|
17
23
|
* @module utils/safeFetch
|
|
18
24
|
*/
|
|
19
25
|
import type { SafeDownloadOptions } from "../types/index.js";
|
package/dist/utils/safeFetch.js
CHANGED
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
* Use this for **every** download of an external (caller-supplied or
|
|
15
15
|
* third-party-returned) URL. Direct `fetch(url)` of such URLs is unsafe.
|
|
16
16
|
*
|
|
17
|
+
* When the environment configures a proxy for the URL (HTTPS_PROXY / ALL_PROXY,
|
|
18
|
+
* and the host is not in NO_PROXY) the download goes through that proxy and
|
|
19
|
+
* nowhere else: there is no fallback to a direct connection. The proxy resolves
|
|
20
|
+
* the host name, so DNS pinning cannot apply; the URL is vetted by
|
|
21
|
+
* `assertSafeUrlForProxy` instead (see there for what that covers).
|
|
22
|
+
*
|
|
17
23
|
* @module utils/safeFetch
|
|
18
24
|
*/
|
|
19
25
|
import { readBoundedBuffer } from "./sizeGuard.js";
|
|
20
|
-
import { validateAndResolveUrl } from "./ssrfGuard.js";
|
|
26
|
+
import { assertSafeUrlForProxy, validateAndResolveUrl } from "./ssrfGuard.js";
|
|
21
27
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
22
28
|
/**
|
|
23
29
|
* Build a once-off undici Agent whose connect lookup resolves `hostname` to
|
|
@@ -80,6 +86,24 @@ function isBoundedBufferResponse(value) {
|
|
|
80
86
|
candidate.headers !== null &&
|
|
81
87
|
typeof candidate.headers.get === "function");
|
|
82
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Check the response the same way for both transports, then read it under the
|
|
91
|
+
* size cap.
|
|
92
|
+
*/
|
|
93
|
+
async function readDownloadResponse(response, url, options) {
|
|
94
|
+
if (response.status >= 300 && response.status < 400) {
|
|
95
|
+
throw new Error(`safeDownload(${options.label}): refused to follow redirect ${response.status} → ${response.headers.get("location") ?? "<no-location>"} (for ${url})`);
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`safeDownload(${options.label}) failed: HTTP ${response.status} for ${url}`);
|
|
99
|
+
}
|
|
100
|
+
// readBoundedBuffer expects a Response that exposes Content-Length and
|
|
101
|
+
// arrayBuffer(). undici Response satisfies both.
|
|
102
|
+
if (!isBoundedBufferResponse(response)) {
|
|
103
|
+
throw new Error(`safeDownload(${options.label}): response is missing headers.get()/arrayBuffer()`);
|
|
104
|
+
}
|
|
105
|
+
return readBoundedBuffer(response, options.maxBytes, options.label);
|
|
106
|
+
}
|
|
83
107
|
/**
|
|
84
108
|
* Safely download a binary asset from an external URL.
|
|
85
109
|
*
|
|
@@ -87,14 +111,30 @@ function isBoundedBufferResponse(value) {
|
|
|
87
111
|
* is encountered, or the HTTP status indicates failure.
|
|
88
112
|
*/
|
|
89
113
|
export async function safeDownload(url, options) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
93
|
-
// Dynamic import keeps undici off the package's static import graph — it
|
|
94
|
-
// is only needed once a download actually happens. See
|
|
114
|
+
// Dynamic imports keep undici and the proxy module off the package's static
|
|
115
|
+
// import graph — they are only needed once a download actually happens. See
|
|
95
116
|
// test/continuous-test-suite-import-cost.ts.
|
|
117
|
+
const { getProxyDispatcherForUrl } = await import("../proxy/proxyFetch.js");
|
|
118
|
+
const proxyDispatcher = await getProxyDispatcherForUrl(url);
|
|
96
119
|
const { fetch: undiciFetch } = await import("undici");
|
|
97
|
-
|
|
120
|
+
// Resolve and vet the destination, and pick the transport. Through a proxy
|
|
121
|
+
// the proxy resolves the name, so only the URL text can be vetted; without
|
|
122
|
+
// one the connection is pinned to the addresses the guard cleared.
|
|
123
|
+
let validatedUrl;
|
|
124
|
+
let dispatcher;
|
|
125
|
+
let closeDispatcher = false;
|
|
126
|
+
if (proxyDispatcher) {
|
|
127
|
+
assertSafeUrlForProxy(url);
|
|
128
|
+
validatedUrl = url;
|
|
129
|
+
dispatcher = proxyDispatcher;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const resolved = await validateAndResolveUrl(url);
|
|
133
|
+
validatedUrl = resolved.url;
|
|
134
|
+
const hostname = new URL(validatedUrl).hostname.replace(/^\[|\]$/g, "");
|
|
135
|
+
dispatcher = await buildPinnedAgent(hostname, resolved.addresses);
|
|
136
|
+
closeDispatcher = true;
|
|
137
|
+
}
|
|
98
138
|
const timeoutCtrl = new AbortController();
|
|
99
139
|
const timeoutId = setTimeout(() => timeoutCtrl.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
100
140
|
const composedSignal = options.signal
|
|
@@ -106,24 +146,16 @@ export async function safeDownload(url, options) {
|
|
|
106
146
|
method: "GET",
|
|
107
147
|
signal: composedSignal,
|
|
108
148
|
redirect: "manual", // a 3xx → private-IP redirect would bypass the guard
|
|
109
|
-
dispatcher
|
|
149
|
+
dispatcher,
|
|
110
150
|
});
|
|
111
151
|
}
|
|
112
152
|
finally {
|
|
113
153
|
clearTimeout(timeoutId);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
if (closeDispatcher) {
|
|
155
|
+
// Close the per-request agent so the pinned connection isn't pooled.
|
|
156
|
+
// The proxy agent is shared process-wide and stays open.
|
|
157
|
+
dispatcher.close().catch(() => undefined);
|
|
158
|
+
}
|
|
119
159
|
}
|
|
120
|
-
|
|
121
|
-
throw new Error(`safeDownload(${options.label}) failed: HTTP ${response.status} for ${url}`);
|
|
122
|
-
}
|
|
123
|
-
// readBoundedBuffer expects a Response that exposes Content-Length and
|
|
124
|
-
// arrayBuffer(). undici Response satisfies both.
|
|
125
|
-
if (!isBoundedBufferResponse(response)) {
|
|
126
|
-
throw new Error(`safeDownload(${options.label}): response is missing headers.get()/arrayBuffer()`);
|
|
127
|
-
}
|
|
128
|
-
return readBoundedBuffer(response, options.maxBytes, options.label);
|
|
160
|
+
return readDownloadResponse(response, url, options);
|
|
129
161
|
}
|
|
@@ -35,6 +35,22 @@ import type { PinnedAddress } from "../types/index.js";
|
|
|
35
35
|
* IP at the actual fetch).
|
|
36
36
|
*/
|
|
37
37
|
export declare function assertSafeUrl(url: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Validate `url` for a download that goes through an HTTP proxy.
|
|
40
|
+
*
|
|
41
|
+
* The proxy, not this process, resolves the host name and opens the
|
|
42
|
+
* connection, so there is no local DNS answer to check or to pin. What can be
|
|
43
|
+
* judged from the URL alone is judged: HTTPS only, blocked IP literals in any
|
|
44
|
+
* encoding, and host names that can only mean something local or private (a
|
|
45
|
+
* single-label name such as `intranet`, and the internal suffixes above).
|
|
46
|
+
* What the proxy resolves any other name to is the proxy's egress policy to
|
|
47
|
+
* enforce; that is the trade for using a proxy at all, and why this is only
|
|
48
|
+
* used when one applies. Without a proxy {@link validateAndResolveUrl} runs
|
|
49
|
+
* instead and the connection is pinned to the addresses it cleared.
|
|
50
|
+
*
|
|
51
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertSafeUrlForProxy(url: string): void;
|
|
38
54
|
/**
|
|
39
55
|
* Validate `url` and return the resolved address set that should be used for
|
|
40
56
|
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
package/dist/utils/ssrfGuard.js
CHANGED
|
@@ -367,6 +367,61 @@ async function resolveAndValidateHost(url, host) {
|
|
|
367
367
|
}
|
|
368
368
|
return { v4: v4Addresses, v6: v6Addresses };
|
|
369
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Host-name endings that can only name a local or private target: the
|
|
372
|
+
* special-use domains of RFC 6761 and RFC 8375 plus the common internal
|
|
373
|
+
* suffix. `.invalid` and `.test` are deliberately absent: they never resolve
|
|
374
|
+
* on the public internet and test fixtures use them.
|
|
375
|
+
*/
|
|
376
|
+
const INTERNAL_HOST_SUFFIXES = [
|
|
377
|
+
"localhost",
|
|
378
|
+
"local",
|
|
379
|
+
"localdomain",
|
|
380
|
+
"internal",
|
|
381
|
+
"home.arpa",
|
|
382
|
+
];
|
|
383
|
+
/**
|
|
384
|
+
* Validate `url` for a download that goes through an HTTP proxy.
|
|
385
|
+
*
|
|
386
|
+
* The proxy, not this process, resolves the host name and opens the
|
|
387
|
+
* connection, so there is no local DNS answer to check or to pin. What can be
|
|
388
|
+
* judged from the URL alone is judged: HTTPS only, blocked IP literals in any
|
|
389
|
+
* encoding, and host names that can only mean something local or private (a
|
|
390
|
+
* single-label name such as `intranet`, and the internal suffixes above).
|
|
391
|
+
* What the proxy resolves any other name to is the proxy's egress policy to
|
|
392
|
+
* enforce; that is the trade for using a proxy at all, and why this is only
|
|
393
|
+
* used when one applies. Without a proxy {@link validateAndResolveUrl} runs
|
|
394
|
+
* instead and the connection is pinned to the addresses it cleared.
|
|
395
|
+
*
|
|
396
|
+
* Same throw semantics as {@link assertSafeUrl}.
|
|
397
|
+
*/
|
|
398
|
+
export function assertSafeUrlForProxy(url) {
|
|
399
|
+
let parsed;
|
|
400
|
+
try {
|
|
401
|
+
parsed = new URL(url);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
throw new Error(`Invalid URL: "${url}"`);
|
|
405
|
+
}
|
|
406
|
+
if (parsed.protocol !== "https:") {
|
|
407
|
+
throw new Error(`Only HTTPS URLs are permitted; got "${parsed.protocol}//" in "${url}"`);
|
|
408
|
+
}
|
|
409
|
+
const host = stripBrackets(parsed.hostname).toLowerCase();
|
|
410
|
+
const literalCheck = checkHostLiteral(host);
|
|
411
|
+
if (literalCheck === null) {
|
|
412
|
+
return; // routable IP literal
|
|
413
|
+
}
|
|
414
|
+
if (literalCheck !== "not-an-ip") {
|
|
415
|
+
throw new Error(`URL "${url}" rejected: ${literalCheck}`);
|
|
416
|
+
}
|
|
417
|
+
const name = host.endsWith(".") ? host.slice(0, -1) : host;
|
|
418
|
+
if (!name.includes(".")) {
|
|
419
|
+
throw new Error(`URL "${url}" rejected: "${name}" is a single-label host name`);
|
|
420
|
+
}
|
|
421
|
+
if (INTERNAL_HOST_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`))) {
|
|
422
|
+
throw new Error(`URL "${url}" rejected: "${name}" is an internal host name`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
370
425
|
/**
|
|
371
426
|
* Validate `url` and return the resolved address set that should be used for
|
|
372
427
|
* the actual fetch (companion to `safeFetch.ts:safeDownload`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.47.
|
|
3
|
+
"version": "12.47.10",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
|
|
6
6
|
"author": {
|