@patrickdk77/office-defuse 0.0.0-stage → 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 +230 -2
- package/dist/context.d.ts +38 -0
- package/dist/context.js +100 -0
- package/dist/crc32.d.ts +2 -0
- package/dist/crc32.js +18 -0
- package/dist/engine.d.ts +5 -0
- package/dist/engine.js +151 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +33 -0
- package/dist/findings.d.ts +25 -0
- package/dist/findings.js +169 -0
- package/dist/formats.d.ts +10 -0
- package/dist/formats.js +46 -0
- package/dist/formulas.d.ts +22 -0
- package/dist/formulas.js +77 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +30 -0
- package/dist/index.mjs +3 -0
- package/dist/io.d.ts +23 -0
- package/dist/io.js +139 -0
- package/dist/links.d.ts +26 -0
- package/dist/links.js +145 -0
- package/dist/odf.d.ts +3 -0
- package/dist/odf.js +297 -0
- package/dist/ooxml.d.ts +8 -0
- package/dist/ooxml.js +516 -0
- package/dist/score.d.ts +17 -0
- package/dist/score.js +63 -0
- package/dist/types.d.ts +247 -0
- package/dist/types.js +133 -0
- package/dist/xlsb.d.ts +19 -0
- package/dist/xlsb.js +101 -0
- package/dist/xml.d.ts +256 -0
- package/dist/xml.js +1105 -0
- package/dist/zip-writer.d.ts +38 -0
- package/dist/zip-writer.js +182 -0
- package/dist/zip.d.ts +55 -0
- package/dist/zip.js +535 -0
- package/package.json +96 -4
package/dist/zip.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ZipArchive = void 0;
|
|
37
|
+
exports.nameKey = nameKey;
|
|
38
|
+
const stream = __importStar(require("node:stream"));
|
|
39
|
+
const zlib = __importStar(require("node:zlib"));
|
|
40
|
+
const crc32_1 = require("./crc32");
|
|
41
|
+
const errors_1 = require("./errors");
|
|
42
|
+
const io_1 = require("./io");
|
|
43
|
+
const types_1 = require("./types");
|
|
44
|
+
const SIG_LOCAL = 0x04034b50;
|
|
45
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
46
|
+
const SIG_END = 0x06054b50;
|
|
47
|
+
const SIG_END64 = 0x06064b50;
|
|
48
|
+
const SIG_LOCATOR64 = 0x07064b50;
|
|
49
|
+
const SIG_DESCRIPTOR = 0x08074b50;
|
|
50
|
+
const MAX16 = 0xffff;
|
|
51
|
+
const MAX32 = 0xffffffff;
|
|
52
|
+
const UNKNOWN64 = 0xffffffffffffffffn;
|
|
53
|
+
/** The end record is 22 bytes and its comment at most 65,535. */
|
|
54
|
+
const END_SEARCH = 22 + MAX16;
|
|
55
|
+
/** Flags whose meaning a local header and the central directory must agree on: encryption, data descriptor, strong encryption, UTF-8 names and an encrypted directory. */
|
|
56
|
+
const FLAG_MASK = 0x0001 | 0x0008 | 0x0040 | 0x0800 | 0x2000;
|
|
57
|
+
const ENCRYPTION_FLAGS = 0x0001 | 0x0040 | 0x2000;
|
|
58
|
+
const MAX_NAME_BYTES = 1024;
|
|
59
|
+
const fail = (detail, message, data) => {
|
|
60
|
+
throw new errors_1.StopError(types_1.OfficeCategory.Container, detail, message, data);
|
|
61
|
+
};
|
|
62
|
+
const limit = (detail, message, data) => {
|
|
63
|
+
throw new errors_1.StopError(types_1.OfficeCategory.Limit, detail, message, data);
|
|
64
|
+
};
|
|
65
|
+
class Bytes {
|
|
66
|
+
b;
|
|
67
|
+
view;
|
|
68
|
+
constructor(b) {
|
|
69
|
+
this.b = b;
|
|
70
|
+
this.view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
71
|
+
}
|
|
72
|
+
u16(at) {
|
|
73
|
+
return this.view.getUint16(at, true);
|
|
74
|
+
}
|
|
75
|
+
u32(at) {
|
|
76
|
+
return this.view.getUint32(at, true);
|
|
77
|
+
}
|
|
78
|
+
/** An 8-byte value as it is, for comparing bytes that may not be a 64-bit field at all. */
|
|
79
|
+
big(at) {
|
|
80
|
+
return this.view.getBigUint64(at, true);
|
|
81
|
+
}
|
|
82
|
+
/** An 8-byte value, which must fit a JavaScript number exactly. */
|
|
83
|
+
u64(at) {
|
|
84
|
+
const v = this.big(at);
|
|
85
|
+
if (v > BigInt(Number.MAX_SAFE_INTEGER))
|
|
86
|
+
fail(types_1.OfficeDetail.OutOfRange, 'A 64-bit field is beyond any file this reader can hold');
|
|
87
|
+
return Number(v);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Splits an extra field block into records. The block must split exactly, and no record id may repeat. */
|
|
91
|
+
function parseExtra(extra, name) {
|
|
92
|
+
const out = new Map();
|
|
93
|
+
const b = new Bytes(extra);
|
|
94
|
+
let p = 0;
|
|
95
|
+
while (p < extra.length) {
|
|
96
|
+
if (p + 4 > extra.length)
|
|
97
|
+
fail(types_1.OfficeDetail.BadExtraField, 'An extra field block ends inside a record header', { name });
|
|
98
|
+
const id = b.u16(p);
|
|
99
|
+
const len = b.u16(p + 2);
|
|
100
|
+
if (p + 4 + len > extra.length)
|
|
101
|
+
fail(types_1.OfficeDetail.BadExtraField, 'An extra field record runs past its block', { name });
|
|
102
|
+
if (out.has(id))
|
|
103
|
+
fail(types_1.OfficeDetail.BadExtraField, `Extra field 0x${id.toString(16)} appears twice`, { name });
|
|
104
|
+
out.set(id, extra.subarray(p + 4, p + 4 + len));
|
|
105
|
+
p += 4 + len;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
110
|
+
/** Decodes a name the way the flags say. A non-ASCII name without the UTF-8 flag has no single reading. */
|
|
111
|
+
function decodeName(raw, flags) {
|
|
112
|
+
if (flags & 0x0800) {
|
|
113
|
+
try {
|
|
114
|
+
return utf8.decode(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return fail(types_1.OfficeDetail.NameEncoding, 'An entry name flagged UTF-8 is not valid UTF-8');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const c of raw)
|
|
121
|
+
if (c >= 0x80)
|
|
122
|
+
fail(types_1.OfficeDetail.NameEncoding, 'An entry name has non-ASCII bytes without the UTF-8 flag');
|
|
123
|
+
return Buffer.from(raw).toString('latin1');
|
|
124
|
+
}
|
|
125
|
+
/** The Info-ZIP Unicode path field gives a second name, which some readers prefer. It must name the same entry. */
|
|
126
|
+
function checkUnicodePath(extras, name) {
|
|
127
|
+
const up = extras.get(0x7075);
|
|
128
|
+
if (!up)
|
|
129
|
+
return;
|
|
130
|
+
let other;
|
|
131
|
+
try {
|
|
132
|
+
other = up.length >= 5 && up[0] === 1 ? utf8.decode(up.subarray(5)) : undefined;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
other = undefined;
|
|
136
|
+
}
|
|
137
|
+
if (other !== name)
|
|
138
|
+
fail(types_1.OfficeDetail.NameEncoding, 'The Unicode path field names a different entry', { name });
|
|
139
|
+
}
|
|
140
|
+
function checkName(name, directory) {
|
|
141
|
+
const why = (reason) => fail(types_1.OfficeDetail.UnsafeName, `Entry name ${reason}`, { name, reason });
|
|
142
|
+
if (name === '')
|
|
143
|
+
why('is empty');
|
|
144
|
+
if (/\p{Cc}/u.test(name))
|
|
145
|
+
why('holds a control character');
|
|
146
|
+
if (name.includes('\\'))
|
|
147
|
+
why('holds a backslash');
|
|
148
|
+
if (name.startsWith('/'))
|
|
149
|
+
why('is absolute');
|
|
150
|
+
if (/^[A-Za-z]:/.test(name))
|
|
151
|
+
why('starts with a drive letter');
|
|
152
|
+
for (const seg of (directory ? name.slice(0, -1) : name).split('/')) {
|
|
153
|
+
if (seg === '' || seg === '.' || seg === '..')
|
|
154
|
+
why('has an empty, "." or ".." segment');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The key two names collide on: percent-decoded, NFC and lowercase, without a directory's slash. */
|
|
158
|
+
function nameKey(name) {
|
|
159
|
+
let n = name;
|
|
160
|
+
try {
|
|
161
|
+
n = decodeURIComponent(n);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Not valid percent-encoding, so it stays as written.
|
|
165
|
+
}
|
|
166
|
+
return n.normalize('NFC').toLowerCase().replace(/\/$/, '');
|
|
167
|
+
}
|
|
168
|
+
/** ZIP64 values the central directory defers to its extra field, in the order the format fixes. */
|
|
169
|
+
function zip64Central(extras, fields, name) {
|
|
170
|
+
const want = [fields.size === MAX32, fields.compressedSize === MAX32, fields.offset === MAX32, fields.disk === MAX16];
|
|
171
|
+
const length = (want[0] ? 8 : 0) + (want[1] ? 8 : 0) + (want[2] ? 8 : 0) + (want[3] ? 4 : 0);
|
|
172
|
+
const z = extras.get(0x0001);
|
|
173
|
+
if (!z) {
|
|
174
|
+
if (length)
|
|
175
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A field at its maximum has no ZIP64 value behind it', { name });
|
|
176
|
+
return fields;
|
|
177
|
+
}
|
|
178
|
+
if (z.length !== length)
|
|
179
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A ZIP64 extra field holds other fields than its header defers to it', { name });
|
|
180
|
+
const b = new Bytes(z);
|
|
181
|
+
let p = 0;
|
|
182
|
+
const out = { ...fields };
|
|
183
|
+
if (want[0]) {
|
|
184
|
+
out.size = b.u64(p);
|
|
185
|
+
p += 8;
|
|
186
|
+
}
|
|
187
|
+
if (want[1]) {
|
|
188
|
+
out.compressedSize = b.u64(p);
|
|
189
|
+
p += 8;
|
|
190
|
+
}
|
|
191
|
+
if (want[2]) {
|
|
192
|
+
out.offset = b.u64(p);
|
|
193
|
+
p += 8;
|
|
194
|
+
}
|
|
195
|
+
if (want[3])
|
|
196
|
+
out.disk = b.u32(p);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A ZIP archive read from its central directory, after every local header was checked against it. Reading an entry
|
|
201
|
+
* inflates it in chunks and checks its size and CRC.
|
|
202
|
+
*/
|
|
203
|
+
class ZipArchive {
|
|
204
|
+
reader;
|
|
205
|
+
entries;
|
|
206
|
+
notes;
|
|
207
|
+
deadline;
|
|
208
|
+
byKey = new Map();
|
|
209
|
+
constructor(reader, entries, notes, deadline) {
|
|
210
|
+
this.reader = reader;
|
|
211
|
+
this.entries = entries;
|
|
212
|
+
this.notes = notes;
|
|
213
|
+
this.deadline = deadline;
|
|
214
|
+
for (const e of entries)
|
|
215
|
+
this.byKey.set(nameKey(e.name), e);
|
|
216
|
+
}
|
|
217
|
+
/** The entry with this name, compared as entry names collide: ignoring case. */
|
|
218
|
+
get(name) {
|
|
219
|
+
return this.byKey.get(nameKey(name));
|
|
220
|
+
}
|
|
221
|
+
static async open(source, limits, deadline) {
|
|
222
|
+
const size = await source.size();
|
|
223
|
+
const reader = new io_1.Reader(source, size);
|
|
224
|
+
const notes = [];
|
|
225
|
+
if (size < 22)
|
|
226
|
+
fail(types_1.OfficeDetail.NotZip, 'The file is too short to be a ZIP archive');
|
|
227
|
+
const tailStart = Math.max(0, size - END_SEARCH);
|
|
228
|
+
const tail = new Bytes(await reader.read(tailStart, size - tailStart));
|
|
229
|
+
// The last end signature is the one a reader scanning backwards takes. An earlier one whose comment reaches the
|
|
230
|
+
// end of the file holds it inside that comment, and a reader that checks comment lengths can take either.
|
|
231
|
+
let endAt = -1;
|
|
232
|
+
let holder = false;
|
|
233
|
+
for (let p = 0; p + 22 <= tail.b.length; p++) {
|
|
234
|
+
if (tail.b[p] !== 0x50 || tail.u32(p) !== SIG_END)
|
|
235
|
+
continue;
|
|
236
|
+
if (endAt >= 0 && tailStart + endAt + 22 + tail.u16(endAt + 20) === size)
|
|
237
|
+
holder = true;
|
|
238
|
+
endAt = p;
|
|
239
|
+
}
|
|
240
|
+
if (endAt < 0)
|
|
241
|
+
fail(types_1.OfficeDetail.NotZip, 'No end of central directory record');
|
|
242
|
+
if (holder)
|
|
243
|
+
fail(types_1.OfficeDetail.AmbiguousEnd, "A second end record sits inside an end record's comment");
|
|
244
|
+
const end = tailStart + endAt;
|
|
245
|
+
const commentLength = tail.u16(endAt + 20);
|
|
246
|
+
if (end + 22 + commentLength > size)
|
|
247
|
+
fail(types_1.OfficeDetail.NotZip, "The end record's comment runs past the end of the file");
|
|
248
|
+
if (end + 22 + commentLength < size)
|
|
249
|
+
notes.push({ detail: types_1.OfficeDetail.TrailingBytes, bytes: size - (end + 22 + commentLength) });
|
|
250
|
+
let disk = tail.u16(endAt + 4);
|
|
251
|
+
let dirDisk = tail.u16(endAt + 6);
|
|
252
|
+
let countOnDisk = tail.u16(endAt + 8);
|
|
253
|
+
let count = tail.u16(endAt + 10);
|
|
254
|
+
let dirSize = tail.u32(endAt + 12);
|
|
255
|
+
let dirOffset = tail.u32(endAt + 16);
|
|
256
|
+
let dirEnd = end;
|
|
257
|
+
const locatorAt = end - 20;
|
|
258
|
+
const locator = locatorAt >= 0 ? new Bytes(await reader.read(locatorAt, 20)) : undefined;
|
|
259
|
+
if (locator && locator.u32(0) === SIG_LOCATOR64) {
|
|
260
|
+
if (locator.u32(4) !== 0 || locator.u32(16) > 1)
|
|
261
|
+
fail(types_1.OfficeDetail.MultiDisk, 'The ZIP64 locator names another disk');
|
|
262
|
+
const at = locator.u64(8);
|
|
263
|
+
if (at + 56 > locatorAt)
|
|
264
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'The ZIP64 locator does not point at a ZIP64 end record');
|
|
265
|
+
const r = new Bytes(await reader.read(at, 56));
|
|
266
|
+
if (r.u32(0) !== SIG_END64)
|
|
267
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'The ZIP64 locator does not point at a ZIP64 end record');
|
|
268
|
+
if (at + 12 + r.u64(4) !== locatorAt)
|
|
269
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'The ZIP64 end record does not end at its locator');
|
|
270
|
+
const pick = (short, max, long, what) => {
|
|
271
|
+
if (short === max)
|
|
272
|
+
return long;
|
|
273
|
+
if (short !== long)
|
|
274
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, `The end record and the ZIP64 end record disagree on ${what}`);
|
|
275
|
+
return long;
|
|
276
|
+
};
|
|
277
|
+
disk = pick(disk, MAX16, r.u32(16), 'the disk number');
|
|
278
|
+
dirDisk = pick(dirDisk, MAX16, r.u32(20), 'the directory disk');
|
|
279
|
+
countOnDisk = pick(countOnDisk, MAX16, r.u64(24), 'the entry count');
|
|
280
|
+
count = pick(count, MAX16, r.u64(32), 'the entry count');
|
|
281
|
+
dirSize = pick(dirSize, MAX32, r.u64(40), 'the directory size');
|
|
282
|
+
dirOffset = pick(dirOffset, MAX32, r.u64(48), 'the directory offset');
|
|
283
|
+
dirEnd = at;
|
|
284
|
+
}
|
|
285
|
+
else if (disk === MAX16 || dirDisk === MAX16 || countOnDisk === MAX16 || count === MAX16 || dirSize === MAX32 || dirOffset === MAX32) {
|
|
286
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'The end record defers to a ZIP64 record that is not there');
|
|
287
|
+
}
|
|
288
|
+
if (disk !== 0 || dirDisk !== 0)
|
|
289
|
+
fail(types_1.OfficeDetail.MultiDisk, 'The archive spans several disks');
|
|
290
|
+
if (countOnDisk !== count)
|
|
291
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'The entry counts for this disk and for the archive differ');
|
|
292
|
+
if (count > limits.entries)
|
|
293
|
+
limit(types_1.OfficeDetail.EntryCount, `${count} entries`, { entries: count, limit: limits.entries });
|
|
294
|
+
if (dirOffset > dirEnd || dirSize > dirEnd)
|
|
295
|
+
fail(types_1.OfficeDetail.OutOfRange, 'The central directory lies outside the file');
|
|
296
|
+
if (dirOffset + dirSize !== dirEnd)
|
|
297
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'The central directory does not end where the end record begins');
|
|
298
|
+
const entries = [];
|
|
299
|
+
const seen = new Map();
|
|
300
|
+
const raws = [];
|
|
301
|
+
let total = 0;
|
|
302
|
+
let pos = dirOffset;
|
|
303
|
+
for (let index = 0; index < count; index++) {
|
|
304
|
+
if (index % 256 === 0)
|
|
305
|
+
(0, errors_1.checkTime)(deadline);
|
|
306
|
+
if (pos + 46 > dirEnd)
|
|
307
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'The central directory holds fewer entries than the end record says');
|
|
308
|
+
const h = new Bytes(await reader.read(pos, 46));
|
|
309
|
+
if (h.u32(0) !== SIG_CENTRAL)
|
|
310
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'The central directory holds fewer entries than the end record says');
|
|
311
|
+
const flags = h.u16(8);
|
|
312
|
+
const method = h.u16(10);
|
|
313
|
+
const nameLength = h.u16(28);
|
|
314
|
+
const extraLength = h.u16(30);
|
|
315
|
+
const commentLen = h.u16(32);
|
|
316
|
+
if (pos + 46 + nameLength + extraLength + commentLen > dirEnd)
|
|
317
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'A central directory header runs past the directory');
|
|
318
|
+
if (nameLength > MAX_NAME_BYTES)
|
|
319
|
+
fail(types_1.OfficeDetail.UnsafeName, `An entry name is ${nameLength} bytes long`, { reason: `longer than ${MAX_NAME_BYTES} bytes` });
|
|
320
|
+
const tailBytes = await reader.read(pos + 46, nameLength + extraLength);
|
|
321
|
+
const raw = tailBytes.slice(0, nameLength);
|
|
322
|
+
const name = decodeName(raw, flags);
|
|
323
|
+
const extras = parseExtra(tailBytes.subarray(nameLength), name);
|
|
324
|
+
checkUnicodePath(extras, name);
|
|
325
|
+
if (flags & ENCRYPTION_FLAGS)
|
|
326
|
+
fail(types_1.OfficeDetail.EncryptedEntry, 'An entry is encrypted', { name });
|
|
327
|
+
if (method !== 0 && method !== 8)
|
|
328
|
+
fail(types_1.OfficeDetail.UnsupportedMethod, `Compression method ${method}`, { name, method });
|
|
329
|
+
const z = zip64Central(extras, { size: h.u32(24), compressedSize: h.u32(20), offset: h.u32(42), disk: h.u16(34) }, name);
|
|
330
|
+
if (z.disk !== 0)
|
|
331
|
+
fail(types_1.OfficeDetail.MultiDisk, 'An entry starts on another disk', { name });
|
|
332
|
+
const directory = name.endsWith('/');
|
|
333
|
+
checkName(name, directory);
|
|
334
|
+
if (directory && z.size !== 0)
|
|
335
|
+
fail(types_1.OfficeDetail.UnsafeName, 'A directory entry has content', { name, reason: 'a directory entry with content' });
|
|
336
|
+
if (method === 0 && z.size !== z.compressedSize)
|
|
337
|
+
fail(types_1.OfficeDetail.DataError, 'A stored entry has two different sizes', { name });
|
|
338
|
+
if (z.size > limits.decompressedBytes)
|
|
339
|
+
limit(types_1.OfficeDetail.DecompressedSize, `${name} declares ${z.size} bytes`, { name, size: z.size, limit: limits.decompressedBytes });
|
|
340
|
+
total += z.size;
|
|
341
|
+
if (total > limits.totalDecompressedBytes)
|
|
342
|
+
limit(types_1.OfficeDetail.TotalDecompressedSize, `The entries declare more than ${limits.totalDecompressedBytes} bytes`, { limit: limits.totalDecompressedBytes });
|
|
343
|
+
const key = nameKey(name);
|
|
344
|
+
const other = seen.get(key);
|
|
345
|
+
if (other !== undefined)
|
|
346
|
+
fail(types_1.OfficeDetail.DuplicateName, `${other} and ${name}`, { name, other });
|
|
347
|
+
seen.set(key, name);
|
|
348
|
+
entries.push({
|
|
349
|
+
index,
|
|
350
|
+
name,
|
|
351
|
+
directory,
|
|
352
|
+
flags,
|
|
353
|
+
method,
|
|
354
|
+
dosTime: h.u16(12),
|
|
355
|
+
dosDate: h.u16(14),
|
|
356
|
+
crc: h.u32(16),
|
|
357
|
+
compressedSize: z.compressedSize,
|
|
358
|
+
size: z.size,
|
|
359
|
+
localOffset: z.offset,
|
|
360
|
+
dataOffset: 0,
|
|
361
|
+
end: 0,
|
|
362
|
+
localExtra: false,
|
|
363
|
+
});
|
|
364
|
+
raws.push(raw);
|
|
365
|
+
pos += 46 + nameLength + extraLength + commentLen;
|
|
366
|
+
}
|
|
367
|
+
if (pos !== dirEnd)
|
|
368
|
+
fail(types_1.OfficeDetail.DirectoryMismatch, 'The central directory holds more than the end record counts');
|
|
369
|
+
for (const [i, e] of entries.entries()) {
|
|
370
|
+
if (i % 256 === 0)
|
|
371
|
+
(0, errors_1.checkTime)(deadline);
|
|
372
|
+
await ZipArchive.checkLocal(reader, e, raws[i], dirOffset);
|
|
373
|
+
}
|
|
374
|
+
// Entries and the central directory must tile the file. Bytes no entry covers are reported, overlaps refused.
|
|
375
|
+
const sorted = [...entries].sort((a, b) => a.localOffset - b.localOffset);
|
|
376
|
+
let covered = 0;
|
|
377
|
+
let hidden = 0;
|
|
378
|
+
for (const e of sorted) {
|
|
379
|
+
if (e.localOffset < covered)
|
|
380
|
+
fail(types_1.OfficeDetail.Overlap, 'Two entries share bytes', { name: e.name });
|
|
381
|
+
if (covered === 0 && e.localOffset > 0)
|
|
382
|
+
notes.push({ detail: types_1.OfficeDetail.LeadingBytes, bytes: e.localOffset });
|
|
383
|
+
else
|
|
384
|
+
hidden += e.localOffset - covered;
|
|
385
|
+
covered = e.end;
|
|
386
|
+
}
|
|
387
|
+
if (sorted.length === 0 && dirOffset > 0)
|
|
388
|
+
notes.push({ detail: types_1.OfficeDetail.LeadingBytes, bytes: dirOffset });
|
|
389
|
+
else
|
|
390
|
+
hidden += dirOffset - covered;
|
|
391
|
+
if (hidden > 0)
|
|
392
|
+
notes.push({ detail: types_1.OfficeDetail.HiddenData, bytes: hidden });
|
|
393
|
+
return new ZipArchive(reader, entries, notes, deadline);
|
|
394
|
+
}
|
|
395
|
+
/** Checks a local header and any data descriptor against the central directory, and fills in where the entry's data and the entry end. */
|
|
396
|
+
static async checkLocal(reader, e, rawName, dirOffset) {
|
|
397
|
+
const name = e.name;
|
|
398
|
+
const mismatch = (what) => fail(types_1.OfficeDetail.HeaderMismatch, `The local header ${what}`, { name, what });
|
|
399
|
+
if (e.localOffset + 30 > dirOffset)
|
|
400
|
+
fail(types_1.OfficeDetail.OutOfRange, 'A local header lies outside the entries', { name });
|
|
401
|
+
const h = new Bytes(await reader.read(e.localOffset, 30));
|
|
402
|
+
if (h.u32(0) !== SIG_LOCAL)
|
|
403
|
+
mismatch('is missing');
|
|
404
|
+
const flags = h.u16(6);
|
|
405
|
+
const nameLength = h.u16(26);
|
|
406
|
+
const extraLength = h.u16(28);
|
|
407
|
+
e.dataOffset = e.localOffset + 30 + nameLength + extraLength;
|
|
408
|
+
if (e.dataOffset > dirOffset)
|
|
409
|
+
fail(types_1.OfficeDetail.OutOfRange, 'A local header runs into the central directory', { name });
|
|
410
|
+
const rest = await reader.read(e.localOffset + 30, nameLength + extraLength);
|
|
411
|
+
if (Buffer.compare(rest.subarray(0, nameLength), rawName) !== 0)
|
|
412
|
+
mismatch('names another entry');
|
|
413
|
+
if ((flags & FLAG_MASK) !== (e.flags & FLAG_MASK))
|
|
414
|
+
mismatch('has other flags');
|
|
415
|
+
if (h.u16(8) !== e.method)
|
|
416
|
+
mismatch('has another compression method');
|
|
417
|
+
const extras = parseExtra(rest.subarray(nameLength), name);
|
|
418
|
+
checkUnicodePath(extras, name);
|
|
419
|
+
e.localExtra = extraLength > 0;
|
|
420
|
+
let size = h.u32(22);
|
|
421
|
+
let compressedSize = h.u32(18);
|
|
422
|
+
const z = extras.get(0x0001);
|
|
423
|
+
if (z) {
|
|
424
|
+
// The local ZIP64 field holds both sizes, and may go on to the offset and disk.
|
|
425
|
+
if (z.length !== 16 && z.length !== 24 && z.length !== 28)
|
|
426
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A local ZIP64 extra field has an odd length', { name });
|
|
427
|
+
const b = new Bytes(z);
|
|
428
|
+
// A writer that streams the entry does not know its sizes yet. It sets the data descriptor flag and may fill
|
|
429
|
+
// the ZIP64 sizes with ones, which then mean what zero means: the data descriptor has them.
|
|
430
|
+
const wide = (at) => (e.flags & 0x0008 && b.big(at) === UNKNOWN64 ? 0 : b.u64(at));
|
|
431
|
+
if (size !== MAX32 && size !== 0 && size !== wide(0))
|
|
432
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A local header and its ZIP64 field disagree on the size', { name });
|
|
433
|
+
if (compressedSize !== MAX32 && compressedSize !== 0 && compressedSize !== wide(8))
|
|
434
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A local header and its ZIP64 field disagree on the size', { name });
|
|
435
|
+
size = wide(0);
|
|
436
|
+
compressedSize = wide(8);
|
|
437
|
+
}
|
|
438
|
+
else if (size === MAX32 || compressedSize === MAX32) {
|
|
439
|
+
fail(types_1.OfficeDetail.Zip64Mismatch, 'A local size at its maximum has no ZIP64 value behind it', { name });
|
|
440
|
+
}
|
|
441
|
+
const crc = h.u32(14);
|
|
442
|
+
if (e.flags & 0x0008) {
|
|
443
|
+
// With a data descriptor the local fields may be zero. Any that are set must agree.
|
|
444
|
+
if (crc !== 0 && crc !== e.crc)
|
|
445
|
+
mismatch('has another CRC');
|
|
446
|
+
if (size !== 0 && size !== e.size)
|
|
447
|
+
mismatch('has another size');
|
|
448
|
+
if (compressedSize !== 0 && compressedSize !== e.compressedSize)
|
|
449
|
+
mismatch('has another compressed size');
|
|
450
|
+
}
|
|
451
|
+
else if (crc !== e.crc || size !== e.size || compressedSize !== e.compressedSize) {
|
|
452
|
+
mismatch('has another CRC or size');
|
|
453
|
+
}
|
|
454
|
+
const dataEnd = e.dataOffset + e.compressedSize;
|
|
455
|
+
if (dataEnd > dirOffset)
|
|
456
|
+
fail(types_1.OfficeDetail.Overlap, "An entry's data runs into the central directory", { name });
|
|
457
|
+
e.end = dataEnd;
|
|
458
|
+
if (!(e.flags & 0x0008))
|
|
459
|
+
return;
|
|
460
|
+
const d = new Bytes(await reader.read(dataEnd, Math.min(24, dirOffset - dataEnd)));
|
|
461
|
+
let p = 0;
|
|
462
|
+
if (d.b.length >= 8 && d.u32(0) === SIG_DESCRIPTOR && d.u32(4) === e.crc)
|
|
463
|
+
p = 4;
|
|
464
|
+
if (d.b.length < p + 12 || d.u32(p) !== e.crc)
|
|
465
|
+
mismatch('has a data descriptor with another CRC');
|
|
466
|
+
// The format says eight-byte sizes go with a ZIP64 field. Either width is accepted when its values agree; the
|
|
467
|
+
// tiling check then sees any bytes the other reading would leave over. Past a narrow descriptor the wide reading
|
|
468
|
+
// takes in the next header's signature, so it compares the bytes without demanding that they fit a number.
|
|
469
|
+
const wide = d.b.length >= p + 20 && d.big(p + 4) === BigInt(e.compressedSize) && d.big(p + 12) === BigInt(e.size);
|
|
470
|
+
const narrow = d.u32(p + 4) === e.compressedSize && d.u32(p + 8) === e.size;
|
|
471
|
+
if (z ? wide : narrow)
|
|
472
|
+
e.end = dataEnd + p + (z ? 20 : 12);
|
|
473
|
+
else if (z ? narrow : wide)
|
|
474
|
+
e.end = dataEnd + p + (z ? 12 : 20);
|
|
475
|
+
else
|
|
476
|
+
mismatch('has a data descriptor with other sizes');
|
|
477
|
+
}
|
|
478
|
+
/** The compressed bytes of an entry, as stored. */
|
|
479
|
+
raw(e) {
|
|
480
|
+
return this.reader.chunks(e.dataOffset, e.compressedSize);
|
|
481
|
+
}
|
|
482
|
+
/** The entry's content in chunks. Its size, its CRC and, for deflate, the compressed bytes consumed must all agree with the central directory. */
|
|
483
|
+
async *read(e) {
|
|
484
|
+
const deadline = this.deadline;
|
|
485
|
+
const bad = (why) => fail(types_1.OfficeDetail.DataError, `${e.name}: ${why}`, { name: e.name, reason: why });
|
|
486
|
+
async function* timed(chunks) {
|
|
487
|
+
for await (const c of chunks) {
|
|
488
|
+
(0, errors_1.checkTime)(deadline);
|
|
489
|
+
yield c;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let crc = 0;
|
|
493
|
+
let total = 0;
|
|
494
|
+
if (e.method === 0) {
|
|
495
|
+
for await (const c of timed(this.raw(e))) {
|
|
496
|
+
total += c.length;
|
|
497
|
+
crc = (0, crc32_1.crc32)(c, crc);
|
|
498
|
+
yield c;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
const inflater = zlib.createInflateRaw();
|
|
503
|
+
const src = stream.Readable.from(timed(this.raw(e)));
|
|
504
|
+
src.on('error', err => inflater.destroy(err));
|
|
505
|
+
src.pipe(inflater);
|
|
506
|
+
try {
|
|
507
|
+
for await (const c of inflater) {
|
|
508
|
+
total += c.length;
|
|
509
|
+
if (total > e.size)
|
|
510
|
+
bad('inflates past its declared size');
|
|
511
|
+
crc = (0, crc32_1.crc32)(c, crc);
|
|
512
|
+
yield c;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
// zlib's own errors mean corrupt or cut-short data. Anything else, such as an I/O error or the time limit, goes up.
|
|
517
|
+
const code = err?.code;
|
|
518
|
+
if (typeof code === 'string' && code.startsWith('Z_'))
|
|
519
|
+
bad(err.message);
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
src.destroy();
|
|
524
|
+
inflater.destroy();
|
|
525
|
+
}
|
|
526
|
+
if (inflater.bytesWritten !== e.compressedSize)
|
|
527
|
+
bad('the deflate data ends before its compressed size');
|
|
528
|
+
}
|
|
529
|
+
if (total !== e.size)
|
|
530
|
+
bad(`inflates to ${total} bytes, not ${e.size}`);
|
|
531
|
+
if (crc !== e.crc)
|
|
532
|
+
bad('has the wrong CRC');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
exports.ZipArchive = ZipArchive;
|
package/package.json
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patrickdk77/office-defuse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Inspect Office and OpenDocument files for VBA and Excel 4.0 macros, LibreOffice Basic and scripts, DDE, external templates and links, embedded OLE objects and ActiveX controls, and score the risk. Strict ZIP and XML readers, bounded memory, no runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "patrickdk77",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"require": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.0.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('build-test',{recursive:true,force:true})\"",
|
|
29
|
+
"build": "tsc -p tsconfig.json && node scripts/write-esm-wrapper.js",
|
|
30
|
+
"build:test": "tsc -p tsconfig.test.json",
|
|
31
|
+
"lint": "biome check .",
|
|
32
|
+
"format": "biome format --write .",
|
|
33
|
+
"test": "npm run lint && npm run build && npm run build:test && mocha --timeout 120000 \"build-test/test/**/*.test.js\"",
|
|
34
|
+
"coverage": "npm run build && npm run build:test && c8 --include \"build-test/src/**\" --reporter text mocha --timeout 120000 \"build-test/test/**/*.test.js\"",
|
|
35
|
+
"prepare": "husky || true",
|
|
36
|
+
"prepublishOnly": "npm run clean && npm test"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/patrickdk77/office-defuse.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/patrickdk77/office-defuse/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/patrickdk77/office-defuse",
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"office",
|
|
51
|
+
"docx",
|
|
52
|
+
"docm",
|
|
53
|
+
"xlsx",
|
|
54
|
+
"xlsm",
|
|
55
|
+
"pptx",
|
|
56
|
+
"pptm",
|
|
57
|
+
"vsdx",
|
|
58
|
+
"odt",
|
|
59
|
+
"ods",
|
|
60
|
+
"odp",
|
|
61
|
+
"ooxml",
|
|
62
|
+
"openxml",
|
|
63
|
+
"opendocument",
|
|
64
|
+
"libreoffice",
|
|
65
|
+
"macro",
|
|
66
|
+
"macros",
|
|
67
|
+
"vba",
|
|
68
|
+
"xlm",
|
|
69
|
+
"dde",
|
|
70
|
+
"ddeauto",
|
|
71
|
+
"activex",
|
|
72
|
+
"ole",
|
|
73
|
+
"zip",
|
|
74
|
+
"security",
|
|
75
|
+
"inspect",
|
|
76
|
+
"inspection",
|
|
77
|
+
"scanner",
|
|
78
|
+
"scan",
|
|
79
|
+
"detect",
|
|
80
|
+
"defuse",
|
|
81
|
+
"cdr"
|
|
82
|
+
],
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"@biomejs/biome": "^2.5.15",
|
|
85
|
+
"@types/chai": "^5.2.3",
|
|
86
|
+
"@types/mocha": "^10.0.10",
|
|
87
|
+
"@types/node": "^22.20.5",
|
|
88
|
+
"c8": "^12.0.0",
|
|
89
|
+
"chai": "^6.3.0",
|
|
90
|
+
"husky": "^9.1.7",
|
|
91
|
+
"lint-staged": "^17.6.0",
|
|
92
|
+
"mocha": "^12.0.3",
|
|
93
|
+
"typescript": "^7.0.2"
|
|
94
|
+
},
|
|
95
|
+
"lint-staged": {
|
|
96
|
+
"*.{ts,js,mjs,cjs,json,jsonc}": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
|
97
|
+
}
|
|
98
|
+
}
|