@juspay/neurolink 9.95.3 → 10.0.1

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.
@@ -25,7 +25,7 @@ import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
26
  import { logger } from "./logger.js";
27
27
  import { withTimeout } from "./errorHandling.js";
28
- import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
28
+ import { normalizeUrlForCache, redactUrlForError, sanitizeErrorCause, } from "./logSanitize.js";
29
29
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
30
30
  import { PDFProcessor } from "./pdfProcessor.js";
31
31
  /**
@@ -54,22 +54,40 @@ const DEFAULT_RETRY_DELAY = 1000; // milliseconds
54
54
  const URL_CONTENT_TYPE_TTL_MS = 60_000;
55
55
  const URL_CONTENT_TYPE_CACHE_MAX_SIZE = 512;
56
56
  const urlContentTypeCache = new Map();
57
+ /**
58
+ * Build the Map key for `urlContentTypeCache`. Delegates to the shared
59
+ * {@link normalizeUrlForCache} — this is a module-level, process-lifetime
60
+ * cache, so it must strip presigned-URL auth/signature query params (see
61
+ * `SENSITIVE_URL_QUERY_PARAM_DENYLIST`) the same way `ImageCache.normalizeUrl`
62
+ * does, folding a short hash of the stripped params into the key whenever any
63
+ * were present so two different presigned URLs for the same path don't
64
+ * collide and serve each other's cached content-type across auth contexts.
65
+ * Also strips tracking/analytics params first, so two URLs differing only by
66
+ * tracking noise (e.g. `utm_source`) still hit the same cache entry instead
67
+ * of missing each other. Falls back to the raw URL if it isn't a parseable
68
+ * absolute URL. Shared by both `getCachedUrlContentType` and
69
+ * `setCachedUrlContentType` so lookups and writes always agree on the key.
70
+ */
71
+ function cacheKeyForUrl(url) {
72
+ return normalizeUrlForCache(url);
73
+ }
57
74
  function getCachedUrlContentType(url, now) {
58
- const hit = urlContentTypeCache.get(url);
75
+ const key = cacheKeyForUrl(url);
76
+ const hit = urlContentTypeCache.get(key);
59
77
  if (hit && hit.expiresAt > now) {
60
78
  // Bump recency: Map iteration order follows insertion order, and the
61
79
  // eviction below deletes the *first* key, so a plain `get` on a hot
62
80
  // entry would leave it first in line for eviction despite being the
63
81
  // most recently used. Re-inserting turns the size-bounded FIFO below
64
82
  // into an actual LRU.
65
- urlContentTypeCache.delete(url);
66
- urlContentTypeCache.set(url, hit);
83
+ urlContentTypeCache.delete(key);
84
+ urlContentTypeCache.set(key, hit);
67
85
  return hit.contentType;
68
86
  }
69
87
  if (hit) {
70
88
  // Entry exists but its TTL has passed — treat as a miss and evict it
71
89
  // immediately rather than serving (or retaining) stale data.
72
- urlContentTypeCache.delete(url);
90
+ urlContentTypeCache.delete(key);
73
91
  }
74
92
  return undefined;
75
93
  }
@@ -89,7 +107,8 @@ function setCachedUrlContentType(url, contentType, now) {
89
107
  if (!contentType) {
90
108
  return;
91
109
  }
92
- urlContentTypeCache.set(url, {
110
+ const key = cacheKeyForUrl(url);
111
+ urlContentTypeCache.set(key, {
93
112
  contentType,
94
113
  expiresAt: now + URL_CONTENT_TYPE_TTL_MS,
95
114
  });
@@ -1436,16 +1455,25 @@ export class FileDetector {
1436
1455
  });
1437
1456
  // Drain/close the (empty) HEAD body so the connection can be reused.
1438
1457
  await head.body.dump();
1439
- const declaredLength = Number(head.headers["content-length"]);
1440
- if (Number.isFinite(declaredLength) && declaredLength > maxSize) {
1441
- throw new Error(`File too large: ${formatFileSize(declaredLength)} (max: ${formatFileSize(maxSize)})`);
1458
+ // Only trust `content-length` on a genuine 2xx response. A non-2xx
1459
+ // HEAD (redirect the dispatcher didn't follow, 403/404/405 "HEAD not
1460
+ // allowed", 5xx, …) can still carry a stale/irrelevant
1461
+ // `content-length` header — enforcing size off of that would reject
1462
+ // (or silently pass) based on the wrong body. Treat any non-2xx HEAD
1463
+ // as if the header were missing and fall through to the streaming
1464
+ // GET guard below, which enforces maxSize independently either way.
1465
+ if (head.statusCode >= 200 && head.statusCode < 300) {
1466
+ const declaredLength = Number(head.headers["content-length"]);
1467
+ if (Number.isFinite(declaredLength) && declaredLength > maxSize) {
1468
+ throw new Error(`File too large: ${formatFileSize(declaredLength)} (max: ${formatFileSize(maxSize)})`);
1469
+ }
1442
1470
  }
1443
1471
  }
1444
1472
  catch (error) {
1445
1473
  if (error instanceof Error && /File too large/.test(error.message)) {
1446
1474
  throw error;
1447
1475
  }
1448
- logger.debug(`[FileDetector] HEAD pre-flight skipped for ${url}: ${error instanceof Error ? error.message : String(error)}`);
1476
+ logger.debug(`[FileDetector] HEAD pre-flight skipped for ${redactUrlForError(url)}: ${sanitizeErrorCause(error).message}`);
1449
1477
  }
1450
1478
  }
1451
1479
  return withRetry(async () => {
@@ -33,8 +33,16 @@ export declare class ImageCache {
33
33
  */
34
34
  private parseConfigValue;
35
35
  /**
36
- * Normalize URL for consistent cache key generation
37
- * Removes tracking parameters and normalizes the URL
36
+ * Normalize URL for consistent cache key generation.
37
+ *
38
+ * Delegates to the shared {@link normalizeUrlForCache} — this normalized
39
+ * URL is used as the in-memory Map key (see `get`/`set` below), which lives
40
+ * for the process lifetime, so both tracking-param noise and presigned-URL
41
+ * auth/signature secrets must be stripped consistently with
42
+ * `fileDetector.cacheKeyForUrl`'s identical requirement. See
43
+ * `stripSensitiveUrlParamsForCacheKey`'s doc comment for why a naive
44
+ * strip-only approach (no hash suffix) would be a correctness bug, not just
45
+ * a hygiene one.
38
46
  */
39
47
  private normalizeUrl;
40
48
  /**
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { createHash } from "crypto";
16
16
  import { logger } from "./logger.js";
17
- import { redactUrlForError } from "./logSanitize.js";
17
+ import { normalizeUrlForCache, redactUrlForError } from "./logSanitize.js";
18
18
  /**
19
19
  * LRU Cache for downloaded images
20
20
  *
@@ -85,30 +85,19 @@ export class ImageCache {
85
85
  return value;
86
86
  }
87
87
  /**
88
- * Normalize URL for consistent cache key generation
89
- * Removes tracking parameters and normalizes the URL
88
+ * Normalize URL for consistent cache key generation.
89
+ *
90
+ * Delegates to the shared {@link normalizeUrlForCache} — this normalized
91
+ * URL is used as the in-memory Map key (see `get`/`set` below), which lives
92
+ * for the process lifetime, so both tracking-param noise and presigned-URL
93
+ * auth/signature secrets must be stripped consistently with
94
+ * `fileDetector.cacheKeyForUrl`'s identical requirement. See
95
+ * `stripSensitiveUrlParamsForCacheKey`'s doc comment for why a naive
96
+ * strip-only approach (no hash suffix) would be a correctness bug, not just
97
+ * a hygiene one.
90
98
  */
91
99
  normalizeUrl(url) {
92
- try {
93
- const parsed = new URL(url);
94
- // Remove common tracking parameters that don't affect content
95
- const trackingParams = [
96
- "utm_source",
97
- "utm_medium",
98
- "utm_campaign",
99
- "utm_term",
100
- "utm_content",
101
- "fbclid",
102
- "gclid",
103
- "_ga",
104
- ];
105
- trackingParams.forEach((param) => parsed.searchParams.delete(param));
106
- return parsed.toString();
107
- }
108
- catch {
109
- // If URL parsing fails, use the original URL
110
- return url;
111
- }
100
+ return normalizeUrlForCache(url);
112
101
  }
113
102
  /**
114
103
  * Generate content hash from image data
@@ -13,6 +13,31 @@ export declare const MIN_IMAGE_SIZE = 12;
13
13
  * buffer that carries a format's magic bytes but is smaller than this is
14
14
  * truncated/corrupt and would fail (or render blank) downstream — reject early
15
15
  * with a clear message instead.
16
+ *
17
+ * Each threshold approximates the smallest byte count that format's
18
+ * container can structurally be while still holding the mandatory
19
+ * header/chunk skeleton for a minimal (e.g. 1×1 pixel) image — not an exact
20
+ * spec minimum, but close enough that anything smaller is unambiguously
21
+ * truncated:
22
+ * - **PNG (67)**: 8-byte signature + IHDR chunk (4 len + 4 type + 13 data +
23
+ * 4 CRC = 25) + a minimal IDAT chunk carrying one zlib-compressed
24
+ * scanline (~22 bytes) + IEND chunk (4 len + 4 type + 0 data + 4 CRC =
25
+ * 12) — matches the commonly-cited size of the smallest possible valid
26
+ * PNG (a 1×1 transparent pixel).
27
+ * - **JPEG (125)**: SOI + APP0/JFIF marker (18) + at least one DQT
28
+ * quantization table (~69 for one 8-bit luma table) + SOF0 + DHT + SOS
29
+ * headers + a minimal entropy-coded scan + EOI — the smallest legal
30
+ * baseline-JPEG skeleton for a 1×1 image.
31
+ * - **GIF (43)**: 6-byte GIF87a/89a header + 7-byte Logical Screen
32
+ * Descriptor + a 2-color (6-byte) color table + 10-byte Image
33
+ * Descriptor + minimal LZW-coded image sub-blocks + 1-byte trailer.
34
+ * - **WEBP (20)**: RIFF container header ("RIFF" + size + "WEBP" = 12
35
+ * bytes) + the first chunk's fourCC + size (8 bytes) — enough to confirm
36
+ * a well-formed RIFF/WEBP container and its first chunk, before any
37
+ * actual VP8/VP8L bitstream payload.
38
+ * - **AVIF (100)**: ISOBMFF `ftyp` box plus the mandatory `meta` box
39
+ * skeleton (`hdlr`/`pitm`/`iloc`/`iinf` sub-boxes) — roughly the minimum
40
+ * to hold AVIF's required box structure before any AV1 image item data.
16
41
  */
17
42
  export declare const MIN_VALID_IMAGE_SIZE: Record<string, number>;
18
43
  /**
@@ -20,6 +20,31 @@ export const MIN_IMAGE_SIZE = 12;
20
20
  * buffer that carries a format's magic bytes but is smaller than this is
21
21
  * truncated/corrupt and would fail (or render blank) downstream — reject early
22
22
  * with a clear message instead.
23
+ *
24
+ * Each threshold approximates the smallest byte count that format's
25
+ * container can structurally be while still holding the mandatory
26
+ * header/chunk skeleton for a minimal (e.g. 1×1 pixel) image — not an exact
27
+ * spec minimum, but close enough that anything smaller is unambiguously
28
+ * truncated:
29
+ * - **PNG (67)**: 8-byte signature + IHDR chunk (4 len + 4 type + 13 data +
30
+ * 4 CRC = 25) + a minimal IDAT chunk carrying one zlib-compressed
31
+ * scanline (~22 bytes) + IEND chunk (4 len + 4 type + 0 data + 4 CRC =
32
+ * 12) — matches the commonly-cited size of the smallest possible valid
33
+ * PNG (a 1×1 transparent pixel).
34
+ * - **JPEG (125)**: SOI + APP0/JFIF marker (18) + at least one DQT
35
+ * quantization table (~69 for one 8-bit luma table) + SOF0 + DHT + SOS
36
+ * headers + a minimal entropy-coded scan + EOI — the smallest legal
37
+ * baseline-JPEG skeleton for a 1×1 image.
38
+ * - **GIF (43)**: 6-byte GIF87a/89a header + 7-byte Logical Screen
39
+ * Descriptor + a 2-color (6-byte) color table + 10-byte Image
40
+ * Descriptor + minimal LZW-coded image sub-blocks + 1-byte trailer.
41
+ * - **WEBP (20)**: RIFF container header ("RIFF" + size + "WEBP" = 12
42
+ * bytes) + the first chunk's fourCC + size (8 bytes) — enough to confirm
43
+ * a well-formed RIFF/WEBP container and its first chunk, before any
44
+ * actual VP8/VP8L bitstream payload.
45
+ * - **AVIF (100)**: ISOBMFF `ftyp` box plus the mandatory `meta` box
46
+ * skeleton (`hdlr`/`pitm`/`iloc`/`iinf` sub-boxes) — roughly the minimum
47
+ * to hold AVIF's required box structure before any AV1 image item data.
23
48
  */
24
49
  export const MIN_VALID_IMAGE_SIZE = {
25
50
  "image/png": 67,
@@ -932,7 +957,9 @@ export const imageUtils = {
932
957
  const cache = getImageCache();
933
958
  const cached = cache.get(url);
934
959
  if (cached) {
935
- logger.debug("Using cached image for URL", { url: url.substring(0, 50) });
960
+ logger.debug("Using cached image for URL", {
961
+ url: redactUrlForError(url),
962
+ });
936
963
  return cached.dataUri;
937
964
  }
938
965
  // Apply rate limiting before download
@@ -110,6 +110,103 @@ export declare function redactUrlCredentials(url: string): string;
110
110
  * @param url - The URL (or URL-shaped string) to redact.
111
111
  */
112
112
  export declare function redactUrlForError(url: string): string;
113
+ /**
114
+ * Query-parameter names that commonly carry authentication/signature
115
+ * secrets in presigned URLs, matched case-insensitively: generic
116
+ * `token`/`signature`, AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`,
117
+ * `X-Amz-Security-Token`, and the legacy `AWSAccessKeyId`/`Signature`/
118
+ * `Expires` query-auth params), Azure SAS (`sig`/`se`/`sp`/`sr`/`sv`), and
119
+ * GCS V4 signed URLs (`X-Goog-Signature`, `X-Goog-Credential`,
120
+ * `X-Goog-Algorithm`, `X-Goog-Date`, `X-Goog-Expires`,
121
+ * `X-Goog-SignedHeaders`).
122
+ *
123
+ * Unlike {@link redactUrlForError} (which drops the *entire* query string
124
+ * for a one-off error message), this is used where a URL is normalized
125
+ * into a long-lived in-memory cache key — content-varying query params
126
+ * must survive so distinct resources still get distinct keys, but a
127
+ * presigned URL's secret must never persist as a Map key for the process
128
+ * lifetime.
129
+ */
130
+ export declare const SENSITIVE_URL_QUERY_PARAM_DENYLIST: readonly string[];
131
+ /**
132
+ * Strip common tracking/analytics query params (`utm_*`, `fbclid`, `gclid`,
133
+ * `_ga`) from `parsed` in place, so callers that build a cache key from a
134
+ * URL don't miss a cache hit solely because two otherwise-identical URLs
135
+ * carry different tracking noise.
136
+ *
137
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` —
138
+ * both build a long-lived in-memory cache key from a URL and should
139
+ * normalize tracking params the same way. Call this before
140
+ * {@link stripSensitiveUrlParamsForCacheKey} so both normalizations apply
141
+ * consistently regardless of caller.
142
+ *
143
+ * @param parsed - A `URL` instance to strip tracking params from in place.
144
+ */
145
+ export declare function stripTrackingParams(parsed: URL): void;
146
+ /**
147
+ * Build a secret-free, but still auth-context-distinct, cache key from a
148
+ * URL that may carry presigned-URL secrets in its query string.
149
+ *
150
+ * Naively stripping {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} params before
151
+ * using a URL as a long-lived cache key (an earlier version of this code did
152
+ * exactly that) creates a correctness bug, not just a hygiene one: two
153
+ * *different* presigned URLs for the same underlying object — e.g. one
154
+ * whose signature has since expired or been revoked, and a fresh one —
155
+ * strip down to the *same* key and collide. A cache populated under the
156
+ * first would silently serve its bytes back for the second, without ever
157
+ * revalidating against the origin.
158
+ *
159
+ * Fix: still remove the sensitive params from `parsed` in place (so the
160
+ * plaintext key never retains a raw secret — the original goal), but fold a
161
+ * short, non-reversible hash of the *removed* key=value pairs into the
162
+ * returned suffix whenever any were present, so distinct auth contexts keep
163
+ * distinct cache keys. Two requests presenting the exact same secret value
164
+ * still collide (correct — that's genuinely the same authorized request);
165
+ * two different secret values for the same object no longer do.
166
+ *
167
+ * Callers combine the return value with the now-stripped `parsed.toString()`
168
+ * to form the final cache key (see `ImageCache.normalizeUrl` and
169
+ * `fileDetector.cacheKeyForUrl`), rather than this function returning the
170
+ * full key itself, so each caller can apply its own additional
171
+ * normalization (e.g. tracking-param removal) in between.
172
+ *
173
+ * Matching against {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} is
174
+ * case-insensitive (both the denylist lookup and the value folded into the
175
+ * hash lower-case the param name), so the original casing of a sensitive
176
+ * param's name is not preserved anywhere in the returned key. That's fine
177
+ * here — this produces a cache key, not a user-facing URL, and the same
178
+ * lower-cased name always hashes to the same suffix for the same value
179
+ * regardless of how the caller happened to case it (`Token=` vs `token=`).
180
+ * Non-sensitive params left on `parsed` keep their original casing untouched.
181
+ *
182
+ * @param parsed - A `URL` instance to strip sensitive params from in place.
183
+ * @returns "" when no sensitive params were present; otherwise a short hex
184
+ * suffix — prefixed with `\0` (a byte `URL#toString()` never emits, since
185
+ * every URL component is percent-encoded, so it can't collide with real
186
+ * URL content) — to append to the caller's cache key.
187
+ */
188
+ export declare function stripSensitiveUrlParamsForCacheKey(parsed: URL): string;
189
+ /**
190
+ * Build a secret-free, auth-context-distinct cache key from a URL, for any
191
+ * caller that needs a long-lived in-memory Map key derived from a URL.
192
+ *
193
+ * Centralizes the exact sequence both existing callers used independently:
194
+ * parse via `new URL()`, strip tracking noise ({@link stripTrackingParams}),
195
+ * then strip and hash-suffix sensitive auth params
196
+ * ({@link stripSensitiveUrlParamsForCacheKey}) so distinct presigned URLs for
197
+ * the same object don't collide (see that function's doc comment for why a
198
+ * naive strip-only approach is a correctness bug, not just a hygiene one).
199
+ *
200
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` — both
201
+ * build a cache key from a URL and must normalize it identically, or the same
202
+ * URL could hash to two different keys depending on which cache looked it up.
203
+ *
204
+ * Falls back to the raw `url` string unchanged when it isn't a parseable
205
+ * absolute URL (mirrors both callers' pre-existing catch behavior).
206
+ *
207
+ * @param url - The URL (or URL-shaped string) to normalize into a cache key.
208
+ */
209
+ export declare function normalizeUrlForCache(url: string): string;
113
210
  /**
114
211
  * Scrub any embedded absolute URLs out of free-form text — typically the
115
212
  * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
@@ -15,6 +15,7 @@
15
15
  * jina_ (Jina), fish- (Fish Audio)
16
16
  * - Generic key=value pairs: api_key=…, access_token: …, secret_key=…
17
17
  */
18
+ import { createHash } from "crypto";
18
19
  import { basename, resolve as resolvePath } from "path";
19
20
  const TOKEN_PREFIXES = [
20
21
  "sk",
@@ -227,6 +228,168 @@ export function redactUrlForError(url) {
227
228
  return truncate(redactUrlCredentials(withoutQueryOrFragment));
228
229
  }
229
230
  }
231
+ /**
232
+ * Query-parameter names that commonly carry authentication/signature
233
+ * secrets in presigned URLs, matched case-insensitively: generic
234
+ * `token`/`signature`, AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`,
235
+ * `X-Amz-Security-Token`, and the legacy `AWSAccessKeyId`/`Signature`/
236
+ * `Expires` query-auth params), Azure SAS (`sig`/`se`/`sp`/`sr`/`sv`), and
237
+ * GCS V4 signed URLs (`X-Goog-Signature`, `X-Goog-Credential`,
238
+ * `X-Goog-Algorithm`, `X-Goog-Date`, `X-Goog-Expires`,
239
+ * `X-Goog-SignedHeaders`).
240
+ *
241
+ * Unlike {@link redactUrlForError} (which drops the *entire* query string
242
+ * for a one-off error message), this is used where a URL is normalized
243
+ * into a long-lived in-memory cache key — content-varying query params
244
+ * must survive so distinct resources still get distinct keys, but a
245
+ * presigned URL's secret must never persist as a Map key for the process
246
+ * lifetime.
247
+ */
248
+ export const SENSITIVE_URL_QUERY_PARAM_DENYLIST = [
249
+ "token",
250
+ "signature",
251
+ "x-amz-signature",
252
+ "x-amz-credential",
253
+ "x-amz-security-token",
254
+ "awsaccesskeyid",
255
+ "expires",
256
+ "sig",
257
+ "se",
258
+ "sp",
259
+ "sr",
260
+ "sv",
261
+ "x-goog-signature",
262
+ "x-goog-credential",
263
+ "x-goog-algorithm",
264
+ "x-goog-date",
265
+ "x-goog-expires",
266
+ "x-goog-signedheaders",
267
+ ];
268
+ /** Common tracking/analytics query params that don't affect the fetched
269
+ * content, so two URLs differing only by these should map to the same
270
+ * cache key rather than missing each other. */
271
+ const TRACKING_URL_QUERY_PARAMS = [
272
+ "utm_source",
273
+ "utm_medium",
274
+ "utm_campaign",
275
+ "utm_term",
276
+ "utm_content",
277
+ "fbclid",
278
+ "gclid",
279
+ "_ga",
280
+ ];
281
+ /**
282
+ * Strip common tracking/analytics query params (`utm_*`, `fbclid`, `gclid`,
283
+ * `_ga`) from `parsed` in place, so callers that build a cache key from a
284
+ * URL don't miss a cache hit solely because two otherwise-identical URLs
285
+ * carry different tracking noise.
286
+ *
287
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` —
288
+ * both build a long-lived in-memory cache key from a URL and should
289
+ * normalize tracking params the same way. Call this before
290
+ * {@link stripSensitiveUrlParamsForCacheKey} so both normalizations apply
291
+ * consistently regardless of caller.
292
+ *
293
+ * @param parsed - A `URL` instance to strip tracking params from in place.
294
+ */
295
+ export function stripTrackingParams(parsed) {
296
+ TRACKING_URL_QUERY_PARAMS.forEach((param) => parsed.searchParams.delete(param));
297
+ }
298
+ /**
299
+ * Build a secret-free, but still auth-context-distinct, cache key from a
300
+ * URL that may carry presigned-URL secrets in its query string.
301
+ *
302
+ * Naively stripping {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} params before
303
+ * using a URL as a long-lived cache key (an earlier version of this code did
304
+ * exactly that) creates a correctness bug, not just a hygiene one: two
305
+ * *different* presigned URLs for the same underlying object — e.g. one
306
+ * whose signature has since expired or been revoked, and a fresh one —
307
+ * strip down to the *same* key and collide. A cache populated under the
308
+ * first would silently serve its bytes back for the second, without ever
309
+ * revalidating against the origin.
310
+ *
311
+ * Fix: still remove the sensitive params from `parsed` in place (so the
312
+ * plaintext key never retains a raw secret — the original goal), but fold a
313
+ * short, non-reversible hash of the *removed* key=value pairs into the
314
+ * returned suffix whenever any were present, so distinct auth contexts keep
315
+ * distinct cache keys. Two requests presenting the exact same secret value
316
+ * still collide (correct — that's genuinely the same authorized request);
317
+ * two different secret values for the same object no longer do.
318
+ *
319
+ * Callers combine the return value with the now-stripped `parsed.toString()`
320
+ * to form the final cache key (see `ImageCache.normalizeUrl` and
321
+ * `fileDetector.cacheKeyForUrl`), rather than this function returning the
322
+ * full key itself, so each caller can apply its own additional
323
+ * normalization (e.g. tracking-param removal) in between.
324
+ *
325
+ * Matching against {@link SENSITIVE_URL_QUERY_PARAM_DENYLIST} is
326
+ * case-insensitive (both the denylist lookup and the value folded into the
327
+ * hash lower-case the param name), so the original casing of a sensitive
328
+ * param's name is not preserved anywhere in the returned key. That's fine
329
+ * here — this produces a cache key, not a user-facing URL, and the same
330
+ * lower-cased name always hashes to the same suffix for the same value
331
+ * regardless of how the caller happened to case it (`Token=` vs `token=`).
332
+ * Non-sensitive params left on `parsed` keep their original casing untouched.
333
+ *
334
+ * @param parsed - A `URL` instance to strip sensitive params from in place.
335
+ * @returns "" when no sensitive params were present; otherwise a short hex
336
+ * suffix — prefixed with `\0` (a byte `URL#toString()` never emits, since
337
+ * every URL component is percent-encoded, so it can't collide with real
338
+ * URL content) — to append to the caller's cache key.
339
+ */
340
+ export function stripSensitiveUrlParamsForCacheKey(parsed) {
341
+ const strippedPairs = [];
342
+ const keysToDelete = [];
343
+ for (const [key, value] of parsed.searchParams.entries()) {
344
+ if (SENSITIVE_URL_QUERY_PARAM_DENYLIST.includes(key.toLowerCase())) {
345
+ strippedPairs.push(`${key.toLowerCase()}=${value}`);
346
+ keysToDelete.push(key);
347
+ }
348
+ }
349
+ if (strippedPairs.length === 0) {
350
+ return "";
351
+ }
352
+ keysToDelete.forEach((key) => parsed.searchParams.delete(key));
353
+ // Sort so key order in the original URL doesn't affect the hash — the
354
+ // same *set* of secret values must always hash to the same suffix.
355
+ strippedPairs.sort();
356
+ const hash = createHash("sha256")
357
+ .update(strippedPairs.join("&"))
358
+ .digest("hex")
359
+ .slice(0, 16);
360
+ return `\0auth=${hash}`;
361
+ }
362
+ /**
363
+ * Build a secret-free, auth-context-distinct cache key from a URL, for any
364
+ * caller that needs a long-lived in-memory Map key derived from a URL.
365
+ *
366
+ * Centralizes the exact sequence both existing callers used independently:
367
+ * parse via `new URL()`, strip tracking noise ({@link stripTrackingParams}),
368
+ * then strip and hash-suffix sensitive auth params
369
+ * ({@link stripSensitiveUrlParamsForCacheKey}) so distinct presigned URLs for
370
+ * the same object don't collide (see that function's doc comment for why a
371
+ * naive strip-only approach is a correctness bug, not just a hygiene one).
372
+ *
373
+ * Shared by `ImageCache.normalizeUrl` and `fileDetector.cacheKeyForUrl` — both
374
+ * build a cache key from a URL and must normalize it identically, or the same
375
+ * URL could hash to two different keys depending on which cache looked it up.
376
+ *
377
+ * Falls back to the raw `url` string unchanged when it isn't a parseable
378
+ * absolute URL (mirrors both callers' pre-existing catch behavior).
379
+ *
380
+ * @param url - The URL (or URL-shaped string) to normalize into a cache key.
381
+ */
382
+ export function normalizeUrlForCache(url) {
383
+ try {
384
+ const parsed = new URL(url);
385
+ stripTrackingParams(parsed);
386
+ const authSuffix = stripSensitiveUrlParamsForCacheKey(parsed);
387
+ return parsed.toString() + authSuffix;
388
+ }
389
+ catch {
390
+ return url;
391
+ }
392
+ }
230
393
  /**
231
394
  * Scrub any embedded absolute URLs out of free-form text — typically the
232
395
  * `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
2
  import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
3
+ import { basename } from "path";
3
4
  import { getGlobalDispatcher, interceptors, request } from "undici";
4
5
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
5
6
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
@@ -1031,8 +1032,26 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1031
1032
  // it in aggregate (e.g. three 40-page PDFs → 120 pages for a 100-page API).
1032
1033
  const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1033
1034
  if (aggregateConfig && pdfFiles.length > 1) {
1035
+ // A null pageCount (accurate count unavailable — see
1036
+ // PDFProcessor.getAccuratePageCount) is treated as 0 in the sum below,
1037
+ // which can undercount the aggregate and let a combined request over
1038
+ // the provider's page limit slip through silently. Enforcement still
1039
+ // runs against the known sum — a PDF with an unknown count must not
1040
+ // fail the request outright — but the gap itself must not be silent.
1041
+ const unknownPageCountFiles = pdfFiles.filter((f) => f.pageCount === null || f.pageCount === undefined);
1034
1042
  const totalPages = pdfFiles.reduce((sum, f) => sum + (f.pageCount ?? 0), 0);
1035
1043
  const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1044
+ if (unknownPageCountFiles.length > 0) {
1045
+ // Filenames are caller-controlled and may be full paths carrying
1046
+ // PII/internal directory segments — log only the basename.
1047
+ const unknownFileNames = unknownPageCountFiles
1048
+ .map((f) => (f.filename ? basename(f.filename) : "<unnamed file>"))
1049
+ .join(", ");
1050
+ logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1051
+ `partially verified: ${unknownPageCountFiles.length} file(s) have an unknown ` +
1052
+ `page count (${unknownFileNames}), so the known total (${totalPages}) may ` +
1053
+ `undercount the true combined page count.`);
1054
+ }
1036
1055
  if (totalPages > aggregateConfig.maxPages) {
1037
1056
  throw new Error(`[PDF] Combined page count across ${pdfFiles.length} PDFs (${totalPages}) exceeds the ` +
1038
1057
  `${aggregateConfig.maxPages}-page limit for ${provider}. ` +
@@ -81,7 +81,13 @@ export declare class PDFProcessor {
81
81
  * base64 PNG as soon as it renders instead of buffering the whole document,
82
82
  * and reports progress via `options.onProgress`. A page that fails to render
83
83
  * is yielded with `error` set (per-page isolation, #294) rather than aborting
84
- * the stream. `convertToImages` is the batch wrapper over this contract.
84
+ * the stream.
85
+ *
86
+ * NOT a wrapper of/over {@link convertToImages}, despite the similar
87
+ * contract — the two are independent, parallel implementations (each does
88
+ * its own `pdf-to-img` import, downscale calculation, and page loop)
89
+ * rather than one delegating to the other. Keep behavior changes (page
90
+ * isolation, downscale, password handling) in sync across both by hand.
85
91
  */
86
92
  static convertToImagesStream(pdfBuffer: Buffer, options?: PDFImageConversionOptions): AsyncGenerator<PDFImagePage, void, void>;
87
93
  /**
@@ -243,16 +243,34 @@ export class PDFProcessor {
243
243
  try {
244
244
  const { PDFParse } = await import("pdf-parse");
245
245
  const pdf = new PDFParse({ data: new Uint8Array(buffer) });
246
+ // Captured outside the try so the `finally` below can always clear it
247
+ // — otherwise a `getInfo()` that wins the race leaves this timer
248
+ // running until PAGE_COUNT_TIMEOUT_MS fires for nothing. `.unref()`
249
+ // so it can never itself hold the process open in the meantime.
250
+ let timeoutHandle;
246
251
  try {
247
252
  const info = await Promise.race([
248
253
  pdf.getInfo(),
249
- new Promise((_, reject) => setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS)),
254
+ new Promise((_, reject) => {
255
+ timeoutHandle = setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS);
256
+ timeoutHandle.unref?.();
257
+ }),
250
258
  ]);
251
259
  const total = info.total;
252
260
  return typeof total === "number" && total > 0 ? total : null;
253
261
  }
254
262
  finally {
255
- await pdf.destroy?.();
263
+ if (timeoutHandle !== undefined) {
264
+ clearTimeout(timeoutHandle);
265
+ }
266
+ try {
267
+ await pdf.destroy?.();
268
+ }
269
+ catch {
270
+ // A throwing destroy() must not discard an otherwise-valid page
271
+ // count returned above (matches fileReferenceRegistry.ts's
272
+ // pdf.destroy() cleanup pattern) — swallow it.
273
+ }
256
274
  }
257
275
  }
258
276
  catch (error) {
@@ -518,7 +536,13 @@ export class PDFProcessor {
518
536
  * base64 PNG as soon as it renders instead of buffering the whole document,
519
537
  * and reports progress via `options.onProgress`. A page that fails to render
520
538
  * is yielded with `error` set (per-page isolation, #294) rather than aborting
521
- * the stream. `convertToImages` is the batch wrapper over this contract.
539
+ * the stream.
540
+ *
541
+ * NOT a wrapper of/over {@link convertToImages}, despite the similar
542
+ * contract — the two are independent, parallel implementations (each does
543
+ * its own `pdf-to-img` import, downscale calculation, and page loop)
544
+ * rather than one delegating to the other. Keep behavior changes (page
545
+ * isolation, downscale, password handling) in sync across both by hand.
522
546
  */
523
547
  static async *convertToImagesStream(pdfBuffer, options) {
524
548
  const startTime = Date.now();