@juspay/neurolink 9.94.4 → 9.94.6
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 +401 -401
- package/dist/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/types/generate.d.ts +1 -0
- package/dist/lib/types/multimodal.d.ts +34 -3
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/lib/utils/errorHandling.d.ts +22 -0
- package/dist/lib/utils/errorHandling.js +55 -0
- package/dist/lib/utils/fileDetector.js +210 -31
- package/dist/lib/utils/imageCache.js +11 -8
- package/dist/lib/utils/imageProcessor.d.ts +37 -0
- package/dist/lib/utils/imageProcessor.js +222 -16
- package/dist/lib/utils/logSanitize.d.ts +100 -1
- package/dist/lib/utils/logSanitize.js +152 -2
- package/dist/lib/utils/messageBuilder.js +80 -9
- package/dist/types/generate.d.ts +1 -0
- package/dist/types/multimodal.d.ts +34 -3
- package/dist/types/stream.d.ts +1 -0
- package/dist/utils/errorHandling.d.ts +22 -0
- package/dist/utils/errorHandling.js +55 -0
- package/dist/utils/fileDetector.js +210 -31
- package/dist/utils/imageCache.js +11 -8
- package/dist/utils/imageProcessor.d.ts +37 -0
- package/dist/utils/imageProcessor.js +222 -16
- package/dist/utils/logSanitize.d.ts +100 -1
- package/dist/utils/logSanitize.js +152 -2
- package/dist/utils/messageBuilder.js +80 -9
- package/package.json +1 -1
|
@@ -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", {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
215
|
-
existingUrl: existingUrl
|
|
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
|
|
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)
|
|
278
|
+
url: redactUrlForError(String(oldestKey)),
|
|
276
279
|
});
|
|
277
280
|
}
|
|
278
281
|
}
|
|
@@ -2,7 +2,19 @@
|
|
|
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";
|
|
7
|
+
/**
|
|
8
|
+
* Minimum bytes required just to detect an image format from magic bytes (#293).
|
|
9
|
+
*/
|
|
10
|
+
export declare const MIN_IMAGE_SIZE = 12;
|
|
11
|
+
/**
|
|
12
|
+
* Minimum plausible byte size for a non-empty image of each format (#293). A
|
|
13
|
+
* buffer that carries a format's magic bytes but is smaller than this is
|
|
14
|
+
* truncated/corrupt and would fail (or render blank) downstream — reject early
|
|
15
|
+
* with a clear message instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MIN_VALID_IMAGE_SIZE: Record<string, number>;
|
|
6
18
|
/**
|
|
7
19
|
* Image processor class for handling provider-specific image formatting
|
|
8
20
|
*/
|
|
@@ -16,6 +28,22 @@ export declare class ImageProcessor {
|
|
|
16
28
|
* @returns Processed image as data URI
|
|
17
29
|
*/
|
|
18
30
|
static process(content: Buffer, _options?: unknown): Promise<FileProcessingResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Strict magic-byte match: does `content` actually begin with `mediaType`'s
|
|
33
|
+
* signature? `detectImageType` returns `image/jpeg` as a *fallback* for
|
|
34
|
+
* unrecognized data, so a per-format minimum must only be enforced when the
|
|
35
|
+
* bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
|
|
36
|
+
* wrongly rejected as "truncated jpeg" (#293 review).
|
|
37
|
+
*/
|
|
38
|
+
private static hasStrictImageMagic;
|
|
39
|
+
/**
|
|
40
|
+
* Validate that a buffer is a plausibly-complete image (#293): non-empty and,
|
|
41
|
+
* for a strictly-identified raster format, at least the minimum plausible byte
|
|
42
|
+
* size. Returns the detected media type.
|
|
43
|
+
*
|
|
44
|
+
* @throws Error when the buffer is empty or a recognized format is truncated.
|
|
45
|
+
*/
|
|
46
|
+
private static validateBufferNotEmpty;
|
|
19
47
|
/**
|
|
20
48
|
* Validate processed output meets required format
|
|
21
49
|
* Checks:
|
|
@@ -171,6 +199,15 @@ export declare const imageUtils: {
|
|
|
171
199
|
* Convert base64 string to Buffer
|
|
172
200
|
*/
|
|
173
201
|
base64ToBuffer: (base64: string) => Buffer;
|
|
202
|
+
/**
|
|
203
|
+
* Redact `filePath` (both as given and its `path.resolve()`'d form) from
|
|
204
|
+
* an error message. Exposed directly (not just used internally by
|
|
205
|
+
* {@link fileToBase64DataUri}) so the resolved-path branch can be verified
|
|
206
|
+
* by a deterministic unit test — real `fs` errors only ever embed the
|
|
207
|
+
* literal path as passed, never a resolved variant, so that branch isn't
|
|
208
|
+
* otherwise observable through the async file-reading API.
|
|
209
|
+
*/
|
|
210
|
+
redactPathFromMessage: typeof redactPathFromMessage;
|
|
174
211
|
/**
|
|
175
212
|
* Convert file path to base64 data URI
|
|
176
213
|
*/
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
* Image processing utilities for multimodal support
|
|
3
3
|
* Handles format conversion for different AI providers
|
|
4
4
|
*/
|
|
5
|
+
import { basename } from "path";
|
|
5
6
|
import { logger } from "./logger.js";
|
|
7
|
+
import { redactPathFromMessage, redactUrlForError, redactUrlsInText, sanitizeErrorCause, } from "./logSanitize.js";
|
|
6
8
|
import { urlDownloadRateLimiter } from "./rateLimiter.js";
|
|
7
9
|
import { withRetry } from "./retryHandler.js";
|
|
8
10
|
import { SYSTEM_LIMITS } from "../core/constants.js";
|
|
9
11
|
import { SIZE_LIMITS_BYTES } from "../processors/config/sizeLimits.js";
|
|
10
12
|
import { getImageCache } from "./imageCache.js";
|
|
11
13
|
import { ErrorFactory, NeuroLinkError } from "./errorHandling.js";
|
|
14
|
+
/**
|
|
15
|
+
* Minimum bytes required just to detect an image format from magic bytes (#293).
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_IMAGE_SIZE = 12;
|
|
18
|
+
/**
|
|
19
|
+
* Minimum plausible byte size for a non-empty image of each format (#293). A
|
|
20
|
+
* buffer that carries a format's magic bytes but is smaller than this is
|
|
21
|
+
* truncated/corrupt and would fail (or render blank) downstream — reject early
|
|
22
|
+
* with a clear message instead.
|
|
23
|
+
*/
|
|
24
|
+
export const MIN_VALID_IMAGE_SIZE = {
|
|
25
|
+
"image/png": 67,
|
|
26
|
+
"image/jpeg": 125,
|
|
27
|
+
"image/gif": 43,
|
|
28
|
+
"image/webp": 20,
|
|
29
|
+
"image/avif": 100,
|
|
30
|
+
};
|
|
12
31
|
/**
|
|
13
32
|
* Network error codes that should trigger a retry
|
|
14
33
|
*/
|
|
@@ -58,6 +77,25 @@ function isRetryableDownloadError(error) {
|
|
|
58
77
|
}
|
|
59
78
|
return false;
|
|
60
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Reject `detectImageType()`'s `application/octet-stream` sentinel — the
|
|
82
|
+
* honest "no known image signature (PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF)
|
|
83
|
+
* matched" fallback (#261/#286). Packaging that sentinel into a data URI
|
|
84
|
+
* hands vision providers (OpenAI/Anthropic/Google) a MIME type they reject
|
|
85
|
+
* outright with an HTTP 400, so every public conversion path that turns
|
|
86
|
+
* unknown bytes into image output must fail loud here first instead of
|
|
87
|
+
* letting the sentinel slip through to a caller.
|
|
88
|
+
*
|
|
89
|
+
* @param mediaType - The MIME type returned by `detectImageType()`.
|
|
90
|
+
* @throws Error if `mediaType` is the octet-stream sentinel.
|
|
91
|
+
*/
|
|
92
|
+
function assertKnownImageType(mediaType) {
|
|
93
|
+
if (mediaType === "application/octet-stream") {
|
|
94
|
+
logger.error("Unable to detect a supported image format from file content");
|
|
95
|
+
throw new Error("Unsupported or corrupted image: no known image signature " +
|
|
96
|
+
"(PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF) was found in the file content.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
61
99
|
/**
|
|
62
100
|
* Image processor class for handling provider-specific image formatting
|
|
63
101
|
*/
|
|
@@ -71,12 +109,12 @@ export class ImageProcessor {
|
|
|
71
109
|
* @returns Processed image as data URI
|
|
72
110
|
*/
|
|
73
111
|
static async process(content, _options) {
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
// #293: reject empty AND small/truncated buffers (not just 0 bytes) before
|
|
113
|
+
// processing; returns the detected media type.
|
|
114
|
+
const mediaType = this.validateBufferNotEmpty(content);
|
|
115
|
+
// #261/#286 follow-up: fail loud instead of packaging the octet-stream
|
|
116
|
+
// sentinel as a "valid" image (see assertKnownImageType() above).
|
|
117
|
+
assertKnownImageType(mediaType);
|
|
80
118
|
const base64 = ImageProcessor.safeBase64Convert(content, "image processing");
|
|
81
119
|
const dataUri = `data:${mediaType};base64,${base64}`;
|
|
82
120
|
// Validate output before returning
|
|
@@ -91,6 +129,73 @@ export class ImageProcessor {
|
|
|
91
129
|
},
|
|
92
130
|
};
|
|
93
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Strict magic-byte match: does `content` actually begin with `mediaType`'s
|
|
134
|
+
* signature? `detectImageType` returns `image/jpeg` as a *fallback* for
|
|
135
|
+
* unrecognized data, so a per-format minimum must only be enforced when the
|
|
136
|
+
* bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
|
|
137
|
+
* wrongly rejected as "truncated jpeg" (#293 review).
|
|
138
|
+
*/
|
|
139
|
+
static hasStrictImageMagic(content, mediaType) {
|
|
140
|
+
const b = content;
|
|
141
|
+
switch (mediaType) {
|
|
142
|
+
case "image/png":
|
|
143
|
+
return (b.length >= 4 &&
|
|
144
|
+
b[0] === 0x89 &&
|
|
145
|
+
b[1] === 0x50 &&
|
|
146
|
+
b[2] === 0x4e &&
|
|
147
|
+
b[3] === 0x47);
|
|
148
|
+
case "image/jpeg":
|
|
149
|
+
return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
|
150
|
+
case "image/gif":
|
|
151
|
+
return b.length >= 3 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46;
|
|
152
|
+
case "image/webp":
|
|
153
|
+
return (b.length >= 12 &&
|
|
154
|
+
b.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
155
|
+
b.subarray(8, 12).toString("latin1") === "WEBP");
|
|
156
|
+
case "image/avif":
|
|
157
|
+
return (b.length >= 12 &&
|
|
158
|
+
b.subarray(4, 8).toString("latin1") === "ftyp" &&
|
|
159
|
+
b.subarray(8, 12).toString("latin1").startsWith("avif"));
|
|
160
|
+
default:
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Validate that a buffer is a plausibly-complete image (#293): non-empty and,
|
|
166
|
+
* for a strictly-identified raster format, at least the minimum plausible byte
|
|
167
|
+
* size. Returns the detected media type.
|
|
168
|
+
*
|
|
169
|
+
* @throws Error when the buffer is empty or a recognized format is truncated.
|
|
170
|
+
*/
|
|
171
|
+
static validateBufferNotEmpty(content) {
|
|
172
|
+
if (content.length === 0) {
|
|
173
|
+
logger.error("Empty buffer provided");
|
|
174
|
+
throw ErrorFactory.imageBufferInvalid("Invalid image processing: buffer is empty");
|
|
175
|
+
}
|
|
176
|
+
const mediaType = this.detectImageType(content);
|
|
177
|
+
// SVG is text and can be legitimately tiny (e.g. `<svg/>`), so the raster
|
|
178
|
+
// size floors don't apply to it (#293 review).
|
|
179
|
+
if (mediaType === "image/svg+xml") {
|
|
180
|
+
return mediaType;
|
|
181
|
+
}
|
|
182
|
+
// A buffer too small to carry any raster header is not a usable image.
|
|
183
|
+
if (content.length < MIN_IMAGE_SIZE) {
|
|
184
|
+
throw ErrorFactory.imageBufferInvalid(`Invalid image processing: buffer too small (${content.length} bytes) — ` +
|
|
185
|
+
`a minimum of ${MIN_IMAGE_SIZE} bytes is required for a valid image.`);
|
|
186
|
+
}
|
|
187
|
+
// Only enforce a per-format minimum when the format was strictly identified
|
|
188
|
+
// from magic bytes (never from detectImageType's jpeg fallback), so valid
|
|
189
|
+
// BMP/TIFF/unknown buffers are not mis-rejected as "truncated jpeg".
|
|
190
|
+
const minForFormat = MIN_VALID_IMAGE_SIZE[mediaType];
|
|
191
|
+
if (minForFormat !== undefined &&
|
|
192
|
+
content.length < minForFormat &&
|
|
193
|
+
this.hasStrictImageMagic(content, mediaType)) {
|
|
194
|
+
throw ErrorFactory.imageBufferInvalid(`Invalid image processing: ${mediaType} buffer is truncated ` +
|
|
195
|
+
`(${content.length} bytes, minimum ${minForFormat} for a valid ${mediaType}).`);
|
|
196
|
+
}
|
|
197
|
+
return mediaType;
|
|
198
|
+
}
|
|
94
199
|
/**
|
|
95
200
|
* Validate processed output meets required format
|
|
96
201
|
* Checks:
|
|
@@ -327,20 +432,50 @@ export class ImageProcessor {
|
|
|
327
432
|
return "image/svg+xml";
|
|
328
433
|
}
|
|
329
434
|
}
|
|
330
|
-
// AVIF:
|
|
435
|
+
// AVIF (ISOBMFF): "ftyp" box at offset 4, major brand at offset 8.
|
|
436
|
+
// Accept the AVIF-spec brand variants — 'avif' (still image), 'avis'
|
|
437
|
+
// (image sequence), and 'avio' (intra-only AV1 image/sequence — the
|
|
438
|
+
// spec lists it under compatible_brands rather than major_brand, but
|
|
439
|
+
// real-world encoders emit it as major_brand too, so it's accepted
|
|
440
|
+
// here) — and compare bytes directly rather than via Buffer.toString()
|
|
441
|
+
// (which would utf-8-decode non-ASCII bytes) so the check is exact
|
|
442
|
+
// (#286).
|
|
331
443
|
if (input.length >= 12) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
444
|
+
const isFtyp = input[4] === 0x66 && // 'f'
|
|
445
|
+
input[5] === 0x74 && // 't'
|
|
446
|
+
input[6] === 0x79 && // 'y'
|
|
447
|
+
input[7] === 0x70; // 'p'
|
|
448
|
+
const brand = String.fromCharCode(input[8], input[9], input[10], input[11]);
|
|
449
|
+
if (isFtyp &&
|
|
450
|
+
(brand === "avif" || brand === "avis" || brand === "avio")) {
|
|
335
451
|
return "image/avif";
|
|
336
452
|
}
|
|
337
453
|
}
|
|
454
|
+
// BMP: "BM" magic (0x42 0x4D)
|
|
455
|
+
if (input[0] === 0x42 && input[1] === 0x4d) {
|
|
456
|
+
return "image/bmp";
|
|
457
|
+
}
|
|
458
|
+
// TIFF: little-endian "II*\0" or big-endian "MM\0*"
|
|
459
|
+
if ((input[0] === 0x49 &&
|
|
460
|
+
input[1] === 0x49 &&
|
|
461
|
+
input[2] === 0x2a &&
|
|
462
|
+
input[3] === 0x00) ||
|
|
463
|
+
(input[0] === 0x4d &&
|
|
464
|
+
input[1] === 0x4d &&
|
|
465
|
+
input[2] === 0x00 &&
|
|
466
|
+
input[3] === 0x2a)) {
|
|
467
|
+
return "image/tiff";
|
|
468
|
+
}
|
|
338
469
|
}
|
|
339
|
-
|
|
470
|
+
// No known image signature matched. Return a safe, honest sentinel
|
|
471
|
+
// rather than mislabeling arbitrary bytes as JPEG (#261): a wrong
|
|
472
|
+
// image/jpeg label silently corrupts the downstream provider request.
|
|
473
|
+
logger.warn("Could not detect image type from magic bytes; using application/octet-stream");
|
|
474
|
+
return "application/octet-stream";
|
|
340
475
|
}
|
|
341
476
|
catch (error) {
|
|
342
477
|
logger.warn("Failed to detect image type, using default:", error);
|
|
343
|
-
return "
|
|
478
|
+
return "application/octet-stream";
|
|
344
479
|
}
|
|
345
480
|
}
|
|
346
481
|
/**
|
|
@@ -415,6 +550,12 @@ export class ImageProcessor {
|
|
|
415
550
|
"image/tiff",
|
|
416
551
|
"image/svg+xml",
|
|
417
552
|
"image/avif",
|
|
553
|
+
// Deliberately excludes "application/octet-stream": that's
|
|
554
|
+
// detectImageType()'s honest sentinel for bytes matching no known
|
|
555
|
+
// image signature (#261), not a real image format. Vision providers
|
|
556
|
+
// (OpenAI/Anthropic/Google) reject it outright with an HTTP 400, so
|
|
557
|
+
// process() must fail loud instead of packaging it as a valid image
|
|
558
|
+
// (see the octet-stream guard in process() below).
|
|
418
559
|
];
|
|
419
560
|
return supportedFormats.includes(mediaType.toLowerCase());
|
|
420
561
|
}
|
|
@@ -495,7 +636,19 @@ export class ImageProcessor {
|
|
|
495
636
|
*/
|
|
496
637
|
static processImage(image, provider, model) {
|
|
497
638
|
try {
|
|
639
|
+
// #257: reject an oversized buffer before any format-detection or
|
|
640
|
+
// conversion work runs on it — mirrors the guard every
|
|
641
|
+
// `processImageForX()` branch below already applies via
|
|
642
|
+
// `safeBase64Convert()`, so the typed IMAGE_TOO_LARGE error surfaces
|
|
643
|
+
// here too instead of being pre-empted by the octet-stream check next.
|
|
644
|
+
if (Buffer.isBuffer(image)) {
|
|
645
|
+
ImageProcessor.validateBufferSize(image, "image processing");
|
|
646
|
+
}
|
|
498
647
|
const mediaType = ImageProcessor.detectImageType(image);
|
|
648
|
+
// #261/#286 follow-up: reject the octet-stream sentinel here too — the
|
|
649
|
+
// returned ProcessedImage.mediaType is what callers use to build a
|
|
650
|
+
// data URI, so this is a separate public path that must not emit it.
|
|
651
|
+
assertKnownImageType(mediaType);
|
|
499
652
|
const size = typeof image === "string"
|
|
500
653
|
? Buffer.byteLength(image, "base64")
|
|
501
654
|
: image.length;
|
|
@@ -687,6 +840,15 @@ export const imageUtils = {
|
|
|
687
840
|
const cleanBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
|
|
688
841
|
return Buffer.from(cleanBase64, "base64");
|
|
689
842
|
},
|
|
843
|
+
/**
|
|
844
|
+
* Redact `filePath` (both as given and its `path.resolve()`'d form) from
|
|
845
|
+
* an error message. Exposed directly (not just used internally by
|
|
846
|
+
* {@link fileToBase64DataUri}) so the resolved-path branch can be verified
|
|
847
|
+
* by a deterministic unit test — real `fs` errors only ever embed the
|
|
848
|
+
* literal path as passed, never a resolved variant, so that branch isn't
|
|
849
|
+
* otherwise observable through the async file-reading API.
|
|
850
|
+
*/
|
|
851
|
+
redactPathFromMessage,
|
|
690
852
|
/**
|
|
691
853
|
* Convert file path to base64 data URI
|
|
692
854
|
*/
|
|
@@ -706,11 +868,38 @@ export const imageUtils = {
|
|
|
706
868
|
// Enhanced MIME detection: try buffer content first, fallback to filename
|
|
707
869
|
const mimeType = ImageProcessor.detectImageType(buffer) ||
|
|
708
870
|
ImageProcessor.detectImageType(filePath);
|
|
871
|
+
// #261/#286 follow-up: reject the octet-stream sentinel here too —
|
|
872
|
+
// this is a third public path (alongside process() and
|
|
873
|
+
// processImage()) that can otherwise package unknown bytes into a
|
|
874
|
+
// `data:application/octet-stream;base64,...` URI a vision provider
|
|
875
|
+
// rejects outright.
|
|
876
|
+
assertKnownImageType(mimeType);
|
|
709
877
|
const base64 = buffer.toString("base64");
|
|
710
878
|
return `data:${mimeType};base64,${base64}`;
|
|
711
879
|
}
|
|
712
880
|
catch (error) {
|
|
713
|
-
|
|
881
|
+
// Full path stays in the debug log; the thrown (potentially
|
|
882
|
+
// client-facing) error only gets the basename. The underlying fs
|
|
883
|
+
// error's own message is scrubbed too — Node's ENOENT/EACCES text
|
|
884
|
+
// embeds the full path verbatim (e.g. "no such file or directory,
|
|
885
|
+
// stat '/full/path'"), which would otherwise leak the host's
|
|
886
|
+
// directory layout right back through the "reason" suffix. The raw
|
|
887
|
+
// `error` is never attached as `cause` either — that would leave the
|
|
888
|
+
// unredacted path reachable via `error.cause.message` for anything
|
|
889
|
+
// that walks the cause chain (cause-aware logging, telemetry); a
|
|
890
|
+
// sanitized copy (via the shared `sanitizeErrorCause`) is attached
|
|
891
|
+
// instead, mirroring `urlToBase64DataUri` below.
|
|
892
|
+
logger.debug("fileToBase64DataUri failed", { filePath, error });
|
|
893
|
+
const safeName = basename(filePath);
|
|
894
|
+
const cause = sanitizeErrorCause(error, { filePath });
|
|
895
|
+
const reason = error instanceof Error ? cause.message : "Unknown error";
|
|
896
|
+
// Assigned to a variable before throwing (rather than `throw new
|
|
897
|
+
// Error(...)` inline) so the sanitized `cause` — a deliberately NEW,
|
|
898
|
+
// redacted Error — isn't mistaken by tooling for a dropped/altered
|
|
899
|
+
// reference to the originally caught `error`; the raw error is never
|
|
900
|
+
// reachable from the thrown result at all, by design.
|
|
901
|
+
const wrapped = new Error(`Failed to convert file to base64 (${safeName}): ${reason}`, { cause });
|
|
902
|
+
throw wrapped;
|
|
714
903
|
}
|
|
715
904
|
},
|
|
716
905
|
/**
|
|
@@ -796,14 +985,31 @@ export const imageUtils = {
|
|
|
796
985
|
maxDelay: SYSTEM_LIMITS.DEFAULT_MAX_DELAY,
|
|
797
986
|
retryCondition: isRetryableDownloadError,
|
|
798
987
|
onRetry: (attempt, error) => {
|
|
799
|
-
|
|
988
|
+
// Some fetch/DNS/TLS errors embed the full request URL (including
|
|
989
|
+
// a presigned query token) in `error.message` — redact that text
|
|
990
|
+
// too, not just the explicit `url` interpolation below. The
|
|
991
|
+
// non-Error branch is sanitized too: String(error) on a thrown
|
|
992
|
+
// non-Error value (e.g. a raw string or object) can just as
|
|
993
|
+
// easily carry the same signed URL.
|
|
994
|
+
const message = redactUrlsInText(error instanceof Error ? error.message : String(error));
|
|
800
995
|
const attemptsLeft = maxAttempts - attempt;
|
|
801
|
-
logger.warn(`⚠️ Image download attempt ${attempt} failed for ${url}: ${message}. ${attemptsLeft} ${attemptsLeft === 1 ? "attempt" : "attempts"} remaining...`);
|
|
996
|
+
logger.warn(`⚠️ Image download attempt ${attempt} failed for ${redactUrlForError(url)}: ${message}. ${attemptsLeft} ${attemptsLeft === 1 ? "attempt" : "attempts"} remaining...`);
|
|
802
997
|
},
|
|
803
998
|
});
|
|
804
999
|
}
|
|
805
1000
|
catch (error) {
|
|
806
|
-
|
|
1001
|
+
// Query string / fragment stripped — a presigned S3/GCS URL or SAS
|
|
1002
|
+
// token embedded there must not be echoed into a thrown error. The
|
|
1003
|
+
// underlying error's own message is scrubbed too, since fetch/DNS/TLS
|
|
1004
|
+
// errors often embed the full URL (query string and all) themselves.
|
|
1005
|
+
// The raw `error` is never attached as `cause` — that would leave the
|
|
1006
|
+
// unredacted URL reachable via `error.cause.message` for anything
|
|
1007
|
+
// that walks the cause chain (cause-aware logging, telemetry); a
|
|
1008
|
+
// sanitized copy is attached instead.
|
|
1009
|
+
const sourceUrl = redactUrlForError(url);
|
|
1010
|
+
const cause = sanitizeErrorCause(error);
|
|
1011
|
+
const wrapped = new Error(`Failed to download and convert URL to base64 (${sourceUrl}): ${cause.message}`, { cause });
|
|
1012
|
+
throw wrapped;
|
|
807
1013
|
}
|
|
808
1014
|
},
|
|
809
1015
|
/**
|
|
@@ -61,11 +61,110 @@ export declare function safeDebugSerialize(value: unknown, maxLen?: number): str
|
|
|
61
61
|
* for diagnostics. Use this for any `baseURL`/endpoint a caller may have
|
|
62
62
|
* supplied with inline credentials. The match is global, so every `//…@`
|
|
63
63
|
* authority in the string is redacted (e.g. a proxy chain or a URL embedded
|
|
64
|
-
* in a query parameter), not just the first.
|
|
64
|
+
* in a query parameter), not just the first. IPv6 literal hosts (`//u:p@[::1]/x`)
|
|
65
|
+
* and percent-encoded userinfo (`//u:p%40x@host/x`) are handled naturally —
|
|
66
|
+
* neither `[`/`]` nor `%` stop either pass below, so the bracketed host or
|
|
67
|
+
* encoded byte just rides along as part of the (non-redacted) remainder.
|
|
68
|
+
*
|
|
69
|
+
* This is best-effort defense-in-depth for free-form logging, deliberately
|
|
70
|
+
* regex-based so it can scrub *multiple* embedded authorities out of
|
|
71
|
+
* arbitrary text (a real URL parser only ever parses one URL at a time).
|
|
72
|
+
* It is NOT the primary protection for user-facing errors — that's
|
|
73
|
+
* {@link redactUrlForError}, which parses the single input via `new URL()`
|
|
74
|
+
* and reconstructs from protocol+host+pathname, never touching userinfo at
|
|
75
|
+
* all. Prefer that function whenever the input is known to be exactly one
|
|
76
|
+
* URL.
|
|
77
|
+
*
|
|
78
|
+
* Two passes, deliberately:
|
|
79
|
+
* 1. Well-formed authorities — credentials with no raw `/` — bounded at the
|
|
80
|
+
* authority's first `/` (or string end). Greedy backtracking naturally
|
|
81
|
+
* absorbs multiple `@` segments within one authority (e.g. `//a@b@host`
|
|
82
|
+
* → `//***@host`) while leaving a second, separate `//...@` authority
|
|
83
|
+
* elsewhere in the string (e.g. one embedded in a query parameter)
|
|
84
|
+
* untouched, because pass 1's own exclusion of `/` stops it at the first
|
|
85
|
+
* `/`, before it can ever reach a nested authority.
|
|
86
|
+
* 2. Malformed authorities whose credentials contain an unescaped `/`
|
|
87
|
+
* (e.g. `//user:sec/ret@host/path`) — pass 1 can't find an `@` before
|
|
88
|
+
* hitting that `/`, so this pass allows `/` and redacts up to the last
|
|
89
|
+
* remaining `@`. It stops before any *nested* `//` (via a negative
|
|
90
|
+
* lookahead) rather than excluding a specific character like `*` —
|
|
91
|
+
* excluding a literal character is what let a password containing both
|
|
92
|
+
* `/` and `*` (e.g. `//user:pa*ss/word@host/path`, legal under RFC 3986)
|
|
93
|
+
* slip through both passes entirely in an earlier version of this
|
|
94
|
+
* function.
|
|
65
95
|
*
|
|
66
96
|
* @param url - The URL (or URL-shaped string) to redact.
|
|
67
97
|
*/
|
|
68
98
|
export declare function redactUrlCredentials(url: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* Reduce a URL to its origin + pathname for safe inclusion in error messages
|
|
101
|
+
* and logs, dropping the query string and fragment — where presigned-URL
|
|
102
|
+
* signatures, SAS tokens, and other secrets commonly live — while keeping
|
|
103
|
+
* enough of the URL (scheme, host, path) for the error to stay diagnostic.
|
|
104
|
+
*
|
|
105
|
+
* Falls back to a best-effort redacted string (query/fragment and
|
|
106
|
+
* `user:pass@` stripped) when the input isn't a parseable absolute URL.
|
|
107
|
+
* Either way the result is capped at 200 chars so a very long host/path
|
|
108
|
+
* can't blow up the error message.
|
|
109
|
+
*
|
|
110
|
+
* @param url - The URL (or URL-shaped string) to redact.
|
|
111
|
+
*/
|
|
112
|
+
export declare function redactUrlForError(url: string): string;
|
|
113
|
+
/**
|
|
114
|
+
* Scrub any embedded absolute URLs out of free-form text — typically the
|
|
115
|
+
* `.message` of an underlying network/DNS/TLS error thrown by `fetch`,
|
|
116
|
+
* undici's `request()`, or Node's resolver, all of which frequently embed
|
|
117
|
+
* the full request URL (query string, credentials, and all) verbatim.
|
|
118
|
+
* Interpolating that message raw into a thrown or logged error would leak a
|
|
119
|
+
* presigned URL's token even after the explicit URL argument was already
|
|
120
|
+
* redacted via {@link redactUrlForError}. Finds each `scheme://…` run in the
|
|
121
|
+
* text and replaces it with its redacted form.
|
|
122
|
+
*
|
|
123
|
+
* @param text - Free-form text (typically `Error.message`) to scrub.
|
|
124
|
+
*/
|
|
125
|
+
export declare function redactUrlsInText(text: string): string;
|
|
126
|
+
/**
|
|
127
|
+
* Redact every occurrence of `filePath` from an error message, checking both
|
|
128
|
+
* the path exactly as given and its `path.resolve()`'d form.
|
|
129
|
+
*
|
|
130
|
+
* Node's own fs errors (ENOENT/EACCES) embed the exact string passed to the
|
|
131
|
+
* fs call, so a plain substring match usually suffices — but wrapped or
|
|
132
|
+
* lower-level errors sometimes normalize the path first (relative→absolute,
|
|
133
|
+
* trailing-slash variations), which an exact-match-only redaction would miss.
|
|
134
|
+
* This is defense-in-depth, not a guarantee: a `realpath`-resolved (symlink-
|
|
135
|
+
* following) variant can still differ from both forms and would slip through.
|
|
136
|
+
*
|
|
137
|
+
* @param message - The error message to scrub.
|
|
138
|
+
* @param filePath - The path to redact (both as given and resolved).
|
|
139
|
+
*/
|
|
140
|
+
export declare function redactPathFromMessage(message: string, filePath: string): string;
|
|
141
|
+
/**
|
|
142
|
+
* Build a sanitized copy of a network/URL/file error, safe to attach as
|
|
143
|
+
* `Error.cause` on a rethrow.
|
|
144
|
+
*
|
|
145
|
+
* Redacting only the OUTER thrown error's message isn't enough: if the RAW
|
|
146
|
+
* underlying error is attached as `cause`, anything that walks the cause
|
|
147
|
+
* chain (cause-aware logging, telemetry spans) can still recover an
|
|
148
|
+
* unredacted URL/path/secret via `error.cause.message`. This returns a NEW
|
|
149
|
+
* `Error` whose message has been through {@link redactUrlsInText} — and,
|
|
150
|
+
* when `options.filePath` is supplied, {@link redactPathFromMessage} too, so
|
|
151
|
+
* a filesystem error's ENOENT/EACCES text doesn't leak the host's directory
|
|
152
|
+
* layout via the cause chain the way the outer message already avoids.
|
|
153
|
+
* `.name` and `.code` (`NodeJS.ErrnoException`) are copied over so retry
|
|
154
|
+
* classification (e.g. `isRetryableNetworkError`) still works when it
|
|
155
|
+
* inspects the cause. The raw original is never referenced by the result.
|
|
156
|
+
*
|
|
157
|
+
* Handles non-`Error` thrown values too (a raw string/object can just as
|
|
158
|
+
* easily carry an unredacted URL or path) by running their `String()` form
|
|
159
|
+
* through the same redaction before wrapping.
|
|
160
|
+
*
|
|
161
|
+
* @param error - The raw underlying error (or thrown value) to sanitize.
|
|
162
|
+
* @param options - `filePath`: a known filesystem path to redact to its
|
|
163
|
+
* basename, in addition to URL redaction.
|
|
164
|
+
*/
|
|
165
|
+
export declare function sanitizeErrorCause(error: unknown, options?: {
|
|
166
|
+
filePath?: string;
|
|
167
|
+
}): Error;
|
|
69
168
|
/**
|
|
70
169
|
* Recursively sanitize a record/array, returning a structurally identical
|
|
71
170
|
* value with sensitive keys redacted and string values run through
|