@n0zer0d4y/vulcan-file-ops 1.2.13 → 1.3.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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * ZIP decompression-bomb guard for ZIP-based documents (VFO-15).
3
+ *
4
+ * Office Open XML (.docx/.xlsx/.pptx) and OpenDocument (.odt/.ods/.odp) files
5
+ * are ZIP archives that mammoth (via JSZip) and officeparser (via yauzl)
6
+ * inflate in memory. A small archive can expand to gigabytes, so before any
7
+ * parser touches the file we:
8
+ *
9
+ * 1. Parse the End Of Central Directory record and the central directory
10
+ * (located from the tail of the file, the same way JSZip/yauzl find it)
11
+ * and enforce limits on entry count, total declared uncompressed size
12
+ * and per-entry compression ratio. ZIP64 and multi-disk archives are
13
+ * rejected (Office/ODF documents within our size limits never need them).
14
+ * 2. Verify that no entry actually inflates beyond its declared size, by
15
+ * streaming each deflated entry through zlib and counting the output
16
+ * (nothing is kept in memory). Declared sizes are attacker-controlled and
17
+ * JSZip only checks them after inflating everything, so step 1 alone is
18
+ * not sufficient.
19
+ *
20
+ * No third-party dependencies.
21
+ */
22
+ import { promises as fs, createReadStream } from "fs";
23
+ import zlib from "zlib";
24
+ import { ZIP_MAX_COMPRESSION_RATIO, ZIP_MAX_ENTRIES, ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES, ZIP_RATIO_CHECK_MIN_BYTES, } from "./limits.js";
25
+ export const DEFAULT_ZIP_GUARD_LIMITS = {
26
+ maxTotalUncompressedBytes: ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES,
27
+ maxEntries: ZIP_MAX_ENTRIES,
28
+ maxCompressionRatio: ZIP_MAX_COMPRESSION_RATIO,
29
+ ratioCheckMinBytes: ZIP_RATIO_CHECK_MIN_BYTES,
30
+ };
31
+ export class UnsafeZipError extends Error {
32
+ reason;
33
+ constructor(reason, message) {
34
+ super(message);
35
+ this.reason = reason;
36
+ this.name = "UnsafeZipError";
37
+ }
38
+ }
39
+ const SIG_EOCD = 0x06054b50;
40
+ const SIG_ZIP64_EOCD_LOCATOR = 0x07064b50;
41
+ const SIG_CENTRAL_HEADER = 0x02014b50;
42
+ const SIG_LOCAL_HEADER = 0x04034b50;
43
+ const EOCD_MIN_SIZE = 22;
44
+ const MAX_COMMENT = 0xffff;
45
+ const CENTRAL_HEADER_SIZE = 46;
46
+ const LOCAL_HEADER_SIZE = 30;
47
+ function formatMB(bytes) {
48
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
49
+ }
50
+ async function readAt(handle, position, length) {
51
+ const buffer = Buffer.alloc(length);
52
+ let offset = 0;
53
+ while (offset < length) {
54
+ const { bytesRead } = await handle.read(buffer, offset, length - offset, position + offset);
55
+ if (bytesRead === 0)
56
+ break;
57
+ offset += bytesRead;
58
+ }
59
+ return offset === length ? buffer : buffer.subarray(0, offset);
60
+ }
61
+ /**
62
+ * Parse and validate the central directory of the ZIP file behind `handle`.
63
+ * Only the EOCD search window (last 64 KiB + 22 bytes) and the central
64
+ * directory itself are read.
65
+ */
66
+ export async function inspectZipCentralDirectory(handle, fileSize, limits = DEFAULT_ZIP_GUARD_LIMITS) {
67
+ if (fileSize < EOCD_MIN_SIZE) {
68
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (file too small)");
69
+ }
70
+ // Locate the last EOCD signature, like JSZip and yauzl.
71
+ const tailLength = Math.min(fileSize, EOCD_MIN_SIZE + MAX_COMMENT);
72
+ const tailStart = fileSize - tailLength;
73
+ const tail = await readAt(handle, tailStart, tailLength);
74
+ let eocd = -1;
75
+ for (let i = tail.length - EOCD_MIN_SIZE; i >= 0; i--) {
76
+ if (tail.readUInt32LE(i) === SIG_EOCD) {
77
+ eocd = i;
78
+ break;
79
+ }
80
+ }
81
+ if (eocd === -1) {
82
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (end of central directory not found)");
83
+ }
84
+ const eocdAbs = tailStart + eocd;
85
+ const diskNumber = tail.readUInt16LE(eocd + 4);
86
+ const centralDirDisk = tail.readUInt16LE(eocd + 6);
87
+ const entriesOnDisk = tail.readUInt16LE(eocd + 8);
88
+ const totalEntries = tail.readUInt16LE(eocd + 10);
89
+ const centralDirSize = tail.readUInt32LE(eocd + 12);
90
+ const centralDirOffset = tail.readUInt32LE(eocd + 16);
91
+ const zip64Locator = eocdAbs >= 20 &&
92
+ (eocd >= 20
93
+ ? tail.readUInt32LE(eocd - 20)
94
+ : (await readAt(handle, eocdAbs - 20, 4)).readUInt32LE(0)) ===
95
+ SIG_ZIP64_EOCD_LOCATOR;
96
+ if (zip64Locator ||
97
+ entriesOnDisk === 0xffff ||
98
+ totalEntries === 0xffff ||
99
+ centralDirSize === 0xffffffff ||
100
+ centralDirOffset === 0xffffffff) {
101
+ throw new UnsafeZipError("unsupported", "ZIP64 archives are not supported for document parsing");
102
+ }
103
+ if (diskNumber !== 0 || centralDirDisk !== 0 || entriesOnDisk !== totalEntries) {
104
+ throw new UnsafeZipError("unsupported", "multi-disk ZIP archives are not supported for document parsing");
105
+ }
106
+ if (totalEntries > limits.maxEntries) {
107
+ throw new UnsafeZipError("limit", `archive has ${totalEntries} entries (limit: ${limits.maxEntries})`);
108
+ }
109
+ if (centralDirOffset + centralDirSize > eocdAbs) {
110
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (central directory out of bounds)");
111
+ }
112
+ const cd = await readAt(handle, centralDirOffset, centralDirSize);
113
+ if (cd.length !== centralDirSize) {
114
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (truncated central directory)");
115
+ }
116
+ const entries = [];
117
+ let total = 0;
118
+ let p = 0;
119
+ // Like JSZip, read every central header present (not just the declared
120
+ // count) and require both to agree.
121
+ while (p + 4 <= cd.length && cd.readUInt32LE(p) === SIG_CENTRAL_HEADER) {
122
+ if (entries.length >= limits.maxEntries) {
123
+ throw new UnsafeZipError("limit", `archive has more than ${limits.maxEntries} entries`);
124
+ }
125
+ if (p + CENTRAL_HEADER_SIZE > cd.length) {
126
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (truncated central header)");
127
+ }
128
+ const flags = cd.readUInt16LE(p + 8);
129
+ const method = cd.readUInt16LE(p + 10);
130
+ const compressedSize = cd.readUInt32LE(p + 20);
131
+ const uncompressedSize = cd.readUInt32LE(p + 24);
132
+ const nameLength = cd.readUInt16LE(p + 28);
133
+ const extraLength = cd.readUInt16LE(p + 30);
134
+ const commentLength = cd.readUInt16LE(p + 32);
135
+ const diskStart = cd.readUInt16LE(p + 34);
136
+ const localHeaderOffset = cd.readUInt32LE(p + 42);
137
+ const recordEnd = p + CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength;
138
+ if (recordEnd > cd.length) {
139
+ throw new UnsafeZipError("invalid", "not a valid ZIP archive (truncated central header)");
140
+ }
141
+ const name = cd.toString("utf8", p + CENTRAL_HEADER_SIZE, p + CENTRAL_HEADER_SIZE + nameLength);
142
+ if (compressedSize === 0xffffffff ||
143
+ uncompressedSize === 0xffffffff ||
144
+ localHeaderOffset === 0xffffffff ||
145
+ diskStart === 0xffff) {
146
+ throw new UnsafeZipError("unsupported", "ZIP64 archives are not supported for document parsing");
147
+ }
148
+ total += uncompressedSize;
149
+ if (total > limits.maxTotalUncompressedBytes) {
150
+ throw new UnsafeZipError("limit", `total uncompressed size exceeds ${formatMB(limits.maxTotalUncompressedBytes)} ` +
151
+ `(possible zip bomb)`);
152
+ }
153
+ const ratio = uncompressedSize / Math.max(compressedSize, 1);
154
+ if (uncompressedSize > limits.ratioCheckMinBytes &&
155
+ ratio > limits.maxCompressionRatio) {
156
+ throw new UnsafeZipError("limit", `entry "${name}" has a compression ratio of ${Math.round(ratio)}:1 ` +
157
+ `(${formatMB(uncompressedSize)} uncompressed; limit ${limits.maxCompressionRatio}:1 ` +
158
+ `above ${formatMB(limits.ratioCheckMinBytes)}) (possible zip bomb)`);
159
+ }
160
+ entries.push({
161
+ name,
162
+ flags,
163
+ method,
164
+ compressedSize,
165
+ uncompressedSize,
166
+ localHeaderOffset,
167
+ });
168
+ p = recordEnd;
169
+ }
170
+ if (entries.length !== totalEntries) {
171
+ throw new UnsafeZipError("invalid", `not a valid ZIP archive (central directory has ${entries.length} entries, ` +
172
+ `end record declares ${totalEntries})`);
173
+ }
174
+ return { entries, totalUncompressedBytes: total };
175
+ }
176
+ /**
177
+ * Inflate one deflated entry without keeping the output, failing as soon as
178
+ * it produces more than `maxBytes`. Corrupt data is not treated as a bomb
179
+ * (the document parser will report it); only the size bound matters here.
180
+ */
181
+ function countInflatedBytes(filePath, start, compressedSize, maxBytes) {
182
+ return new Promise((resolve) => {
183
+ if (compressedSize === 0) {
184
+ resolve({ bytes: 0, exceeded: false });
185
+ return;
186
+ }
187
+ const input = createReadStream(filePath, {
188
+ start,
189
+ end: start + compressedSize - 1,
190
+ });
191
+ const inflate = zlib.createInflateRaw();
192
+ let bytes = 0;
193
+ let done = false;
194
+ const finish = (exceeded) => {
195
+ if (done)
196
+ return;
197
+ done = true;
198
+ input.destroy();
199
+ inflate.destroy();
200
+ resolve({ bytes, exceeded });
201
+ };
202
+ inflate.on("data", (chunk) => {
203
+ bytes += chunk.length;
204
+ if (bytes > maxBytes)
205
+ finish(true);
206
+ });
207
+ inflate.on("end", () => finish(false));
208
+ inflate.on("error", () => finish(false));
209
+ input.on("error", () => finish(false));
210
+ input.pipe(inflate);
211
+ });
212
+ }
213
+ /**
214
+ * Throws {@link UnsafeZipError} unless the ZIP file at `filePath` is within
215
+ * the configured decompression limits.
216
+ */
217
+ export async function assertSafeZipFile(filePath, limits = DEFAULT_ZIP_GUARD_LIMITS) {
218
+ const handle = await fs.open(filePath, "r");
219
+ try {
220
+ const { size } = await handle.stat();
221
+ const inspection = await inspectZipCentralDirectory(handle, size, limits);
222
+ let actualTotal = 0;
223
+ for (const entry of inspection.entries) {
224
+ // Encrypted entries cannot be inflated; parsers refuse them anyway.
225
+ if (entry.flags & 0x1)
226
+ continue;
227
+ if (entry.method !== 0 && entry.method !== 8)
228
+ continue; // unsupported by parsers
229
+ const local = await readAt(handle, entry.localHeaderOffset, LOCAL_HEADER_SIZE);
230
+ if (local.length !== LOCAL_HEADER_SIZE || local.readUInt32LE(0) !== SIG_LOCAL_HEADER) {
231
+ throw new UnsafeZipError("invalid", `not a valid ZIP archive (bad local header for "${entry.name}")`);
232
+ }
233
+ const dataStart = entry.localHeaderOffset +
234
+ LOCAL_HEADER_SIZE +
235
+ local.readUInt16LE(26) +
236
+ local.readUInt16LE(28);
237
+ if (dataStart + entry.compressedSize > size) {
238
+ throw new UnsafeZipError("invalid", `not a valid ZIP archive (data for "${entry.name}" out of bounds)`);
239
+ }
240
+ if (entry.method === 0) {
241
+ if (entry.compressedSize !== entry.uncompressedSize) {
242
+ throw new UnsafeZipError("invalid", `not a valid ZIP archive (stored entry "${entry.name}" has inconsistent sizes)`);
243
+ }
244
+ actualTotal += entry.compressedSize;
245
+ continue;
246
+ }
247
+ const { bytes, exceeded } = await countInflatedBytes(filePath, dataStart, entry.compressedSize, entry.uncompressedSize);
248
+ if (exceeded) {
249
+ throw new UnsafeZipError("limit", `entry "${entry.name}" inflates beyond its declared size of ` +
250
+ `${entry.uncompressedSize} bytes (possible zip bomb)`);
251
+ }
252
+ actualTotal += bytes;
253
+ }
254
+ // Declared sizes are already bounded and actual sizes never exceed them,
255
+ // but keep an explicit check on what was actually produced.
256
+ if (actualTotal > limits.maxTotalUncompressedBytes) {
257
+ throw new UnsafeZipError("limit", `total uncompressed size exceeds ${formatMB(limits.maxTotalUncompressedBytes)} ` +
258
+ `(possible zip bomb)`);
259
+ }
260
+ return inspection;
261
+ }
262
+ finally {
263
+ await handle.close();
264
+ }
265
+ }
266
+ //# sourceMappingURL=zip-guard.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@n0zer0d4y/vulcan-file-ops",
3
- "version": "1.2.13",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.n0zer0d4y/vulcan-file-ops",
5
5
  "description": "MCP server for AI assistants: read, write, edit, and manage files securely on local filesystem.",
6
6
  "license": "MIT",
@@ -38,6 +38,9 @@
38
38
  "typescript",
39
39
  "ai-assisted-coding"
40
40
  ],
41
+ "engines": {
42
+ "node": "^20.19.0 || >=22.12.0"
43
+ },
41
44
  "type": "module",
42
45
  "bin": {
43
46
  "vulcan-file-ops": "dist/cli.js"
@@ -65,7 +68,7 @@
65
68
  "lint": "npm run lint:json"
66
69
  },
67
70
  "dependencies": {
68
- "@modelcontextprotocol/sdk": "1.20.0",
71
+ "@modelcontextprotocol/sdk": "1.30.0",
69
72
  "@turbodocx/html-to-docx": "^1.16.0",
70
73
  "diff": "^8.0.3",
71
74
  "docx": "^9.5.1",
@@ -73,9 +76,9 @@
73
76
  "glob": "^11.0.3",
74
77
  "html-to-pdfmake": "^2.5.32",
75
78
  "jsdom": "^27.4.0",
76
- "mammoth": "^1.11.0",
77
- "minimatch": "^10.1.1",
78
- "officeparser": "^5.2.1",
79
+ "mammoth": "^1.12.3",
80
+ "minimatch": "^10.2.6",
81
+ "officeparser": "^7.8.0",
79
82
  "pdf-lib": "^1.17.1",
80
83
  "pdf-parse": "^2.4.5",
81
84
  "pdfmake": "^0.2.20",
@@ -83,7 +86,7 @@
83
86
  "zod-to-json-schema": "^3.25.1"
84
87
  },
85
88
  "devDependencies": {
86
- "@jest/globals": "^30.2.0",
89
+ "@jest/globals": "30.2.0",
87
90
  "@types/html-to-docx": "^1.8.0",
88
91
  "@types/jest": "^30.0.0",
89
92
  "@types/jsdom": "^27.0.0",
@@ -91,9 +94,9 @@
91
94
  "@types/node": "^22",
92
95
  "@types/pdf-parse": "^1.1.5",
93
96
  "@types/pdfmake": "^0.2.12",
94
- "jest": "^30.2.0",
97
+ "jest": "30.2.0",
95
98
  "shx": "^0.3.4",
96
- "ts-jest": "^29.4.5",
99
+ "ts-jest": "29.4.6",
97
100
  "ts-node": "^10.9.2",
98
101
  "typescript": "^5.9.3"
99
102
  }