clear-excel 0.1.0
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/LICENSE +21 -0
- package/README.md +180 -0
- package/index.cjs +3 -0
- package/index.d.ts +62 -0
- package/index.js +7 -0
- package/package.json +68 -0
- package/src/index.cjs +894 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Ivanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# clear-excel
|
|
2
|
+
|
|
3
|
+
`clear-excel` safely removes drawing-backed content and unused cell styles from
|
|
4
|
+
OOXML `.xlsx` workbooks while preserving worksheet data and structure. It is a
|
|
5
|
+
plain Node.js library with no framework or build-time requirement.
|
|
6
|
+
|
|
7
|
+
## What it removes
|
|
8
|
+
|
|
9
|
+
- drawings and images;
|
|
10
|
+
- classic and threaded comments;
|
|
11
|
+
- controls, OLE objects, VML drawings, and worksheet pictures;
|
|
12
|
+
- threaded-comment person metadata;
|
|
13
|
+
- unused `cellXfs` style records.
|
|
14
|
+
|
|
15
|
+
Values, formulas and cached results, merged cells, worksheets, and styles that
|
|
16
|
+
are still referenced by cells, rows, or columns are retained.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Node.js 22 or newer;
|
|
21
|
+
- OOXML `.xlsx` input.
|
|
22
|
+
|
|
23
|
+
Legacy `.xls` files and macro-enabled `.xlsm` workbooks are intentionally not
|
|
24
|
+
supported.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install clear-excel
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Buffer API
|
|
33
|
+
|
|
34
|
+
ES modules:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
38
|
+
import { sanitizeExcel } from "clear-excel";
|
|
39
|
+
|
|
40
|
+
const input = await readFile("report.xlsx");
|
|
41
|
+
const output = await sanitizeExcel(input);
|
|
42
|
+
await writeFile("report.cleaned.xlsx", output);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
CommonJS:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const { readFile, writeFile } = require("node:fs/promises");
|
|
49
|
+
const { sanitizeExcel } = require("clear-excel");
|
|
50
|
+
|
|
51
|
+
const input = await readFile("report.xlsx");
|
|
52
|
+
const output = await sanitizeExcel(input);
|
|
53
|
+
await writeFile("report.cleaned.xlsx", output);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`sanitizeExcel` accepts a Node.js `Buffer`, returns a new `Buffer`, and never
|
|
57
|
+
modifies the input buffer.
|
|
58
|
+
|
|
59
|
+
## File API
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { sanitizeExcelFile } from "clear-excel";
|
|
63
|
+
|
|
64
|
+
await sanitizeExcelFile("report.xlsx", "report.cleaned.xlsx");
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The input and output paths must be different and end in `.xlsx`. The output
|
|
68
|
+
directory must already exist. An existing output file is not replaced unless
|
|
69
|
+
`overwrite: true` is explicitly supplied:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
await sanitizeExcelFile("report.xlsx", "report.cleaned.xlsx", {
|
|
73
|
+
overwrite: true,
|
|
74
|
+
requireSingleWorksheet: true,
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
Both sanitizing functions accept these options:
|
|
81
|
+
|
|
82
|
+
| Option | Type | Default | Description |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| `requireSingleWorksheet` | `boolean` | `false` | Reject workbooks that do not contain exactly one sheet. |
|
|
85
|
+
| `limits` | `object` | See below | Override individual ZIP/XML safety limits. |
|
|
86
|
+
|
|
87
|
+
`sanitizeExcelFile` additionally accepts `overwrite`, which defaults to
|
|
88
|
+
`false`.
|
|
89
|
+
|
|
90
|
+
The exported `DEFAULT_LIMITS` object contains the defaults:
|
|
91
|
+
|
|
92
|
+
| Limit | Default |
|
|
93
|
+
| --- | ---: |
|
|
94
|
+
| `maxEntryCount` | 10,000 entries |
|
|
95
|
+
| `maxUncompressedBytes` | 512 MiB |
|
|
96
|
+
| `maxXmlBytes` | 512 MiB aggregate XML output |
|
|
97
|
+
| `maxWorksheetXmlBytes` | 256 MiB per worksheet |
|
|
98
|
+
| `maxStylesXmlBytes` | 64 MiB |
|
|
99
|
+
| `maxMetadataXmlBytes` | 16 MiB per metadata part |
|
|
100
|
+
| `maxRelationshipsXmlBytes` | 8 MiB per relationships part |
|
|
101
|
+
|
|
102
|
+
Only specified values are overridden:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const output = await sanitizeExcel(input, {
|
|
106
|
+
limits: {
|
|
107
|
+
maxUncompressedBytes: 128 * 1024 * 1024,
|
|
108
|
+
maxWorksheetXmlBytes: 64 * 1024 * 1024,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every limit must be a positive safe integer. Raising limits can significantly
|
|
114
|
+
increase memory usage.
|
|
115
|
+
|
|
116
|
+
## Errors
|
|
117
|
+
|
|
118
|
+
Public functions reject with `ExcelSanitizationError`. Use its stable `code`
|
|
119
|
+
instead of matching the English message:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
import { ExcelSanitizationError, sanitizeExcel } from "clear-excel";
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await sanitizeExcel(input);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof ExcelSanitizationError) {
|
|
128
|
+
console.error(error.code, error.details);
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Code | Meaning |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `ERR_INVALID_ARGUMENT` | An argument or option has an invalid type or value. |
|
|
137
|
+
| `ERR_UNSUPPORTED_EXTENSION` | A file path does not end in `.xlsx`. |
|
|
138
|
+
| `ERR_INVALID_XLSX` | The ZIP/OOXML package is malformed or unsupported. |
|
|
139
|
+
| `ERR_LIMIT_EXCEEDED` | A configured ZIP or XML limit was exceeded. |
|
|
140
|
+
| `ERR_WORKSHEET_COUNT` | Exactly one worksheet was required. |
|
|
141
|
+
| `ERR_SAME_PATH` | Input and output resolve to the same path. |
|
|
142
|
+
| `ERR_OUTPUT_EXISTS` | Output exists and `overwrite` is false. |
|
|
143
|
+
| `ERR_FILE_READ` | The input could not be read. |
|
|
144
|
+
| `ERR_FILE_WRITE` | The sanitized output could not be written. |
|
|
145
|
+
|
|
146
|
+
The original technical error is available through `error.cause` when one
|
|
147
|
+
exists. Limit and worksheet-count failures also include structured `details`.
|
|
148
|
+
|
|
149
|
+
## Security and memory model
|
|
150
|
+
|
|
151
|
+
Before loading an archive, `clear-excel` validates the ZIP central directory,
|
|
152
|
+
rejects ZIP64 and multi-disk archives, rejects unsafe entry paths, and checks
|
|
153
|
+
declared entry counts and expansion sizes. Required XML parts are inflated
|
|
154
|
+
lazily with both per-part and aggregate runtime budgets, including protection
|
|
155
|
+
when ZIP size metadata has been forged.
|
|
156
|
+
|
|
157
|
+
JSZip still loads the ZIP directory and regenerates the complete workbook in
|
|
158
|
+
memory. Plan for memory usage above the input size, especially when increasing
|
|
159
|
+
the defaults. The sanitizer does not evaluate formulas or execute embedded
|
|
160
|
+
content.
|
|
161
|
+
|
|
162
|
+
## Publishing
|
|
163
|
+
|
|
164
|
+
Maintainers can verify the package locally with:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
npm ci
|
|
168
|
+
npm run check
|
|
169
|
+
npm login
|
|
170
|
+
npm publish
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`npm run check` validates syntax, runs the tests, inspects `npm pack --dry-run`,
|
|
174
|
+
and installs a temporary tarball to smoke-test both ESM and CommonJS exports.
|
|
175
|
+
`prepublishOnly` runs the same checks automatically. npm credentials and
|
|
176
|
+
publishing automation are deliberately not stored in this repository.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/index.cjs
ADDED
package/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
export interface ExcelSanitizationLimits {
|
|
4
|
+
maxEntryCount: number;
|
|
5
|
+
maxUncompressedBytes: number;
|
|
6
|
+
maxXmlBytes: number;
|
|
7
|
+
maxWorksheetXmlBytes: number;
|
|
8
|
+
maxStylesXmlBytes: number;
|
|
9
|
+
maxMetadataXmlBytes: number;
|
|
10
|
+
maxRelationshipsXmlBytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SanitizeExcelOptions {
|
|
14
|
+
requireSingleWorksheet?: boolean;
|
|
15
|
+
limits?: Partial<ExcelSanitizationLimits>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SanitizeExcelFileOptions extends SanitizeExcelOptions {
|
|
19
|
+
overwrite?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ExcelSanitizationErrorCode =
|
|
23
|
+
| "ERR_INVALID_ARGUMENT"
|
|
24
|
+
| "ERR_UNSUPPORTED_EXTENSION"
|
|
25
|
+
| "ERR_INVALID_XLSX"
|
|
26
|
+
| "ERR_LIMIT_EXCEEDED"
|
|
27
|
+
| "ERR_WORKSHEET_COUNT"
|
|
28
|
+
| "ERR_SAME_PATH"
|
|
29
|
+
| "ERR_OUTPUT_EXISTS"
|
|
30
|
+
| "ERR_FILE_READ"
|
|
31
|
+
| "ERR_FILE_WRITE";
|
|
32
|
+
|
|
33
|
+
export interface ExcelSanitizationErrorOptions {
|
|
34
|
+
cause?: unknown;
|
|
35
|
+
details?: Readonly<Record<string, unknown>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ExcelSanitizationError extends Error {
|
|
39
|
+
constructor(
|
|
40
|
+
code: ExcelSanitizationErrorCode,
|
|
41
|
+
message: string,
|
|
42
|
+
options?: ExcelSanitizationErrorOptions,
|
|
43
|
+
);
|
|
44
|
+
readonly name: "ExcelSanitizationError";
|
|
45
|
+
readonly code: ExcelSanitizationErrorCode;
|
|
46
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_LIMITS: Readonly<ExcelSanitizationLimits>;
|
|
50
|
+
|
|
51
|
+
export function isXlsxFilename(name: unknown): name is string;
|
|
52
|
+
|
|
53
|
+
export function sanitizeExcel(
|
|
54
|
+
buffer: Buffer,
|
|
55
|
+
options?: SanitizeExcelOptions,
|
|
56
|
+
): Promise<Buffer>;
|
|
57
|
+
|
|
58
|
+
export function sanitizeExcelFile(
|
|
59
|
+
inputPath: string,
|
|
60
|
+
outputPath: string,
|
|
61
|
+
options?: SanitizeExcelFileOptions,
|
|
62
|
+
): Promise<void>;
|
package/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import api from "./src/index.cjs";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LIMITS = api.DEFAULT_LIMITS;
|
|
4
|
+
export const ExcelSanitizationError = api.ExcelSanitizationError;
|
|
5
|
+
export const isXlsxFilename = api.isXlsxFilename;
|
|
6
|
+
export const sanitizeExcel = api.sanitizeExcel;
|
|
7
|
+
export const sanitizeExcelFile = api.sanitizeExcelFile;
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clear-excel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Safely remove drawings, comments, embedded objects, and unused styles from XLSX workbooks.",
|
|
5
|
+
"author": "Vladislav Ivanov",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./index.cjs",
|
|
9
|
+
"module": "./index.js",
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./index.d.ts",
|
|
14
|
+
"import": "./index.js",
|
|
15
|
+
"require": "./index.cjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"index.cjs",
|
|
21
|
+
"index.d.ts",
|
|
22
|
+
"src/index.cjs",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Vlad1slav1vanov/clear-excel.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Vlad1slav1vanov/clear-excel/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/Vlad1slav1vanov/clear-excel#readme",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"excel",
|
|
43
|
+
"xlsx",
|
|
44
|
+
"ooxml",
|
|
45
|
+
"sanitize",
|
|
46
|
+
"comments",
|
|
47
|
+
"images",
|
|
48
|
+
"styles"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"check:syntax": "node --check index.js && node --check index.cjs && node --check src/index.cjs",
|
|
52
|
+
"test": "node --test",
|
|
53
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
54
|
+
"test:package": "node scripts/verify-package.cjs",
|
|
55
|
+
"check": "npm run check:syntax && npm test && npm run pack:dry-run && npm run test:package",
|
|
56
|
+
"prepublishOnly": "npm run check"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"jszip": "^3.10.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^22.0.0",
|
|
63
|
+
"exceljs": "^4.4.0"
|
|
64
|
+
},
|
|
65
|
+
"overrides": {
|
|
66
|
+
"uuid": "^11.1.1"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs/promises");
|
|
5
|
+
const { posix: packagePath, resolve: resolveFilePath, dirname } = require("node:path");
|
|
6
|
+
const JSZip = require("jszip");
|
|
7
|
+
|
|
8
|
+
const DRAWING_RELATIONSHIP_SUFFIXES = [
|
|
9
|
+
"/comments",
|
|
10
|
+
"/control",
|
|
11
|
+
"/drawing",
|
|
12
|
+
"/image",
|
|
13
|
+
"/oleObject",
|
|
14
|
+
"/threadedComment",
|
|
15
|
+
"/vmlDrawing",
|
|
16
|
+
];
|
|
17
|
+
const PERSON_RELATIONSHIP_SUFFIXES = ["/person"];
|
|
18
|
+
const DRAWING_ELEMENTS = [
|
|
19
|
+
"controls",
|
|
20
|
+
"drawing",
|
|
21
|
+
"legacyDrawing",
|
|
22
|
+
"legacyDrawingHF",
|
|
23
|
+
"oleObjects",
|
|
24
|
+
"picture",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const ERROR_MESSAGES = Object.freeze({
|
|
28
|
+
ERR_INVALID_ARGUMENT: "Invalid argument",
|
|
29
|
+
ERR_UNSUPPORTED_EXTENSION: "Only .xlsx files are supported",
|
|
30
|
+
ERR_INVALID_XLSX: "Invalid or unsupported XLSX workbook",
|
|
31
|
+
ERR_LIMIT_EXCEEDED: "XLSX workbook exceeds a configured safety limit",
|
|
32
|
+
ERR_WORKSHEET_COUNT: "The workbook must contain exactly one worksheet",
|
|
33
|
+
ERR_SAME_PATH: "Input and output paths must be different",
|
|
34
|
+
ERR_OUTPUT_EXISTS: "The output file already exists",
|
|
35
|
+
ERR_FILE_READ: "Unable to read the input XLSX file",
|
|
36
|
+
ERR_FILE_WRITE: "Unable to write the sanitized XLSX file",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
40
|
+
maxEntryCount: 10_000,
|
|
41
|
+
maxUncompressedBytes: 512 * 1024 * 1024,
|
|
42
|
+
maxXmlBytes: 512 * 1024 * 1024,
|
|
43
|
+
maxWorksheetXmlBytes: 256 * 1024 * 1024,
|
|
44
|
+
maxStylesXmlBytes: 64 * 1024 * 1024,
|
|
45
|
+
maxMetadataXmlBytes: 16 * 1024 * 1024,
|
|
46
|
+
maxRelationshipsXmlBytes: 8 * 1024 * 1024,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const SANITIZE_OPTION_NAMES = new Set(["requireSingleWorksheet", "limits"]);
|
|
50
|
+
const FILE_OPTION_NAMES = new Set([
|
|
51
|
+
"requireSingleWorksheet",
|
|
52
|
+
"limits",
|
|
53
|
+
"overwrite",
|
|
54
|
+
]);
|
|
55
|
+
const LIMIT_NAMES = new Set(Object.keys(DEFAULT_LIMITS));
|
|
56
|
+
|
|
57
|
+
class ExcelSanitizationError extends Error {
|
|
58
|
+
constructor(code, message, options = {}) {
|
|
59
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
60
|
+
this.name = "ExcelSanitizationError";
|
|
61
|
+
this.code = code;
|
|
62
|
+
if (options.details !== undefined) {
|
|
63
|
+
this.details = Object.freeze({ ...options.details });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createError(code, options = {}) {
|
|
69
|
+
return new ExcelSanitizationError(
|
|
70
|
+
code,
|
|
71
|
+
options.message ?? ERROR_MESSAGES[code] ?? "Excel sanitization failed",
|
|
72
|
+
options,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateKnownKeys(value, knownKeys, label) {
|
|
81
|
+
for (const key of Object.keys(value)) {
|
|
82
|
+
if (!knownKeys.has(key)) {
|
|
83
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
84
|
+
message: `Unknown ${label}: ${key}`,
|
|
85
|
+
details: { key },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeLimits(overrides) {
|
|
92
|
+
if (overrides === undefined) {
|
|
93
|
+
return DEFAULT_LIMITS;
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(overrides)) {
|
|
96
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
97
|
+
message: "options.limits must be an object",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
validateKnownKeys(overrides, LIMIT_NAMES, "limit");
|
|
101
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
102
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
103
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
104
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
105
|
+
message: `options.limits.${name} must be a positive safe integer`,
|
|
106
|
+
details: { limit: name, value },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
limits[name] = value;
|
|
110
|
+
}
|
|
111
|
+
return Object.freeze(limits);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeOptions(options, fileOptions = false) {
|
|
115
|
+
if (options === undefined) {
|
|
116
|
+
return {
|
|
117
|
+
requireSingleWorksheet: false,
|
|
118
|
+
limits: DEFAULT_LIMITS,
|
|
119
|
+
...(fileOptions ? { overwrite: false } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(options)) {
|
|
123
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
124
|
+
message: "options must be an object",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
validateKnownKeys(
|
|
128
|
+
options,
|
|
129
|
+
fileOptions ? FILE_OPTION_NAMES : SANITIZE_OPTION_NAMES,
|
|
130
|
+
"option",
|
|
131
|
+
);
|
|
132
|
+
if (
|
|
133
|
+
options.requireSingleWorksheet !== undefined &&
|
|
134
|
+
typeof options.requireSingleWorksheet !== "boolean"
|
|
135
|
+
) {
|
|
136
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
137
|
+
message: "options.requireSingleWorksheet must be a boolean",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
fileOptions &&
|
|
142
|
+
options.overwrite !== undefined &&
|
|
143
|
+
typeof options.overwrite !== "boolean"
|
|
144
|
+
) {
|
|
145
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
146
|
+
message: "options.overwrite must be a boolean",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
requireSingleWorksheet: options.requireSingleWorksheet ?? false,
|
|
151
|
+
limits: normalizeLimits(options.limits),
|
|
152
|
+
...(fileOptions ? { overwrite: options.overwrite ?? false } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isXlsxFilename(name) {
|
|
157
|
+
return typeof name === "string" && name.toLowerCase().endsWith(".xlsx");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function sanitizeExcel(buffer, options) {
|
|
161
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
162
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
163
|
+
message: "buffer must be a Node.js Buffer",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const normalized = normalizeOptions(options);
|
|
167
|
+
try {
|
|
168
|
+
return await sanitizeOoxmlWorkbook(buffer, normalized);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof ExcelSanitizationError) {
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
throw createError("ERR_INVALID_XLSX", { cause: error });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function sanitizeExcelFile(inputPath, outputPath, options) {
|
|
178
|
+
if (
|
|
179
|
+
typeof inputPath !== "string" ||
|
|
180
|
+
inputPath.length === 0 ||
|
|
181
|
+
typeof outputPath !== "string" ||
|
|
182
|
+
outputPath.length === 0
|
|
183
|
+
) {
|
|
184
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
185
|
+
message: "inputPath and outputPath must be non-empty strings",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (!isXlsxFilename(inputPath) || !isXlsxFilename(outputPath)) {
|
|
189
|
+
throw createError("ERR_UNSUPPORTED_EXTENSION", {
|
|
190
|
+
details: { inputPath, outputPath },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const normalized = normalizeOptions(options, true);
|
|
194
|
+
const absoluteInputPath = resolveFilePath(inputPath);
|
|
195
|
+
const absoluteOutputPath = resolveFilePath(outputPath);
|
|
196
|
+
if (absoluteInputPath === absoluteOutputPath) {
|
|
197
|
+
throw createError("ERR_SAME_PATH", {
|
|
198
|
+
details: { inputPath: absoluteInputPath, outputPath: absoluteOutputPath },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let input;
|
|
203
|
+
try {
|
|
204
|
+
input = await fs.readFile(absoluteInputPath);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw createError("ERR_FILE_READ", {
|
|
207
|
+
cause: error,
|
|
208
|
+
details: { inputPath: absoluteInputPath },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const sanitized = await sanitizeExcel(input, {
|
|
213
|
+
requireSingleWorksheet: normalized.requireSingleWorksheet,
|
|
214
|
+
limits: normalized.limits,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!normalized.overwrite) {
|
|
218
|
+
try {
|
|
219
|
+
await fs.writeFile(absoluteOutputPath, sanitized, { flag: "wx" });
|
|
220
|
+
return;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error?.code === "EEXIST") {
|
|
223
|
+
throw createError("ERR_OUTPUT_EXISTS", {
|
|
224
|
+
cause: error,
|
|
225
|
+
details: { outputPath: absoluteOutputPath },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
await fs.unlink(absoluteOutputPath).catch(() => undefined);
|
|
229
|
+
throw createError("ERR_FILE_WRITE", {
|
|
230
|
+
cause: error,
|
|
231
|
+
details: { outputPath: absoluteOutputPath },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const temporaryPath = `${absoluteOutputPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
237
|
+
try {
|
|
238
|
+
await fs.writeFile(temporaryPath, sanitized, { flag: "wx" });
|
|
239
|
+
await fs.rename(temporaryPath, absoluteOutputPath);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
242
|
+
throw createError("ERR_FILE_WRITE", {
|
|
243
|
+
cause: error,
|
|
244
|
+
details: { outputPath: absoluteOutputPath },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function sanitizeOoxmlWorkbook(buffer, options) {
|
|
250
|
+
const { limits, requireSingleWorksheet } = options;
|
|
251
|
+
if (!hasZipSignature(buffer)) {
|
|
252
|
+
throw new Error("Invalid OOXML ZIP container");
|
|
253
|
+
}
|
|
254
|
+
validateZipCentralDirectory(buffer, limits);
|
|
255
|
+
const readBudget = { consumedBytes: 0, limitBytes: limits.maxXmlBytes };
|
|
256
|
+
|
|
257
|
+
// CRC verification eagerly inflates every archive entry. Required XML parts
|
|
258
|
+
// are instead inflated lazily below and constrained by per-part budgets.
|
|
259
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
260
|
+
const workbookXml = await requireXml(
|
|
261
|
+
zip,
|
|
262
|
+
"xl/workbook.xml",
|
|
263
|
+
readBudget,
|
|
264
|
+
limits.maxMetadataXmlBytes,
|
|
265
|
+
);
|
|
266
|
+
const workbookRelsPath = "xl/_rels/workbook.xml.rels";
|
|
267
|
+
const workbookRelsXml = await requireXml(
|
|
268
|
+
zip,
|
|
269
|
+
workbookRelsPath,
|
|
270
|
+
readBudget,
|
|
271
|
+
limits.maxRelationshipsXmlBytes,
|
|
272
|
+
);
|
|
273
|
+
const workbookRels = parseRelationships(workbookRelsXml, "xl/workbook.xml");
|
|
274
|
+
await requireXml(
|
|
275
|
+
zip,
|
|
276
|
+
"[Content_Types].xml",
|
|
277
|
+
readBudget,
|
|
278
|
+
limits.maxMetadataXmlBytes,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const sheetTags = workbookXml.match(/<(?:\w+:)?sheet\b[^>]*\/?>/gi) ?? [];
|
|
282
|
+
enforceWorksheetCount(sheetTags.length, requireSingleWorksheet);
|
|
283
|
+
if (sheetTags.length === 0) {
|
|
284
|
+
throw new Error("Workbook contains no sheets");
|
|
285
|
+
}
|
|
286
|
+
const relsById = new Map(workbookRels.map((relationship) => [relationship.id, relationship]));
|
|
287
|
+
const worksheetPaths = sheetTags.flatMap((sheetTag) => {
|
|
288
|
+
const id = getAttribute(sheetTag, "r:id");
|
|
289
|
+
const relationship = id ? relsById.get(id) : undefined;
|
|
290
|
+
return relationship?.type.endsWith("/worksheet")
|
|
291
|
+
? [relationship.target]
|
|
292
|
+
: [];
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const detachedRoots = new Set();
|
|
296
|
+
const cleanedWorkbookRels = removeRelationships(
|
|
297
|
+
workbookRelsXml,
|
|
298
|
+
"xl/workbook.xml",
|
|
299
|
+
PERSON_RELATIONSHIP_SUFFIXES,
|
|
300
|
+
);
|
|
301
|
+
zip.file(workbookRelsPath, cleanedWorkbookRels.xml);
|
|
302
|
+
for (const target of cleanedWorkbookRels.targets) {
|
|
303
|
+
detachedRoots.add(target);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const worksheetXmlByPath = new Map();
|
|
307
|
+
for (const worksheetPath of worksheetPaths) {
|
|
308
|
+
const worksheetXml = await requireXml(
|
|
309
|
+
zip,
|
|
310
|
+
worksheetPath,
|
|
311
|
+
readBudget,
|
|
312
|
+
limits.maxWorksheetXmlBytes,
|
|
313
|
+
);
|
|
314
|
+
const cleanedWorksheetXml = removeThreadedCommentExtensions(
|
|
315
|
+
removeWorksheetDrawingMarkup(worksheetXml),
|
|
316
|
+
);
|
|
317
|
+
worksheetXmlByPath.set(worksheetPath, cleanedWorksheetXml);
|
|
318
|
+
zip.file(worksheetPath, cleanedWorksheetXml);
|
|
319
|
+
|
|
320
|
+
const relationshipsPath = relationshipFileForPart(worksheetPath);
|
|
321
|
+
const relationshipsFile = zip.file(relationshipsPath);
|
|
322
|
+
if (relationshipsFile) {
|
|
323
|
+
const cleaned = removeRelationships(
|
|
324
|
+
await readXmlPart(
|
|
325
|
+
relationshipsFile,
|
|
326
|
+
relationshipsPath,
|
|
327
|
+
readBudget,
|
|
328
|
+
limits.maxRelationshipsXmlBytes,
|
|
329
|
+
),
|
|
330
|
+
worksheetPath,
|
|
331
|
+
DRAWING_RELATIONSHIP_SUFFIXES,
|
|
332
|
+
);
|
|
333
|
+
zip.file(relationshipsPath, cleaned.xml);
|
|
334
|
+
for (const target of cleaned.targets) {
|
|
335
|
+
detachedRoots.add(target);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await removeUnreachableParts(zip, detachedRoots, readBudget, limits);
|
|
341
|
+
await removeUnusedCellStyles(zip, worksheetXmlByPath, readBudget, limits);
|
|
342
|
+
return zip.generateAsync({
|
|
343
|
+
type: "nodebuffer",
|
|
344
|
+
compression: "DEFLATE",
|
|
345
|
+
compressionOptions: { level: 6 },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hasZipSignature(buffer) {
|
|
350
|
+
return buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateZipCentralDirectory(buffer, limits) {
|
|
354
|
+
const endOffset = findEndOfCentralDirectory(buffer);
|
|
355
|
+
if (endOffset === undefined || endOffset + 22 > buffer.length) {
|
|
356
|
+
throw new Error("ZIP end-of-central-directory record is missing");
|
|
357
|
+
}
|
|
358
|
+
const diskNumber = buffer.readUInt16LE(endOffset + 4);
|
|
359
|
+
const centralDirectoryDisk = buffer.readUInt16LE(endOffset + 6);
|
|
360
|
+
const entriesOnDisk = buffer.readUInt16LE(endOffset + 8);
|
|
361
|
+
const totalEntries = buffer.readUInt16LE(endOffset + 10);
|
|
362
|
+
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12);
|
|
363
|
+
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16);
|
|
364
|
+
const commentLength = buffer.readUInt16LE(endOffset + 20);
|
|
365
|
+
if (endOffset + 22 + commentLength > buffer.length) {
|
|
366
|
+
throw new Error("Truncated ZIP comment");
|
|
367
|
+
}
|
|
368
|
+
if (
|
|
369
|
+
diskNumber !== 0 ||
|
|
370
|
+
centralDirectoryDisk !== 0 ||
|
|
371
|
+
entriesOnDisk !== totalEntries
|
|
372
|
+
) {
|
|
373
|
+
throw new Error("Multi-disk ZIP files are not supported");
|
|
374
|
+
}
|
|
375
|
+
if (
|
|
376
|
+
totalEntries === 0xffff ||
|
|
377
|
+
centralDirectorySize === 0xffffffff ||
|
|
378
|
+
centralDirectoryOffset === 0xffffffff ||
|
|
379
|
+
(endOffset >= 20 && buffer.readUInt32LE(endOffset - 20) === 0x07064b50)
|
|
380
|
+
) {
|
|
381
|
+
throw new Error("ZIP64 files are not supported");
|
|
382
|
+
}
|
|
383
|
+
if (totalEntries > limits.maxEntryCount) {
|
|
384
|
+
throw limitExceeded("maxEntryCount", limits.maxEntryCount, totalEntries);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize;
|
|
388
|
+
if (
|
|
389
|
+
centralDirectoryOffset > endOffset ||
|
|
390
|
+
centralDirectoryEnd > endOffset ||
|
|
391
|
+
centralDirectoryEnd > buffer.length
|
|
392
|
+
) {
|
|
393
|
+
throw new Error("Invalid ZIP central-directory bounds");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let entryOffset = centralDirectoryOffset;
|
|
397
|
+
let totalUncompressedSize = 0;
|
|
398
|
+
for (let index = 0; index < totalEntries; index += 1) {
|
|
399
|
+
if (
|
|
400
|
+
entryOffset + 46 > centralDirectoryEnd ||
|
|
401
|
+
buffer.readUInt32LE(entryOffset) !== 0x02014b50
|
|
402
|
+
) {
|
|
403
|
+
throw new Error("Malformed ZIP central-directory entry");
|
|
404
|
+
}
|
|
405
|
+
const compressedSize = buffer.readUInt32LE(entryOffset + 20);
|
|
406
|
+
const uncompressedSize = buffer.readUInt32LE(entryOffset + 24);
|
|
407
|
+
const fileNameLength = buffer.readUInt16LE(entryOffset + 28);
|
|
408
|
+
const extraLength = buffer.readUInt16LE(entryOffset + 30);
|
|
409
|
+
const fileCommentLength = buffer.readUInt16LE(entryOffset + 32);
|
|
410
|
+
const entryDisk = buffer.readUInt16LE(entryOffset + 34);
|
|
411
|
+
const localHeaderOffset = buffer.readUInt32LE(entryOffset + 42);
|
|
412
|
+
if (
|
|
413
|
+
compressedSize === 0xffffffff ||
|
|
414
|
+
uncompressedSize === 0xffffffff ||
|
|
415
|
+
localHeaderOffset === 0xffffffff ||
|
|
416
|
+
entryDisk !== 0
|
|
417
|
+
) {
|
|
418
|
+
throw new Error("ZIP64 or multi-disk entries are not supported");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const fileNameOffset = entryOffset + 46;
|
|
422
|
+
const extraOffset = fileNameOffset + fileNameLength;
|
|
423
|
+
const nextEntryOffset = extraOffset + extraLength + fileCommentLength;
|
|
424
|
+
if (nextEntryOffset > centralDirectoryEnd) {
|
|
425
|
+
throw new Error("Truncated ZIP central-directory entry");
|
|
426
|
+
}
|
|
427
|
+
validatePackageEntryName(
|
|
428
|
+
buffer.subarray(fileNameOffset, extraOffset).toString("utf8"),
|
|
429
|
+
);
|
|
430
|
+
rejectZip64ExtraFields(buffer, extraOffset, extraLength);
|
|
431
|
+
if (localHeaderOffset >= centralDirectoryOffset) {
|
|
432
|
+
throw new Error("Invalid ZIP local-header offset");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
totalUncompressedSize += uncompressedSize;
|
|
436
|
+
if (totalUncompressedSize > limits.maxUncompressedBytes) {
|
|
437
|
+
throw limitExceeded(
|
|
438
|
+
"maxUncompressedBytes",
|
|
439
|
+
limits.maxUncompressedBytes,
|
|
440
|
+
totalUncompressedSize,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
entryOffset = nextEntryOffset;
|
|
444
|
+
}
|
|
445
|
+
if (entryOffset !== centralDirectoryEnd) {
|
|
446
|
+
throw new Error("ZIP central-directory entry count is inconsistent");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function validatePackageEntryName(name) {
|
|
451
|
+
const normalized = name.replaceAll("\\", "/");
|
|
452
|
+
if (
|
|
453
|
+
normalized.includes("\0") ||
|
|
454
|
+
normalized.startsWith("/") ||
|
|
455
|
+
/^[A-Za-z]:\//.test(normalized) ||
|
|
456
|
+
normalized.split("/").includes("..")
|
|
457
|
+
) {
|
|
458
|
+
throw new Error(`Unsafe ZIP entry name: ${name}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function findEndOfCentralDirectory(buffer) {
|
|
463
|
+
const minimumOffset = Math.max(0, buffer.length - 65_557);
|
|
464
|
+
for (let offset = buffer.length - 22; offset >= minimumOffset; offset -= 1) {
|
|
465
|
+
if (
|
|
466
|
+
buffer.readUInt32LE(offset) === 0x06054b50 &&
|
|
467
|
+
offset + 22 + buffer.readUInt16LE(offset + 20) === buffer.length
|
|
468
|
+
) {
|
|
469
|
+
return offset;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function rejectZip64ExtraFields(buffer, extraOffset, extraLength) {
|
|
476
|
+
const extraEnd = extraOffset + extraLength;
|
|
477
|
+
let offset = extraOffset;
|
|
478
|
+
while (offset < extraEnd) {
|
|
479
|
+
if (offset + 4 > extraEnd) {
|
|
480
|
+
throw new Error("Malformed ZIP extra field");
|
|
481
|
+
}
|
|
482
|
+
const headerId = buffer.readUInt16LE(offset);
|
|
483
|
+
const fieldLength = buffer.readUInt16LE(offset + 2);
|
|
484
|
+
offset += 4;
|
|
485
|
+
if (offset + fieldLength > extraEnd) {
|
|
486
|
+
throw new Error("Truncated ZIP extra field");
|
|
487
|
+
}
|
|
488
|
+
if (headerId === 0x0001) {
|
|
489
|
+
throw new Error("ZIP64 files are not supported");
|
|
490
|
+
}
|
|
491
|
+
offset += fieldLength;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function limitExceeded(limit, configured, actual, partName) {
|
|
496
|
+
return createError("ERR_LIMIT_EXCEEDED", {
|
|
497
|
+
details: {
|
|
498
|
+
limit,
|
|
499
|
+
configured,
|
|
500
|
+
actual,
|
|
501
|
+
...(partName === undefined ? {} : { partName }),
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function enforceWorksheetCount(count, required) {
|
|
507
|
+
if (required && count !== 1) {
|
|
508
|
+
throw createError("ERR_WORKSHEET_COUNT", {
|
|
509
|
+
details: { expected: 1, actual: count },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function requireXml(zip, partName, budget, partLimitBytes) {
|
|
515
|
+
const file = zip.file(partName);
|
|
516
|
+
if (!file) {
|
|
517
|
+
throw new Error(`Required workbook part is missing: ${partName}`);
|
|
518
|
+
}
|
|
519
|
+
return readXmlPart(file, partName, budget, partLimitBytes);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function readXmlPart(file, partName, budget, partLimitBytes) {
|
|
523
|
+
return new Promise((resolve, reject) => {
|
|
524
|
+
const chunks = [];
|
|
525
|
+
const stream = file.internalStream("nodebuffer");
|
|
526
|
+
let partBytes = 0;
|
|
527
|
+
let settled = false;
|
|
528
|
+
|
|
529
|
+
const fail = (error) => {
|
|
530
|
+
if (settled) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
settled = true;
|
|
534
|
+
stream.pause();
|
|
535
|
+
reject(error);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
stream
|
|
539
|
+
.on("data", (chunk) => {
|
|
540
|
+
if (settled) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
partBytes += chunk.length;
|
|
544
|
+
budget.consumedBytes += chunk.length;
|
|
545
|
+
if (partBytes > partLimitBytes) {
|
|
546
|
+
fail(limitExceeded("partBytes", partLimitBytes, partBytes, partName));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (budget.consumedBytes > budget.limitBytes) {
|
|
550
|
+
fail(
|
|
551
|
+
limitExceeded(
|
|
552
|
+
"maxXmlBytes",
|
|
553
|
+
budget.limitBytes,
|
|
554
|
+
budget.consumedBytes,
|
|
555
|
+
partName,
|
|
556
|
+
),
|
|
557
|
+
);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
chunks.push(Buffer.from(chunk));
|
|
561
|
+
})
|
|
562
|
+
.on("error", (error) => fail(error))
|
|
563
|
+
.on("end", () => {
|
|
564
|
+
if (settled) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
settled = true;
|
|
568
|
+
resolve(Buffer.concat(chunks, partBytes).toString("utf8"));
|
|
569
|
+
})
|
|
570
|
+
.resume();
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function removeWorksheetDrawingMarkup(xml) {
|
|
575
|
+
return DRAWING_ELEMENTS.reduce((result, name) => {
|
|
576
|
+
const element = new RegExp(
|
|
577
|
+
`<(?:\\w+:)?${name}\\b[^>]*(?:\\/\\s*>|>[\\s\\S]*?<\\/(?:\\w+:)?${name}\\s*>)`,
|
|
578
|
+
"gi",
|
|
579
|
+
);
|
|
580
|
+
return result.replace(element, "");
|
|
581
|
+
}, xml);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function removeThreadedCommentExtensions(xml) {
|
|
585
|
+
return xml.replace(
|
|
586
|
+
/<(?:\w+:)?ext\b[^>]*>[\s\S]*?<\/(?:\w+:)?ext\s*>/gi,
|
|
587
|
+
(extension) =>
|
|
588
|
+
/<(?:\w+:)?threadedComments?\b/i.test(extension) ? "" : extension,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function removeRelationships(xml, source, relationshipSuffixes) {
|
|
593
|
+
const targets = new Set();
|
|
594
|
+
const cleaned = xml.replace(
|
|
595
|
+
/<(?:\w+:)?Relationship\b[^>]*?(?:\/\s*>|>\s*<\/(?:\w+:)?Relationship\s*>)/gi,
|
|
596
|
+
(tag) => {
|
|
597
|
+
const type = getAttribute(tag, "Type") ?? "";
|
|
598
|
+
const target = getAttribute(tag, "Target");
|
|
599
|
+
const targetMode = getAttribute(tag, "TargetMode");
|
|
600
|
+
if (
|
|
601
|
+
!target ||
|
|
602
|
+
targetMode?.toLowerCase() === "external" ||
|
|
603
|
+
!relationshipSuffixes.some((suffix) => type.endsWith(suffix))
|
|
604
|
+
) {
|
|
605
|
+
return tag;
|
|
606
|
+
}
|
|
607
|
+
targets.add(resolveTarget(source, target));
|
|
608
|
+
return "";
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
return { xml: cleaned, targets };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parseRelationships(xml, source) {
|
|
615
|
+
return Array.from(
|
|
616
|
+
xml.matchAll(
|
|
617
|
+
/<(?:\w+:)?Relationship\b([^>]*?)(?:\/\s*>|>\s*<\/(?:\w+:)?Relationship\s*>)/gi,
|
|
618
|
+
),
|
|
619
|
+
).flatMap((match) => {
|
|
620
|
+
const attributes = match[1];
|
|
621
|
+
const id = getAttribute(attributes, "Id");
|
|
622
|
+
const target = getAttribute(attributes, "Target");
|
|
623
|
+
const type = getAttribute(attributes, "Type");
|
|
624
|
+
if (!id || !target || !type) {
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
627
|
+
const targetMode = getAttribute(attributes, "TargetMode");
|
|
628
|
+
return [
|
|
629
|
+
{
|
|
630
|
+
id,
|
|
631
|
+
target:
|
|
632
|
+
targetMode?.toLowerCase() === "external"
|
|
633
|
+
? target
|
|
634
|
+
: resolveTarget(source, target),
|
|
635
|
+
targetMode,
|
|
636
|
+
type,
|
|
637
|
+
},
|
|
638
|
+
];
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function getAttribute(xml, name) {
|
|
643
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
644
|
+
const match = xml.match(
|
|
645
|
+
new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(["'])(.*?)\\1`, "i"),
|
|
646
|
+
);
|
|
647
|
+
return match ? decodeXml(match[2]) : undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function decodeXml(value) {
|
|
651
|
+
return value
|
|
652
|
+
.replaceAll("&", "&")
|
|
653
|
+
.replaceAll(""", '"')
|
|
654
|
+
.replaceAll("'", "'")
|
|
655
|
+
.replaceAll("<", "<")
|
|
656
|
+
.replaceAll(">", ">");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function resolveTarget(source, target) {
|
|
660
|
+
const resolved = target.startsWith("/")
|
|
661
|
+
? packagePath.normalize(target.slice(1))
|
|
662
|
+
: packagePath.normalize(packagePath.join(packagePath.dirname(source), target));
|
|
663
|
+
if (resolved === ".." || resolved.startsWith("../")) {
|
|
664
|
+
throw new Error(`Relationship target escapes package: ${target}`);
|
|
665
|
+
}
|
|
666
|
+
return resolved;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function relationshipFileForPart(partName) {
|
|
670
|
+
return packagePath.join(
|
|
671
|
+
packagePath.dirname(partName),
|
|
672
|
+
"_rels",
|
|
673
|
+
`${packagePath.basename(partName)}.rels`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sourcePartForRelationshipFile(fileName) {
|
|
678
|
+
if (fileName === "_rels/.rels") {
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
const match = fileName.match(/^(.*\/)_rels\/([^/]+)\.rels$/);
|
|
682
|
+
return match ? `${match[1]}${match[2]}` : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function removeUnreachableParts(zip, detachedRoots, readBudget, limits) {
|
|
686
|
+
if (detachedRoots.size === 0) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const graph = new Map();
|
|
690
|
+
for (const fileName of Object.keys(zip.files)) {
|
|
691
|
+
if (!fileName.endsWith(".rels")) {
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const source = sourcePartForRelationshipFile(fileName);
|
|
695
|
+
const file = zip.file(fileName);
|
|
696
|
+
if (source !== undefined && file) {
|
|
697
|
+
graph.set(
|
|
698
|
+
source,
|
|
699
|
+
parseRelationships(
|
|
700
|
+
await readXmlPart(
|
|
701
|
+
file,
|
|
702
|
+
fileName,
|
|
703
|
+
readBudget,
|
|
704
|
+
limits.maxRelationshipsXmlBytes,
|
|
705
|
+
),
|
|
706
|
+
source,
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const candidates = collectReachableParts(detachedRoots, graph);
|
|
713
|
+
const packageReachable = collectReachableParts(new Set([""]), graph);
|
|
714
|
+
packageReachable.delete("");
|
|
715
|
+
const deletedParts = new Set(
|
|
716
|
+
[...candidates].filter((partName) => !packageReachable.has(partName)),
|
|
717
|
+
);
|
|
718
|
+
for (const partName of deletedParts) {
|
|
719
|
+
zip.remove(partName);
|
|
720
|
+
zip.remove(relationshipFileForPart(partName));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const contentTypes = await requireXml(
|
|
724
|
+
zip,
|
|
725
|
+
"[Content_Types].xml",
|
|
726
|
+
readBudget,
|
|
727
|
+
limits.maxMetadataXmlBytes,
|
|
728
|
+
);
|
|
729
|
+
zip.file(
|
|
730
|
+
"[Content_Types].xml",
|
|
731
|
+
contentTypes.replace(/<(?:\w+:)?Override\b[^>]*\/>/gi, (tag) => {
|
|
732
|
+
const partName = getAttribute(tag, "PartName")?.replace(/^\//, "");
|
|
733
|
+
return partName && deletedParts.has(partName) ? "" : tag;
|
|
734
|
+
}),
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function collectReachableParts(initial, graph) {
|
|
739
|
+
const reachable = new Set(initial);
|
|
740
|
+
const pending = [...initial];
|
|
741
|
+
while (pending.length > 0) {
|
|
742
|
+
const source = pending.pop();
|
|
743
|
+
if (source === undefined) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
for (const relationship of graph.get(source) ?? []) {
|
|
747
|
+
if (
|
|
748
|
+
relationship.targetMode?.toLowerCase() !== "external" &&
|
|
749
|
+
!reachable.has(relationship.target)
|
|
750
|
+
) {
|
|
751
|
+
reachable.add(relationship.target);
|
|
752
|
+
pending.push(relationship.target);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return reachable;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function removeUnusedCellStyles(zip, worksheets, readBudget, limits) {
|
|
760
|
+
const stylesFile = zip.file("xl/styles.xml");
|
|
761
|
+
if (!stylesFile) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const usedStyles = new Set([0]);
|
|
765
|
+
for (const xml of worksheets.values()) {
|
|
766
|
+
collectStyleReferences(xml, usedStyles);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const stylesXml = await readXmlPart(
|
|
770
|
+
stylesFile,
|
|
771
|
+
"xl/styles.xml",
|
|
772
|
+
readBudget,
|
|
773
|
+
limits.maxStylesXmlBytes,
|
|
774
|
+
);
|
|
775
|
+
const cellXfs = findCollection(stylesXml, "cellXfs");
|
|
776
|
+
if (!cellXfs) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const xfs = matchElements(cellXfs.body, "xf");
|
|
780
|
+
const retainedIndexes = [...usedStyles].sort((left, right) => left - right);
|
|
781
|
+
if (
|
|
782
|
+
retainedIndexes.some(
|
|
783
|
+
(index) => !Number.isInteger(index) || index < 0 || index >= xfs.length,
|
|
784
|
+
)
|
|
785
|
+
) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`Worksheet references an invalid style index (${retainedIndexes.join(",")} of ${xfs.length})`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
const indexMap = new Map(
|
|
791
|
+
retainedIndexes.map((oldIndex, newIndex) => [oldIndex, newIndex]),
|
|
792
|
+
);
|
|
793
|
+
zip.file(
|
|
794
|
+
"xl/styles.xml",
|
|
795
|
+
replaceCollection(
|
|
796
|
+
stylesXml,
|
|
797
|
+
"cellXfs",
|
|
798
|
+
retainedIndexes.map((index) => xfs[index]).join(""),
|
|
799
|
+
retainedIndexes.length,
|
|
800
|
+
),
|
|
801
|
+
);
|
|
802
|
+
for (const [worksheetPath, xml] of worksheets) {
|
|
803
|
+
zip.file(worksheetPath, remapWorksheetStyles(xml, indexMap));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function collectStyleReferences(xml, references) {
|
|
808
|
+
for (const tag of xml.match(/<(?:\w+:)?(?:c|row)\b[^>]*>/gi) ?? []) {
|
|
809
|
+
const value = getAttribute(tag, "s");
|
|
810
|
+
if (value !== undefined) {
|
|
811
|
+
references.add(Number(value));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
for (const tag of xml.match(/<(?:\w+:)?col\b[^>]*>/gi) ?? []) {
|
|
815
|
+
const value = getAttribute(tag, "style");
|
|
816
|
+
if (value !== undefined) {
|
|
817
|
+
references.add(Number(value));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function remapWorksheetStyles(xml, indexMap) {
|
|
823
|
+
return xml
|
|
824
|
+
.replace(/<(?:\w+:)?(?:c|row)\b[^>]*>/gi, (tag) =>
|
|
825
|
+
remapNumericAttribute(tag, "s", indexMap),
|
|
826
|
+
)
|
|
827
|
+
.replace(/<(?:\w+:)?col\b[^>]*>/gi, (tag) =>
|
|
828
|
+
remapNumericAttribute(tag, "style", indexMap),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function remapNumericAttribute(tag, attributeName, indexMap) {
|
|
833
|
+
const expression = new RegExp(
|
|
834
|
+
`(\\s${attributeName}\\s*=\\s*)(["'])(\\d+)\\2`,
|
|
835
|
+
"i",
|
|
836
|
+
);
|
|
837
|
+
return tag.replace(expression, (match, prefix, quote, value) => {
|
|
838
|
+
const remapped = indexMap.get(Number(value));
|
|
839
|
+
return remapped === undefined
|
|
840
|
+
? match
|
|
841
|
+
: `${prefix}${quote}${remapped}${quote}`;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function findCollection(xml, name) {
|
|
846
|
+
const match = xml.match(
|
|
847
|
+
new RegExp(
|
|
848
|
+
`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)<\\/(?:\\w+:)?${name}\\s*>`,
|
|
849
|
+
"i",
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
if (!match) {
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
body: match[1],
|
|
857
|
+
full: match[0],
|
|
858
|
+
opening: match[0].slice(0, match[0].indexOf(">") + 1),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function replaceCollection(xml, name, body, count) {
|
|
863
|
+
const collection = findCollection(xml, name);
|
|
864
|
+
if (!collection) {
|
|
865
|
+
return xml;
|
|
866
|
+
}
|
|
867
|
+
const opening = /\bcount\s*=/.test(collection.opening)
|
|
868
|
+
? collection.opening.replace(
|
|
869
|
+
/(\bcount\s*=\s*)(["'])\d+\2/i,
|
|
870
|
+
`$1"${count}"`,
|
|
871
|
+
)
|
|
872
|
+
: collection.opening.replace(/>$/, ` count="${count}">`);
|
|
873
|
+
const prefix = collection.opening.match(/^<(\w+:)/)?.[1] ?? "";
|
|
874
|
+
return xml.replace(collection.full, `${opening}${body}</${prefix}${name}>`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function matchElements(xml, name) {
|
|
878
|
+
return (
|
|
879
|
+
xml.match(
|
|
880
|
+
new RegExp(
|
|
881
|
+
`<(?:\\w+:)?${name}\\b[^>]*\\/\\s*>|<(?:\\w+:)?${name}\\b[^>]*>[\\s\\S]*?<\\/(?:\\w+:)?${name}\\s*>`,
|
|
882
|
+
"gi",
|
|
883
|
+
),
|
|
884
|
+
) ?? []
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
module.exports = {
|
|
889
|
+
DEFAULT_LIMITS,
|
|
890
|
+
ExcelSanitizationError,
|
|
891
|
+
isXlsxFilename,
|
|
892
|
+
sanitizeExcel,
|
|
893
|
+
sanitizeExcelFile,
|
|
894
|
+
};
|