@juspay/neurolink 9.94.3 → 9.94.5

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.
@@ -34,6 +34,7 @@ export declare const ERROR_CODES: {
34
34
  readonly IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE";
35
35
  readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
36
36
  readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
37
+ readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
37
38
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
38
39
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
39
40
  readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
@@ -199,6 +200,14 @@ export declare class ErrorFactory {
199
200
  * Create an image too large error
200
201
  */
201
202
  static imageTooLarge(sizeMB: string, maxMB: string): NeuroLinkError;
203
+ /**
204
+ * Create an invalid image size error (NaN/Infinity/negative byte length).
205
+ * Distinct from `imageTooLarge` — this rejects a malformed size value
206
+ * before it reaches the max-size comparison, since `NaN > maxSize` and
207
+ * `-1 > maxSize` both evaluate to `false` and would otherwise let a
208
+ * corrupted stat/header value silently skip the guard.
209
+ */
210
+ static invalidImageSize(size: number): NeuroLinkError;
202
211
  /**
203
212
  * Create an image too small error
204
213
  */
@@ -45,6 +45,7 @@ export const ERROR_CODES = {
45
45
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
46
46
  IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
47
47
  INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
48
+ INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
48
49
  // PDF validation errors
49
50
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
50
51
  // Rate limiter errors
@@ -570,6 +571,26 @@ export class ErrorFactory {
570
571
  },
571
572
  });
572
573
  }
574
+ /**
575
+ * Create an invalid image size error (NaN/Infinity/negative byte length).
576
+ * Distinct from `imageTooLarge` — this rejects a malformed size value
577
+ * before it reaches the max-size comparison, since `NaN > maxSize` and
578
+ * `-1 > maxSize` both evaluate to `false` and would otherwise let a
579
+ * corrupted stat/header value silently skip the guard.
580
+ */
581
+ static invalidImageSize(size) {
582
+ return new NeuroLinkError({
583
+ code: ERROR_CODES.INVALID_IMAGE_SIZE,
584
+ message: `Invalid image size: ${size} (must be a finite, non-negative number)`,
585
+ category: ErrorCategory.VALIDATION,
586
+ severity: ErrorSeverity.MEDIUM,
587
+ retriable: false,
588
+ context: {
589
+ field: "input.images",
590
+ size,
591
+ },
592
+ });
593
+ }
573
594
  /**
574
595
  * Create an image too small error
575
596
  */
@@ -4,7 +4,7 @@
4
4
  * Uses multi-strategy approach for reliable type identification
5
5
  */
6
6
  import { open, readFile, realpath } from "fs/promises";
7
- import { isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
7
+ import { basename, isAbsolute as isAbsolutePath, relative as relativePath, resolve as resolvePath, sep, } from "path";
8
8
  import { getGlobalDispatcher, interceptors, request } from "undici";
9
9
  // Lazy-loaded processor singletons — avoids loading heavy media deps
10
10
  // (mediabunny, fluent-ffmpeg, music-metadata, adm-zip) on every generate() call.
@@ -24,6 +24,7 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
24
24
  import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
26
  import { logger } from "./logger.js";
27
+ import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
27
28
  import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
28
29
  import { PDFProcessor } from "./pdfProcessor.js";
29
30
  /**
@@ -1338,25 +1339,55 @@ export class FileDetector {
1338
1339
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
1339
1340
  const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
1340
1341
  return withRetry(async () => {
1341
- const response = await request(url, {
1342
- dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1343
- method: "GET",
1344
- headersTimeout: timeout,
1345
- bodyTimeout: timeout,
1346
- });
1347
- if (response.statusCode !== 200) {
1348
- throw new Error(`HTTP ${response.statusCode} fetching ${url}`);
1349
- }
1350
- const chunks = [];
1351
- let totalSize = 0;
1352
- for await (const chunk of response.body) {
1353
- totalSize += chunk.length;
1354
- if (totalSize > maxSize) {
1355
- throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1342
+ try {
1343
+ const response = await request(url, {
1344
+ dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
1345
+ method: "GET",
1346
+ headersTimeout: timeout,
1347
+ bodyTimeout: timeout,
1348
+ });
1349
+ if (response.statusCode !== 200) {
1350
+ // Query string / fragment stripped — a presigned URL's token must
1351
+ // not be echoed into a thrown error.
1352
+ throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
1356
1353
  }
1357
- chunks.push(chunk);
1354
+ const chunks = [];
1355
+ let totalSize = 0;
1356
+ for await (const chunk of response.body) {
1357
+ totalSize += chunk.length;
1358
+ if (totalSize > maxSize) {
1359
+ throw new Error(`File too large: ${formatFileSize(totalSize)} (max: ${formatFileSize(maxSize)})`);
1360
+ }
1361
+ chunks.push(chunk);
1362
+ }
1363
+ return Buffer.concat(chunks);
1364
+ }
1365
+ catch (error) {
1366
+ // Node/undici DNS, TLS, and connect-timeout errors embed the full
1367
+ // request URL (including a presigned query token) in
1368
+ // `error.message`. Redact into a NEW error instead of mutating the
1369
+ // original in place, so anything that still holds a reference to
1370
+ // the original — debug logs, telemetry spans, upstream callers —
1371
+ // keeps seeing the real message. `.code` is copied onto the new
1372
+ // error so `isRetryableNetworkError`'s retry check in the outer
1373
+ // `withRetry` catch still classifies it correctly. The raw
1374
+ // original error is NEVER attached as `cause` — that would leave
1375
+ // the unredacted URL reachable via `error.cause.message` for
1376
+ // anything that walks the cause chain (cause-aware logging,
1377
+ // telemetry). `cause` instead gets its own sanitized copy.
1378
+ // `sanitizeErrorCause` handles non-`Error` thrown values too (a raw
1379
+ // string/object can just as easily carry the full URL), so there is
1380
+ // no unconditional `throw error` fallback that would bypass
1381
+ // redaction for that case.
1382
+ const cause = sanitizeErrorCause(error);
1383
+ const redacted = new Error(cause.message, { cause });
1384
+ redacted.name = cause.name;
1385
+ const code = cause.code;
1386
+ if (code !== undefined) {
1387
+ redacted.code = code;
1388
+ }
1389
+ throw redacted;
1358
1390
  }
1359
- return Buffer.concat(chunks);
1360
1391
  }, { maxRetries, retryDelay });
1361
1392
  }
1362
1393
  /**
@@ -1374,6 +1405,13 @@ export class FileDetector {
1374
1405
  // the base dir pointing outside cannot bypass containment. The
1375
1406
  // path.relative check (not a string prefix) correctly handles the root dir
1376
1407
  // ("/") and sibling-prefix ("/app" vs "/app-evil") edge cases.
1408
+ //
1409
+ // The actual open() below MUST target this validated `real` path, not the
1410
+ // original `filePath` — otherwise a symlink swapped between the realpath()
1411
+ // check and the open() call routes the read outside the sandbox even
1412
+ // though validation passed (TOCTOU). With no sandbox configured there's no
1413
+ // boundary to defend, so the original path is used as given.
1414
+ let pathToOpen = filePath;
1377
1415
  if (options?.allowedBaseDir) {
1378
1416
  let base;
1379
1417
  let real;
@@ -1381,24 +1419,66 @@ export class FileDetector {
1381
1419
  base = await realpath(resolvePath(options.allowedBaseDir));
1382
1420
  real = await realpath(filePath);
1383
1421
  }
1384
- catch {
1385
- throw new Error(`Access denied: "${filePath}" could not be resolved within the allowed base directory`);
1422
+ catch (error) {
1423
+ // Full path stays in the debug log; the thrown (potentially
1424
+ // client-facing) error only gets the basename to avoid leaking the
1425
+ // host's directory layout to an untrusted caller. The cause is
1426
+ // sanitized too — Node's realpath ENOENT/EACCES messages embed the
1427
+ // full path verbatim, which would otherwise survive on the cause
1428
+ // chain (cause-aware logging, telemetry) even though the outer
1429
+ // message is already redacted.
1430
+ logger.debug("loadFromPath: realpath resolution failed", {
1431
+ filePath,
1432
+ error,
1433
+ });
1434
+ // Assigned to a variable before the throw (rather than an inline
1435
+ // `{ cause: sanitizeErrorCause(...) }`) so the sanitized, path-redacted
1436
+ // copy is unambiguously the attached cause — the raw `error`, whose
1437
+ // message still embeds the full path, is never reachable from the
1438
+ // thrown result.
1439
+ const cause = sanitizeErrorCause(error, { filePath });
1440
+ const denied = new Error(`Access denied: "${basename(filePath)}" could not be resolved within the allowed base directory`, { cause });
1441
+ throw denied;
1386
1442
  }
1387
1443
  const rel = relativePath(base, real);
1388
1444
  if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolutePath(rel)) {
1389
- throw new Error(`Access denied: "${filePath}" resolves outside the allowed base directory`);
1445
+ logger.debug("loadFromPath: path resolves outside allowed base dir", {
1446
+ filePath,
1447
+ real,
1448
+ });
1449
+ throw new Error(`Access denied: "${basename(filePath)}" resolves outside the allowed base directory`);
1390
1450
  }
1451
+ pathToOpen = real;
1391
1452
  }
1392
1453
  // Open a handle and stat/read through the SAME descriptor so a symlink
1393
1454
  // swap between the size check and the read cannot occur (TOCTOU).
1394
- const handle = await open(filePath, "r");
1455
+ let handle;
1456
+ try {
1457
+ handle = await open(pathToOpen, "r");
1458
+ }
1459
+ catch (error) {
1460
+ // A failed open (ENOENT/EACCES/…) embeds the opened path verbatim in
1461
+ // its message. When a sandbox is configured `pathToOpen` is the
1462
+ // realpath-resolved target (`real`), which differs from both `filePath`
1463
+ // and its resolved form — so redact `pathToOpen` specifically, or the
1464
+ // full host path would survive on both the thrown message and the cause
1465
+ // chain despite this PR's path-redaction hardening.
1466
+ const cause = sanitizeErrorCause(error, { filePath: pathToOpen });
1467
+ const failed = new Error(cause.message, { cause });
1468
+ failed.name = cause.name;
1469
+ const code = cause.code;
1470
+ if (code !== undefined) {
1471
+ failed.code = code;
1472
+ }
1473
+ throw failed;
1474
+ }
1395
1475
  try {
1396
1476
  const statInfo = await handle.stat();
1397
1477
  if (!statInfo.isFile()) {
1398
- throw new Error(`Not a file: ${filePath}`);
1478
+ throw new Error(`Not a file: ${basename(filePath)}`);
1399
1479
  }
1400
1480
  if (statInfo.size > maxSize) {
1401
- throw new Error(`File too large: ${filePath} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1481
+ throw new Error(`File too large: ${basename(filePath)} is ${formatFileSize(statInfo.size)} (max: ${formatFileSize(maxSize)})`);
1402
1482
  }
1403
1483
  return await handle.readFile();
1404
1484
  }
@@ -1450,6 +1530,15 @@ class MagicBytesStrategy {
1450
1530
  input[6] === 0x79 &&
1451
1531
  input[7] === 0x70) {
1452
1532
  const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1533
+ // AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
1534
+ // ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
1535
+ // — spec-listed under compatible_brands but also emitted as
1536
+ // major_brand by real encoders) distinguishes them. Detect before the
1537
+ // audio/video branches so an AVIF buffer isn't misrouted to the video
1538
+ // pipeline (#286).
1539
+ if (/^(avif|avis|avio)/.test(brand)) {
1540
+ return this.result("image", "image/avif", 95);
1541
+ }
1453
1542
  if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1454
1543
  return this.result("audio", "audio/mp4", 95);
1455
1544
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createHash } from "crypto";
16
16
  import { logger } from "./logger.js";
17
+ import { redactUrlForError } from "./logSanitize.js";
17
18
  /**
18
19
  * LRU Cache for downloaded images
19
20
  *
@@ -142,7 +143,9 @@ export class ImageCache {
142
143
  const entry = this.cache.get(normalizedUrl);
143
144
  if (!entry) {
144
145
  this.stats.misses++;
145
- logger.debug("Image cache miss", { url: normalizedUrl.substring(0, 50) });
146
+ logger.debug("Image cache miss", {
147
+ url: redactUrlForError(normalizedUrl),
148
+ });
146
149
  return null;
147
150
  }
148
151
  // Check TTL expiration
@@ -150,7 +153,7 @@ export class ImageCache {
150
153
  this.stats.expirations++;
151
154
  this.delete(normalizedUrl);
152
155
  logger.debug("Image cache entry expired", {
153
- url: normalizedUrl.substring(0, 50),
156
+ url: redactUrlForError(normalizedUrl),
154
157
  });
155
158
  return null;
156
159
  }
@@ -162,7 +165,7 @@ export class ImageCache {
162
165
  this.cache.set(normalizedUrl, entry);
163
166
  this.stats.hits++;
164
167
  logger.debug("Image cache hit", {
165
- url: normalizedUrl.substring(0, 50),
168
+ url: redactUrlForError(normalizedUrl),
166
169
  accessCount: entry.accessCount,
167
170
  });
168
171
  return entry;
@@ -192,7 +195,7 @@ export class ImageCache {
192
195
  // Skip caching if image exceeds max size
193
196
  if (size > this.maxImageSize) {
194
197
  logger.debug("Image too large to cache", {
195
- url: normalizedUrl.substring(0, 50),
198
+ url: redactUrlForError(normalizedUrl),
196
199
  size,
197
200
  maxSize: this.maxImageSize,
198
201
  });
@@ -211,8 +214,8 @@ export class ImageCache {
211
214
  // Update content hash index to point to the new URL as well
212
215
  this.contentHashIndex.set(contentHash, normalizedUrl);
213
216
  logger.debug("Image cache dedup hit", {
214
- newUrl: normalizedUrl.substring(0, 50),
215
- existingUrl: existingUrl.substring(0, 50),
217
+ newUrl: redactUrlForError(normalizedUrl),
218
+ existingUrl: redactUrlForError(existingUrl),
216
219
  });
217
220
  return;
218
221
  }
@@ -234,7 +237,7 @@ export class ImageCache {
234
237
  this.cache.set(normalizedUrl, entry);
235
238
  this.contentHashIndex.set(contentHash, normalizedUrl);
236
239
  logger.debug("Image cached", {
237
- url: normalizedUrl.substring(0, 50),
240
+ url: redactUrlForError(normalizedUrl),
238
241
  size,
239
242
  contentHash: contentHash.substring(0, 8),
240
243
  cacheSize: this.cache.size,
@@ -272,7 +275,7 @@ export class ImageCache {
272
275
  this.cache.delete(oldestKey);
273
276
  this.stats.evictions++;
274
277
  logger.debug("Image cache eviction", {
275
- url: String(oldestKey).substring(0, 50),
278
+ url: redactUrlForError(String(oldestKey)),
276
279
  });
277
280
  }
278
281
  }
@@ -2,6 +2,7 @@
2
2
  * Image processing utilities for multimodal support
3
3
  * Handles format conversion for different AI providers
4
4
  */
5
+ import { redactPathFromMessage } from "./logSanitize.js";
5
6
  import type { ProcessedImage, FileProcessingResult } from "../types/index.js";
6
7
  /**
7
8
  * Image processor class for handling provider-specific image formatting
@@ -59,9 +60,41 @@ export declare class ImageProcessor {
59
60
  */
60
61
  static detectImageType(input: string | Buffer): string;
61
62
  /**
62
- * Validate image size (default 10MB limit)
63
+ * Throwing size guard for a raw byte length. Prevents memory exhaustion by
64
+ * rejecting an oversized input BEFORE an unbounded read/allocation happens
65
+ * (#257) — callers that can get a size cheaply (e.g. `fs.stat`) should
66
+ * validate it before ever reading the bytes into memory. Default limit is
67
+ * the canonical `SIZE_LIMITS_BYTES.IMAGE_MAX` (10 MB); there is no public
68
+ * way to raise it — `maxSize` is an internal-only override for advanced
69
+ * callers (e.g. video image inputs use a higher limit).
70
+ *
71
+ * @param size - Byte length to validate
72
+ * @param context - Short label identifying the source (unused in the
73
+ * thrown message today, kept for call-site readability and future use)
74
+ * @param maxSize - Max allowed size in bytes
75
+ * @throws NeuroLinkError (`INVALID_IMAGE_SIZE`) if `size` is not a finite,
76
+ * non-negative number
77
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if `size` exceeds `maxSize`
78
+ */
79
+ static validateSize(size: number, context: string, maxSize?: number): void;
80
+ /**
81
+ * Throwing size guard for image buffers. Prevents memory exhaustion by
82
+ * rejecting oversized buffers with a descriptive error BEFORE an unbounded
83
+ * `Buffer.toString("base64")` allocation (#257). Delegates to
84
+ * `validateSize` so buffer-based and pre-read (stat-based) callers share
85
+ * one implementation.
86
+ *
87
+ * @param buffer - Image buffer to validate
88
+ * @param context - Short label identifying the source (see `validateSize`)
89
+ * @param maxSize - Max allowed size in bytes
90
+ * @throws NeuroLinkError (`IMAGE_TOO_LARGE`) if the buffer exceeds `maxSize`
91
+ */
92
+ static validateBufferSize(buffer: Buffer, context: string, maxSize?: number): void;
93
+ /**
94
+ * Size-guarded Buffer → base64. Use in place of `buffer.toString("base64")`
95
+ * on any path that converts a caller-supplied image buffer (#257).
63
96
  */
64
- static validateImageSize(data: Buffer | string, maxSize?: number): boolean;
97
+ static safeBase64Convert(buffer: Buffer, context: string, maxSize?: number): string;
65
98
  /**
66
99
  * Validate image format
67
100
  */
@@ -126,13 +159,28 @@ export declare const imageUtils: {
126
159
  */
127
160
  formatFileSize: (bytes: number) => string;
128
161
  /**
129
- * Convert Buffer to base64 string
162
+ * Convert Buffer to base64 string. Guarded by the same #257 size limit as
163
+ * every other conversion site (default `SIZE_LIMITS_BYTES.IMAGE_MAX`);
164
+ * pass `maxBytes` to override for a caller with a legitimate need for a
165
+ * different ceiling rather than silently accepting an unbounded buffer.
166
+ * `imageUtils`/`ImageProcessor` are internal-only — not re-exported from
167
+ * `src/lib/index.ts` or the package's public `exports` map — so this
168
+ * default does not change behavior for any external SDK consumer.
130
169
  */
131
- bufferToBase64: (buffer: Buffer) => string;
170
+ bufferToBase64: (buffer: Buffer, maxBytes?: number) => string;
132
171
  /**
133
172
  * Convert base64 string to Buffer
134
173
  */
135
174
  base64ToBuffer: (base64: string) => Buffer;
175
+ /**
176
+ * Redact `filePath` (both as given and its `path.resolve()`'d form) from
177
+ * an error message. Exposed directly (not just used internally by
178
+ * {@link fileToBase64DataUri}) so the resolved-path branch can be verified
179
+ * by a deterministic unit test — real `fs` errors only ever embed the
180
+ * literal path as passed, never a resolved variant, so that branch isn't
181
+ * otherwise observable through the async file-reading API.
182
+ */
183
+ redactPathFromMessage: typeof redactPathFromMessage;
136
184
  /**
137
185
  * Convert file path to base64 data URI
138
186
  */