@juspay/neurolink 12.47.8 → 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.
@@ -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 { MAX_IMAGE_BYTES, readBoundedBuffer } from "../../utils/sizeGuard.js";
11
- import { assertSafeUrl } from "../../utils/ssrfGuard.js";
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
- // Guard the API-returned URL before fetching (provider-returned URLs
457
- // carry the same SSRF risk as caller-supplied ones).
458
- await assertSafeUrl(first.url);
459
- const proxyFetch = createProxyFetch();
460
- const dlController = new AbortController();
461
- const dlTimeoutId = setTimeout(() => dlController.abort(), 60_000);
462
- let imgResp;
463
- try {
464
- imgResp = await proxyFetch(first.url, { signal: dlController.signal });
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
@@ -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 globalWithCache = globalThis;
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";
@@ -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
- const { url: validatedUrl, addresses } = await validateAndResolveUrl(url);
91
- const parsed = new URL(validatedUrl);
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
- const agent = await buildPinnedAgent(hostname, addresses);
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: agent,
149
+ dispatcher,
110
150
  });
111
151
  }
112
152
  finally {
113
153
  clearTimeout(timeoutId);
114
- // Close the per-request agent so the pinned connection isn't pooled.
115
- agent.close().catch(() => undefined);
116
- }
117
- if (response.status >= 300 && response.status < 400) {
118
- throw new Error(`safeDownload(${options.label}): refused to follow redirect ${response.status} → ${response.headers.get("location") ?? "<no-location>"} (for ${url})`);
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
- if (!response.ok) {
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`).
@@ -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`).