@juspay/neurolink 10.12.3 → 10.12.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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +408 -408
- package/dist/lib/processors/archive/ArchiveProcessor.js +32 -18
- package/dist/lib/processors/archive/zipEntryReader.d.ts +51 -0
- package/dist/lib/processors/archive/zipEntryReader.js +81 -0
- package/dist/lib/processors/base/BaseFileProcessor.d.ts +19 -0
- package/dist/lib/processors/base/BaseFileProcessor.js +104 -3
- package/dist/lib/processors/document/OpenDocumentProcessor.js +13 -1
- package/dist/lib/processors/document/PptxProcessor.js +58 -12
- package/dist/lib/types/processor.d.ts +38 -0
- package/dist/processors/archive/ArchiveProcessor.js +32 -18
- package/dist/processors/archive/zipEntryReader.d.ts +51 -0
- package/dist/processors/archive/zipEntryReader.js +80 -0
- package/dist/processors/base/BaseFileProcessor.d.ts +19 -0
- package/dist/processors/base/BaseFileProcessor.js +104 -3
- package/dist/processors/document/OpenDocumentProcessor.js +13 -1
- package/dist/processors/document/PptxProcessor.js +58 -12
- package/dist/types/processor.d.ts +38 -0
- package/package.json +5 -3
|
@@ -37,12 +37,10 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import * as path from "path";
|
|
39
39
|
import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
|
|
40
|
+
import { isDecompressionBoundExceeded, readZipEntryWithinLimit, } from "./zipEntryReader.js";
|
|
40
41
|
import { SIZE_LIMITS_MB } from "../config/index.js";
|
|
41
42
|
import { FileErrorCode } from "../errors/index.js";
|
|
42
43
|
// =============================================================================
|
|
43
|
-
// TYPES
|
|
44
|
-
// =============================================================================
|
|
45
|
-
// =============================================================================
|
|
46
44
|
// SECURITY CONFIGURATION
|
|
47
45
|
// =============================================================================
|
|
48
46
|
/**
|
|
@@ -162,17 +160,6 @@ const SINGLE_STREAM_TOOLS = {
|
|
|
162
160
|
xz: "xz",
|
|
163
161
|
zst: "zstd",
|
|
164
162
|
};
|
|
165
|
-
/**
|
|
166
|
-
* Whether a zlib rejection is the output bound firing rather than bad input.
|
|
167
|
-
*
|
|
168
|
-
* `maxOutputLength` aborts an inflate the moment its output would pass the cap,
|
|
169
|
-
* which is the whole point — but it surfaces as a plain `RangeError`, and a
|
|
170
|
-
* bomb reported as "failed to decompress" reads as a corrupt upload and invites
|
|
171
|
-
* the user to send it again. It will fail identically every time.
|
|
172
|
-
*
|
|
173
|
-
* Keyed on `code`, not the message: the message embeds a byte count.
|
|
174
|
-
*/
|
|
175
|
-
const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
|
|
176
163
|
/** File extensions recognized as archive formats */
|
|
177
164
|
const SUPPORTED_ARCHIVE_EXTENSIONS = [".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".jar", ".xz", ".txz", ".zst", ".tzst"];
|
|
178
165
|
// =============================================================================
|
|
@@ -1368,6 +1355,7 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
1368
1355
|
.sort((a, b) => a.uncompressedSize - b.uncompressedSize);
|
|
1369
1356
|
let totalExtracted = 0;
|
|
1370
1357
|
let extractCount = 0;
|
|
1358
|
+
const zlibModule = await import("zlib");
|
|
1371
1359
|
for (const entry of candidates) {
|
|
1372
1360
|
if (extractCount >= ARCHIVE_CONFIG.MAX_EXTRACT_ENTRIES) {
|
|
1373
1361
|
break;
|
|
@@ -1380,8 +1368,17 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
1380
1368
|
if (!zipEntry) {
|
|
1381
1369
|
continue;
|
|
1382
1370
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1371
|
+
// This path was already bounded, but only by coincidence: entries
|
|
1372
|
+
// declaring 0 are dropped above, oversized declarations are dropped
|
|
1373
|
+
// above, and adm-zip caps everything else at the size it declares.
|
|
1374
|
+
// That leaves the ceiling resting on library internals we do not
|
|
1375
|
+
// trust anywhere else in this file, so it is spelled out here.
|
|
1376
|
+
const read = readZipEntryWithinLimit(zipEntry, Math.min(ARCHIVE_CONFIG.MAX_EXTRACT_ENTRY_SIZE, ARCHIVE_CONFIG.MAX_TOTAL_EXTRACT_SIZE - totalExtracted), zlibModule);
|
|
1377
|
+
if (read.status !== "ok") {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const data = read.buffer;
|
|
1381
|
+
if (data.length === 0) {
|
|
1385
1382
|
continue;
|
|
1386
1383
|
}
|
|
1387
1384
|
// Simple binary detection: check for null bytes in first 512 bytes
|
|
@@ -1552,12 +1549,29 @@ export class ArchiveProcessor extends BaseFileProcessor {
|
|
|
1552
1549
|
if (targetEntry.isDirectory) {
|
|
1553
1550
|
return `"${entryPath}" is a directory, not a file.`;
|
|
1554
1551
|
}
|
|
1555
|
-
// Security: size check
|
|
1552
|
+
// Security: size check.
|
|
1553
|
+
//
|
|
1554
|
+
// The declared size is a hint, not the bound — it is chosen by whoever
|
|
1555
|
+
// built the archive, and an entry claiming 0 passes this comparison while
|
|
1556
|
+
// switching off adm-zip's own cap (see readZipEntryWithinLimit). It is
|
|
1557
|
+
// still worth checking, because an honestly-declared oversized entry is
|
|
1558
|
+
// refused here without decompressing anything at all.
|
|
1556
1559
|
const maxSize = 5 * 1024 * 1024; // 5 MB
|
|
1557
1560
|
if (targetEntry.header.size > maxSize) {
|
|
1558
1561
|
return `Entry "${entryPath}" is too large (${this.formatHumanReadableSize(targetEntry.header.size)}). Maximum extraction size is 5 MB.`;
|
|
1559
1562
|
}
|
|
1560
|
-
const
|
|
1563
|
+
const zlibModule = await import("zlib");
|
|
1564
|
+
const read = readZipEntryWithinLimit(targetEntry, maxSize, zlibModule);
|
|
1565
|
+
if (read.status === "too-large") {
|
|
1566
|
+
return `Entry "${entryPath}" is too large. Maximum extraction size is 5 MB.`;
|
|
1567
|
+
}
|
|
1568
|
+
if (read.status === "unsupported-method") {
|
|
1569
|
+
return `Entry "${entryPath}" uses an unsupported compression method.`;
|
|
1570
|
+
}
|
|
1571
|
+
if (read.status === "corrupt") {
|
|
1572
|
+
return `Entry "${entryPath}" could not be read — the archive entry is corrupt.`;
|
|
1573
|
+
}
|
|
1574
|
+
const data = read.buffer;
|
|
1561
1575
|
// Check if it looks like text
|
|
1562
1576
|
const sampleSize = Math.min(data.length, 512);
|
|
1563
1577
|
let printable = 0;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ZIP entry reading, shared by every processor that opens a ZIP.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ArchiveProcessor because the Office formats are ZIPs too:
|
|
5
|
+
* .pptx and .odt read their entries directly and so can be handed the same
|
|
6
|
+
* bomb, and need the same refusal. One implementation rather than three means
|
|
7
|
+
* a correction to the guard lands everywhere at once.
|
|
8
|
+
*
|
|
9
|
+
* .docx and .xlsx deliberately do NOT use this. mammoth and exceljs unzip for
|
|
10
|
+
* themselves and were measured refusing a 400MB bomb at 46MB and 53MB peak,
|
|
11
|
+
* so wrapping them in a pre-scan bought no safety and roughly tripled the cost
|
|
12
|
+
* of every ordinary document.
|
|
13
|
+
*
|
|
14
|
+
* @module processors/archive/zipEntryReader
|
|
15
|
+
*/
|
|
16
|
+
import type { ArchiveEntryReadResult, BoundedZipEntry } from "../../types/index.js";
|
|
17
|
+
/** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
|
|
18
|
+
export declare const ZIP_METHOD_STORED = 0;
|
|
19
|
+
export declare const ZIP_METHOD_DEFLATED = 8;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a zlib rejection is the output bound firing rather than bad input.
|
|
22
|
+
*
|
|
23
|
+
* `maxOutputLength` aborts an inflate the moment its output would pass the cap,
|
|
24
|
+
* which is the whole point — but it surfaces as a plain `RangeError`, and a
|
|
25
|
+
* bomb reported as "failed to decompress" reads as a corrupt upload and invites
|
|
26
|
+
* the user to send it again. It will fail identically every time.
|
|
27
|
+
*
|
|
28
|
+
* Keyed on `code`, not the message: the message embeds a byte count.
|
|
29
|
+
*/
|
|
30
|
+
export declare const isDecompressionBoundExceeded: (error: unknown) => boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Read one ZIP entry's bytes without trusting the size it declares.
|
|
33
|
+
*
|
|
34
|
+
* `entry.getData()` cannot be used for this. It sizes its output buffer from
|
|
35
|
+
* the central-directory `size` field, which the archive author chooses, and
|
|
36
|
+
* adm-zip only arms its own guard when that field is positive:
|
|
37
|
+
*
|
|
38
|
+
* const option = version >= 15 && expectedLength > 0
|
|
39
|
+
* ? { maxOutputLength: expectedLength } : {};
|
|
40
|
+
*
|
|
41
|
+
* So an entry declaring 0 disables the bound and the caller's `size > maxSize`
|
|
42
|
+
* check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
|
|
43
|
+
* The declared size is the attack, so nothing here may depend on it: the cap
|
|
44
|
+
* comes from our own limit and is handed to the decoder.
|
|
45
|
+
*
|
|
46
|
+
* CRC is verified on both paths rather than dropped, so bypassing `getData()`
|
|
47
|
+
* does not also quietly lose its corruption check — a STORED entry is copied
|
|
48
|
+
* out rather than decoded, but it can be damaged just the same. It detects
|
|
49
|
+
* damage, not malice — the CRC field is attacker-controlled too.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readZipEntryWithinLimit(entry: BoundedZipEntry, maxBytes: number, zlibModule: typeof import("zlib")): ArchiveEntryReadResult;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ZIP entry reading, shared by every processor that opens a ZIP.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ArchiveProcessor because the Office formats are ZIPs too:
|
|
5
|
+
* .pptx and .odt read their entries directly and so can be handed the same
|
|
6
|
+
* bomb, and need the same refusal. One implementation rather than three means
|
|
7
|
+
* a correction to the guard lands everywhere at once.
|
|
8
|
+
*
|
|
9
|
+
* .docx and .xlsx deliberately do NOT use this. mammoth and exceljs unzip for
|
|
10
|
+
* themselves and were measured refusing a 400MB bomb at 46MB and 53MB peak,
|
|
11
|
+
* so wrapping them in a pre-scan bought no safety and roughly tripled the cost
|
|
12
|
+
* of every ordinary document.
|
|
13
|
+
*
|
|
14
|
+
* @module processors/archive/zipEntryReader
|
|
15
|
+
*/
|
|
16
|
+
/** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
|
|
17
|
+
export const ZIP_METHOD_STORED = 0;
|
|
18
|
+
export const ZIP_METHOD_DEFLATED = 8;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a zlib rejection is the output bound firing rather than bad input.
|
|
21
|
+
*
|
|
22
|
+
* `maxOutputLength` aborts an inflate the moment its output would pass the cap,
|
|
23
|
+
* which is the whole point — but it surfaces as a plain `RangeError`, and a
|
|
24
|
+
* bomb reported as "failed to decompress" reads as a corrupt upload and invites
|
|
25
|
+
* the user to send it again. It will fail identically every time.
|
|
26
|
+
*
|
|
27
|
+
* Keyed on `code`, not the message: the message embeds a byte count.
|
|
28
|
+
*/
|
|
29
|
+
export const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
|
|
30
|
+
/**
|
|
31
|
+
* Read one ZIP entry's bytes without trusting the size it declares.
|
|
32
|
+
*
|
|
33
|
+
* `entry.getData()` cannot be used for this. It sizes its output buffer from
|
|
34
|
+
* the central-directory `size` field, which the archive author chooses, and
|
|
35
|
+
* adm-zip only arms its own guard when that field is positive:
|
|
36
|
+
*
|
|
37
|
+
* const option = version >= 15 && expectedLength > 0
|
|
38
|
+
* ? { maxOutputLength: expectedLength } : {};
|
|
39
|
+
*
|
|
40
|
+
* So an entry declaring 0 disables the bound and the caller's `size > maxSize`
|
|
41
|
+
* check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
|
|
42
|
+
* The declared size is the attack, so nothing here may depend on it: the cap
|
|
43
|
+
* comes from our own limit and is handed to the decoder.
|
|
44
|
+
*
|
|
45
|
+
* CRC is verified on both paths rather than dropped, so bypassing `getData()`
|
|
46
|
+
* does not also quietly lose its corruption check — a STORED entry is copied
|
|
47
|
+
* out rather than decoded, but it can be damaged just the same. It detects
|
|
48
|
+
* damage, not malice — the CRC field is attacker-controlled too.
|
|
49
|
+
*/
|
|
50
|
+
export function readZipEntryWithinLimit(entry, maxBytes, zlibModule) {
|
|
51
|
+
const compressed = entry.getCompressedData();
|
|
52
|
+
const matchesCrc = (data) => (zlibModule.crc32(data) >>> 0) === (entry.header.crc >>> 0);
|
|
53
|
+
// STORED: the bytes are already the payload, so its own length is the bound.
|
|
54
|
+
if (entry.header.method === ZIP_METHOD_STORED) {
|
|
55
|
+
if (compressed.length > maxBytes) {
|
|
56
|
+
return { status: "too-large" };
|
|
57
|
+
}
|
|
58
|
+
return matchesCrc(compressed)
|
|
59
|
+
? { status: "ok", buffer: compressed }
|
|
60
|
+
: { status: "corrupt" };
|
|
61
|
+
}
|
|
62
|
+
if (entry.header.method !== ZIP_METHOD_DEFLATED) {
|
|
63
|
+
return { status: "unsupported-method" };
|
|
64
|
+
}
|
|
65
|
+
let inflated;
|
|
66
|
+
try {
|
|
67
|
+
inflated = zlibModule.inflateRawSync(compressed, {
|
|
68
|
+
maxOutputLength: maxBytes,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isDecompressionBoundExceeded(error)) {
|
|
73
|
+
return { status: "too-large" };
|
|
74
|
+
}
|
|
75
|
+
return { status: "corrupt" };
|
|
76
|
+
}
|
|
77
|
+
return matchesCrc(inflated)
|
|
78
|
+
? { status: "ok", buffer: inflated }
|
|
79
|
+
: { status: "corrupt" };
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=zipEntryReader.js.map
|
|
@@ -181,6 +181,25 @@ export declare abstract class BaseFileProcessor<T extends ProcessedFileBase> {
|
|
|
181
181
|
* @throws Error if download fails
|
|
182
182
|
*/
|
|
183
183
|
protected downloadFile(url: string, authHeaders?: Record<string, string>, timeout?: number): Promise<Buffer>;
|
|
184
|
+
/**
|
|
185
|
+
* Read a response body, refusing it the moment it passes `maxBytes`.
|
|
186
|
+
*
|
|
187
|
+
* `response.arrayBuffer()` buffers whatever the server sends before anyone
|
|
188
|
+
* can object, so the size check further up only ever ran against a body that
|
|
189
|
+
* had already been paid for in full. Metering the stream makes the ceiling
|
|
190
|
+
* the configured limit instead of the server's choice, and cancelling the
|
|
191
|
+
* reader stops the transfer rather than merely stopping our interest in it.
|
|
192
|
+
*
|
|
193
|
+
* Content-Length is deliberately not trusted as the bound — it is a hint that
|
|
194
|
+
* can be absent or a lie — but it is worth honouring when present, because it
|
|
195
|
+
* refuses the honest oversized case without reading a byte.
|
|
196
|
+
*
|
|
197
|
+
* @param response - Fetch response whose body should be read
|
|
198
|
+
* @param maxBytes - Ceiling for the body, in bytes
|
|
199
|
+
* @returns The body as a Buffer
|
|
200
|
+
* @throws When the body exceeds `maxBytes`
|
|
201
|
+
*/
|
|
202
|
+
private readBodyWithinLimit;
|
|
184
203
|
/**
|
|
185
204
|
* Download file with retry logic for transient failures.
|
|
186
205
|
*
|
|
@@ -52,6 +52,30 @@ import { tracers } from "../../telemetry/tracers.js";
|
|
|
52
52
|
import { createFileError, extractHttpStatus, FileErrorCode, isRetryableError, } from "../errors/index.js";
|
|
53
53
|
import { DEFAULT_RETRY_CONFIG } from "../../types/index.js";
|
|
54
54
|
const gunzipAsync = promisify(gunzip);
|
|
55
|
+
/**
|
|
56
|
+
* Marker on the error raised when a download is refused for exceeding the
|
|
57
|
+
* processor's size limit.
|
|
58
|
+
*
|
|
59
|
+
* A code rather than a message match, for two reasons. `classifyDownloadError`
|
|
60
|
+
* can then report FILE_TOO_LARGE — the same verdict the post-download check
|
|
61
|
+
* gave, so refusing earlier does not quietly downgrade the error a caller
|
|
62
|
+
* sees to DOWNLOAD_FAILED. And `isRetryableError` decides from text; a bound
|
|
63
|
+
* that fires is not a transient fault, and re-fetching a file that will be
|
|
64
|
+
* exactly as oversized next time is the one thing worth not doing three times.
|
|
65
|
+
*/
|
|
66
|
+
const DOWNLOAD_TOO_LARGE_CODE = "DOWNLOAD_TOO_LARGE";
|
|
67
|
+
function downloadTooLargeError(maxSizeMB, typeName) {
|
|
68
|
+
const error = new Error(`Download exceeds the maximum size of ${maxSizeMB} MB for ${typeName}`);
|
|
69
|
+
error.code = DOWNLOAD_TOO_LARGE_CODE;
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
function isDownloadTooLarge(error) {
|
|
73
|
+
return (error?.code === DOWNLOAD_TOO_LARGE_CODE);
|
|
74
|
+
}
|
|
75
|
+
/** Node's signal that a zlib output bound was reached. */
|
|
76
|
+
function isBufferTooLargeError(error) {
|
|
77
|
+
return (error?.code === "ERR_BUFFER_TOO_LARGE");
|
|
78
|
+
}
|
|
55
79
|
/**
|
|
56
80
|
* Abstract base class for file processors.
|
|
57
81
|
* Provides common download, validation, and error handling functionality.
|
|
@@ -389,8 +413,8 @@ export class BaseFileProcessor {
|
|
|
389
413
|
if (contentType && contentType.includes("text/html")) {
|
|
390
414
|
throw new Error(`Received HTML response instead of file content (Content-Type: ${contentType}). This usually means the download URL returned an error page.`);
|
|
391
415
|
}
|
|
392
|
-
const
|
|
393
|
-
let buffer =
|
|
416
|
+
const maxBytes = this.config.maxSizeMB * 1024 * 1024;
|
|
417
|
+
let buffer = await this.readBodyWithinLimit(response, maxBytes);
|
|
394
418
|
// Check for gzip encoding and decompress if needed
|
|
395
419
|
// Only decompress if the data actually starts with gzip magic bytes (0x1f 0x8b)
|
|
396
420
|
const contentEncoding = response.headers.get("Content-Encoding");
|
|
@@ -398,9 +422,16 @@ export class BaseFileProcessor {
|
|
|
398
422
|
if (contentEncoding?.toLowerCase().includes("gzip") &&
|
|
399
423
|
isActuallyGzipped) {
|
|
400
424
|
try {
|
|
401
|
-
|
|
425
|
+
// Bounded at the decoder. The size check that runs after the download
|
|
426
|
+
// only ever sees what already fits in memory, so a few KB of gzip
|
|
427
|
+
// declaring gigabytes was inflated in full before anything objected —
|
|
428
|
+
// the response passed the "is it small" test on its way in.
|
|
429
|
+
buffer = Buffer.from(await gunzipAsync(buffer, { maxOutputLength: maxBytes }));
|
|
402
430
|
}
|
|
403
431
|
catch (gzipError) {
|
|
432
|
+
if (isBufferTooLargeError(gzipError)) {
|
|
433
|
+
throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
|
|
434
|
+
}
|
|
404
435
|
throw new Error(`Failed to decompress gzip response: ${gzipError instanceof Error ? gzipError.message : String(gzipError)}`, { cause: gzipError });
|
|
405
436
|
}
|
|
406
437
|
}
|
|
@@ -408,7 +439,68 @@ export class BaseFileProcessor {
|
|
|
408
439
|
}
|
|
409
440
|
finally {
|
|
410
441
|
clearTimeout(timeoutId);
|
|
442
|
+
// Release the connection on every early exit. Rejecting a response for
|
|
443
|
+
// its status or its Content-Type leaves the body unread, which keeps the
|
|
444
|
+
// socket out of the pool until the response is garbage-collected. After
|
|
445
|
+
// a successful read there is nothing left in flight, so this is a no-op.
|
|
446
|
+
controller.abort();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Read a response body, refusing it the moment it passes `maxBytes`.
|
|
451
|
+
*
|
|
452
|
+
* `response.arrayBuffer()` buffers whatever the server sends before anyone
|
|
453
|
+
* can object, so the size check further up only ever ran against a body that
|
|
454
|
+
* had already been paid for in full. Metering the stream makes the ceiling
|
|
455
|
+
* the configured limit instead of the server's choice, and cancelling the
|
|
456
|
+
* reader stops the transfer rather than merely stopping our interest in it.
|
|
457
|
+
*
|
|
458
|
+
* Content-Length is deliberately not trusted as the bound — it is a hint that
|
|
459
|
+
* can be absent or a lie — but it is worth honouring when present, because it
|
|
460
|
+
* refuses the honest oversized case without reading a byte.
|
|
461
|
+
*
|
|
462
|
+
* @param response - Fetch response whose body should be read
|
|
463
|
+
* @param maxBytes - Ceiling for the body, in bytes
|
|
464
|
+
* @returns The body as a Buffer
|
|
465
|
+
* @throws When the body exceeds `maxBytes`
|
|
466
|
+
*/
|
|
467
|
+
async readBodyWithinLimit(response, maxBytes) {
|
|
468
|
+
const declared = Number(response.headers.get("Content-Length"));
|
|
469
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
470
|
+
// Release the socket. An unread body keeps the connection out of the
|
|
471
|
+
// pool until the response is collected, so a run of oversized downloads
|
|
472
|
+
// would hold one socket open each.
|
|
473
|
+
await response.body?.cancel();
|
|
474
|
+
throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
|
|
475
|
+
}
|
|
476
|
+
// Some responses carry no readable stream (mocked fetch, polyfills). The
|
|
477
|
+
// buffered read is the fallback, still checked — just after the fact.
|
|
478
|
+
if (!response.body) {
|
|
479
|
+
const buffered = Buffer.from(await response.arrayBuffer());
|
|
480
|
+
if (buffered.length > maxBytes) {
|
|
481
|
+
throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
|
|
482
|
+
}
|
|
483
|
+
return buffered;
|
|
484
|
+
}
|
|
485
|
+
const reader = response.body.getReader();
|
|
486
|
+
const chunks = [];
|
|
487
|
+
let total = 0;
|
|
488
|
+
for (;;) {
|
|
489
|
+
const { done, value } = await reader.read();
|
|
490
|
+
if (done) {
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
if (!value) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
total += value.byteLength;
|
|
497
|
+
if (total > maxBytes) {
|
|
498
|
+
await reader.cancel();
|
|
499
|
+
throw downloadTooLargeError(this.config.maxSizeMB, this.config.fileTypeName);
|
|
500
|
+
}
|
|
501
|
+
chunks.push(Buffer.from(value));
|
|
411
502
|
}
|
|
503
|
+
return Buffer.concat(chunks, total);
|
|
412
504
|
}
|
|
413
505
|
/**
|
|
414
506
|
* Download file with retry logic for transient failures.
|
|
@@ -559,6 +651,15 @@ export class BaseFileProcessor {
|
|
|
559
651
|
* @returns Structured file processing error
|
|
560
652
|
*/
|
|
561
653
|
classifyDownloadError(error) {
|
|
654
|
+
// Refusing an oversized body mid-stream is the same verdict the old
|
|
655
|
+
// post-download check gave, so it must carry the same code — otherwise
|
|
656
|
+
// bounding the read earlier would silently reclassify a familiar error.
|
|
657
|
+
if (isDownloadTooLarge(error)) {
|
|
658
|
+
return this.createError(FileErrorCode.FILE_TOO_LARGE, {
|
|
659
|
+
maxMB: this.config.maxSizeMB,
|
|
660
|
+
type: this.config.fileTypeName,
|
|
661
|
+
}, error);
|
|
662
|
+
}
|
|
562
663
|
if (isAbortError(error)) {
|
|
563
664
|
return this.createError(FileErrorCode.DOWNLOAD_TIMEOUT, { timeoutMs: this.config.timeoutMs }, error);
|
|
564
665
|
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* @module processors/document/OpenDocumentProcessor
|
|
8
8
|
*/
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
|
+
import * as zlib from "node:zlib";
|
|
10
11
|
import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
|
|
12
|
+
import { readZipEntryWithinLimit } from "../archive/zipEntryReader.js";
|
|
11
13
|
import { SIZE_LIMITS } from "../config/index.js";
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
13
15
|
// Re-import for local use within this file
|
|
@@ -64,7 +66,17 @@ export class OpenDocumentProcessor extends BaseFileProcessor {
|
|
|
64
66
|
// Try to get content.xml
|
|
65
67
|
const contentEntry = zip.getEntry("content.xml");
|
|
66
68
|
if (contentEntry) {
|
|
67
|
-
|
|
69
|
+
// Bounded rather than `getData()`: an ODF file is a ZIP, so its
|
|
70
|
+
// content.xml can declare any uncompressed size it likes, and
|
|
71
|
+
// `maxSizeMB` only ever saw the compressed archive on the way in.
|
|
72
|
+
const read = readZipEntryWithinLimit(contentEntry, SIZE_LIMITS.DOCUMENT_MAX_MB * 1024 * 1024, zlib);
|
|
73
|
+
if (read.status === "too-large") {
|
|
74
|
+
throw new Error(`content.xml exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit for OpenDocument content`);
|
|
75
|
+
}
|
|
76
|
+
if (read.status !== "ok") {
|
|
77
|
+
throw new Error("content.xml could not be read from the archive");
|
|
78
|
+
}
|
|
79
|
+
const xmlContent = read.buffer.toString("utf-8");
|
|
68
80
|
const extracted = this.extractTextFromXml(xmlContent);
|
|
69
81
|
textContent = extracted.text;
|
|
70
82
|
paragraphCount = extracted.paragraphCount;
|
|
@@ -27,6 +27,47 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
import AdmZip from "adm-zip";
|
|
30
|
+
import * as zlib from "node:zlib";
|
|
31
|
+
import { readZipEntryWithinLimit } from "../archive/zipEntryReader.js";
|
|
32
|
+
import { SIZE_LIMITS } from "../config/index.js";
|
|
33
|
+
/**
|
|
34
|
+
* Total budget for everything read out of one presentation.
|
|
35
|
+
*
|
|
36
|
+
* This class is a static utility with no `BaseFileProcessor` behind it, so
|
|
37
|
+
* there is no `maxSizeMB` to inherit and nothing else was bounding these
|
|
38
|
+
* reads at all — not even a late check. A .pptx is a ZIP, so any entry in it
|
|
39
|
+
* can declare whatever uncompressed size its author likes.
|
|
40
|
+
*/
|
|
41
|
+
const PPTX_MAX_TOTAL_BYTES = SIZE_LIMITS.DOCUMENT_MAX_MB * 1024 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* Read entries out of one presentation, refusing once the total passes budget.
|
|
44
|
+
*
|
|
45
|
+
* The budget spans the call rather than each entry: a deck of two hundred
|
|
46
|
+
* slides that each sit just under a per-entry limit is the obvious way around
|
|
47
|
+
* one. Returns null for an absent or unreadable entry, which every call site
|
|
48
|
+
* already treats as "no content here".
|
|
49
|
+
*/
|
|
50
|
+
function createBoundedEntryReader() {
|
|
51
|
+
let spent = 0;
|
|
52
|
+
return (entry) => {
|
|
53
|
+
if (!entry) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const remaining = PPTX_MAX_TOTAL_BYTES - spent;
|
|
57
|
+
if (remaining <= 0) {
|
|
58
|
+
throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`);
|
|
59
|
+
}
|
|
60
|
+
const read = readZipEntryWithinLimit(entry, remaining, zlib);
|
|
61
|
+
if (read.status === "too-large") {
|
|
62
|
+
throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`);
|
|
63
|
+
}
|
|
64
|
+
if (read.status !== "ok") {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
spent += read.buffer.length;
|
|
68
|
+
return read.buffer.toString("utf-8");
|
|
69
|
+
};
|
|
70
|
+
}
|
|
30
71
|
/**
|
|
31
72
|
* Regex to match text content within PowerPoint XML `<a:t>` elements.
|
|
32
73
|
* These elements contain the actual visible text on slides.
|
|
@@ -63,14 +104,17 @@ export class PptxProcessor {
|
|
|
63
104
|
static async extractText(content) {
|
|
64
105
|
const zip = new AdmZip(content);
|
|
65
106
|
const entries = zip.getEntries();
|
|
107
|
+
const readEntry = createBoundedEntryReader();
|
|
66
108
|
// Collect slide entries with their slide numbers for sorting
|
|
67
109
|
const slides = [];
|
|
68
110
|
for (const entry of entries) {
|
|
69
111
|
const match = entry.entryName.match(SLIDE_ENTRY_REGEX);
|
|
70
112
|
if (match) {
|
|
71
113
|
const slideNumber = parseInt(match[1], 10);
|
|
72
|
-
const xmlContent = entry
|
|
73
|
-
|
|
114
|
+
const xmlContent = readEntry(entry);
|
|
115
|
+
if (xmlContent !== null) {
|
|
116
|
+
slides.push({ slideNumber, xml: xmlContent });
|
|
117
|
+
}
|
|
74
118
|
}
|
|
75
119
|
}
|
|
76
120
|
// Sort slides by number (slide1, slide2, ...)
|
|
@@ -82,7 +126,7 @@ export class PptxProcessor {
|
|
|
82
126
|
parts.push(`Presentation: ${slides.length} slide(s)\n`);
|
|
83
127
|
for (const slide of slides) {
|
|
84
128
|
const texts = PptxProcessor.extractTextFromXml(slide.xml);
|
|
85
|
-
const notes = PptxProcessor.extractNotesForSlide(zip, slide.slideNumber);
|
|
129
|
+
const notes = PptxProcessor.extractNotesForSlide(zip, slide.slideNumber, readEntry);
|
|
86
130
|
// Emit a slide section when it has either body text or speaker notes.
|
|
87
131
|
if (texts.length > 0 || notes) {
|
|
88
132
|
parts.push(`### Slide ${slide.slideNumber}`);
|
|
@@ -111,12 +155,11 @@ export class PptxProcessor {
|
|
|
111
155
|
* @param slideNumber - 1-indexed slide number
|
|
112
156
|
* @returns The notes text (runs joined by a space), or null when absent
|
|
113
157
|
*/
|
|
114
|
-
static extractNotesForSlide(zip, slideNumber) {
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
158
|
+
static extractNotesForSlide(zip, slideNumber, readEntry) {
|
|
159
|
+
const relsXml = readEntry(zip.getEntry(SLIDE_RELS_NAME(slideNumber)));
|
|
160
|
+
if (relsXml === null) {
|
|
117
161
|
return null;
|
|
118
162
|
}
|
|
119
|
-
const relsXml = relsEntry.getData().toString("utf-8");
|
|
120
163
|
let notesTarget = null;
|
|
121
164
|
RELATIONSHIP_TAG_REGEX.lastIndex = 0;
|
|
122
165
|
for (let match = RELATIONSHIP_TAG_REGEX.exec(relsXml); match !== null; match = RELATIONSHIP_TAG_REGEX.exec(relsXml)) {
|
|
@@ -134,11 +177,11 @@ export class PptxProcessor {
|
|
|
134
177
|
const normalized = notesTarget
|
|
135
178
|
.replace(/^\.\.\//, "ppt/")
|
|
136
179
|
.replace(/^\/+/, "");
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
180
|
+
const notesXml = readEntry(zip.getEntry(normalized));
|
|
181
|
+
if (notesXml === null) {
|
|
139
182
|
return null;
|
|
140
183
|
}
|
|
141
|
-
const notes = PptxProcessor.extractTextFromXml(
|
|
184
|
+
const notes = PptxProcessor.extractTextFromXml(notesXml).join(" ");
|
|
142
185
|
return notes.trim() || null;
|
|
143
186
|
}
|
|
144
187
|
/**
|
|
@@ -175,6 +218,7 @@ export class PptxProcessor {
|
|
|
175
218
|
static async extractSlides(content, slideNumbers) {
|
|
176
219
|
const zip = new AdmZip(content);
|
|
177
220
|
const entries = zip.getEntries();
|
|
221
|
+
const readEntry = createBoundedEntryReader();
|
|
178
222
|
// Collect all slides
|
|
179
223
|
const slides = [];
|
|
180
224
|
for (const entry of entries) {
|
|
@@ -182,8 +226,10 @@ export class PptxProcessor {
|
|
|
182
226
|
if (match) {
|
|
183
227
|
const slideNumber = parseInt(match[1], 10);
|
|
184
228
|
if (slideNumbers.includes(slideNumber)) {
|
|
185
|
-
const xmlContent = entry
|
|
186
|
-
|
|
229
|
+
const xmlContent = readEntry(entry);
|
|
230
|
+
if (xmlContent !== null) {
|
|
231
|
+
slides.push({ slideNumber, xml: xmlContent });
|
|
232
|
+
}
|
|
187
233
|
}
|
|
188
234
|
}
|
|
189
235
|
}
|
|
@@ -761,6 +761,44 @@ export type ArchiveDecompressionResult = {
|
|
|
761
761
|
} | {
|
|
762
762
|
readonly status: "failed";
|
|
763
763
|
};
|
|
764
|
+
/**
|
|
765
|
+
* Outcome of reading one ZIP entry under an explicit output bound.
|
|
766
|
+
*
|
|
767
|
+
* Separate from {@link ArchiveDecompressionResult} because the failures differ:
|
|
768
|
+
* a single-stream archive can fail for want of an external tool, while a ZIP
|
|
769
|
+
* entry can instead use a compression method this reader does not implement.
|
|
770
|
+
* Collapsing them would force one caller to handle a state it can never see.
|
|
771
|
+
*
|
|
772
|
+
* `too-large` is a distinct outcome rather than a corrupt-file error because it
|
|
773
|
+
* is a verdict about our limit, not about the archive: the entry may be
|
|
774
|
+
* perfectly well-formed, and telling the user it is damaged would send them to
|
|
775
|
+
* re-create a file that was never broken.
|
|
776
|
+
*/
|
|
777
|
+
/**
|
|
778
|
+
* The slice of an adm-zip entry the bounded reader depends on.
|
|
779
|
+
*
|
|
780
|
+
* Structural rather than adm-zip's own `IZipEntry` so the reader states what it
|
|
781
|
+
* actually needs — the compressed bytes and the header fields it refuses to
|
|
782
|
+
* trust — instead of importing a library type it would then have to satisfy in
|
|
783
|
+
* full when building a test double.
|
|
784
|
+
*/
|
|
785
|
+
export type BoundedZipEntry = {
|
|
786
|
+
getCompressedData: () => Buffer;
|
|
787
|
+
header: {
|
|
788
|
+
method: number;
|
|
789
|
+
crc: number;
|
|
790
|
+
};
|
|
791
|
+
};
|
|
792
|
+
export type ArchiveEntryReadResult = {
|
|
793
|
+
readonly status: "ok";
|
|
794
|
+
readonly buffer: Buffer;
|
|
795
|
+
} | {
|
|
796
|
+
readonly status: "too-large";
|
|
797
|
+
} | {
|
|
798
|
+
readonly status: "unsupported-method";
|
|
799
|
+
} | {
|
|
800
|
+
readonly status: "corrupt";
|
|
801
|
+
};
|
|
764
802
|
/**
|
|
765
803
|
* Metadata about an individual entry within an archive.
|
|
766
804
|
*/
|