@doccloak/core 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -2
- package/README.md +252 -19
- package/dist/doc.d.ts +68 -1
- package/dist/doc.js +447 -62
- package/dist/dom/docx.d.ts +135 -7
- package/dist/dom/docx.js +752 -125
- package/dist/dom/drawingml.d.ts +59 -0
- package/dist/dom/drawingml.js +176 -0
- package/dist/dom/errors.d.ts +35 -0
- package/dist/dom/errors.js +24 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +6 -0
- package/dist/dom/office.d.ts +56 -0
- package/dist/dom/office.js +76 -23
- package/dist/dom/opc.d.ts +135 -7
- package/dist/dom/opc.js +407 -14
- package/dist/dom/package-policy.d.ts +137 -0
- package/dist/dom/package-policy.js +355 -0
- package/dist/dom/xlsx.d.ts +64 -6
- package/dist/dom/xlsx.js +130 -20
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +37 -13
- package/dist/env.d.ts +21 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +10 -5
- package/dist/model-loader.d.ts +61 -6
- package/dist/model-loader.js +122 -17
- package/dist/pipeline.d.ts +87 -8
- package/dist/pipeline.js +249 -50
- package/dist/providers/bardsai.d.ts +8 -1
- package/dist/providers/bardsai.js +22 -2
- package/dist/providers/gliner-base.d.ts +8 -1
- package/dist/providers/gliner-base.js +22 -2
- package/dist/providers/gliner.d.ts +8 -1
- package/dist/providers/gliner.js +22 -2
- package/dist/regex/loader.d.ts +2 -0
- package/dist/regex/loader.js +2 -0
- package/dist/regex/rules.data.js +15 -14
- package/dist/regex/types.d.ts +6 -0
- package/dist/session.d.ts +101 -3
- package/dist/session.js +397 -43
- package/dist/surrogates.js +57 -45
- package/dist/worker-protocol.d.ts +13 -1
- package/dist/worker-protocol.js +164 -12
- package/package.json +1 -1
- package/rules/at.json +1 -1
- package/rules/be.json +1 -1
- package/rules/ch.json +1 -1
- package/rules/de.json +1 -1
- package/rules/dk.json +1 -1
- package/rules/es.json +1 -1
- package/rules/fi.json +1 -1
- package/rules/fr.json +1 -1
- package/rules/it.json +1 -1
- package/rules/no.json +1 -1
- package/rules/pl.json +1 -1
- package/rules/pt.json +1 -1
- package/rules/schema.json +4 -0
- package/rules/se.json +1 -1
- package/rules/universal.json +2 -1
package/dist/doc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import CFB from 'cfb';
|
|
2
2
|
import { normalizeReplacements } from "./docx.js";
|
|
3
|
+
import { UnsupportedDocumentError } from "./dom/errors.js";
|
|
3
4
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
4
5
|
function toUint8Array(content) {
|
|
5
6
|
return content instanceof Uint8Array
|
|
@@ -182,6 +183,107 @@ function readPlcBlob(tableStream, fc, lcb) {
|
|
|
182
183
|
return new Uint8Array(0);
|
|
183
184
|
return tableStream.slice(fc, fc + lcb);
|
|
184
185
|
}
|
|
186
|
+
// ─── FIB base flags (MS-DOC 2.5.1 FibBase) ───────────────────────────────────
|
|
187
|
+
/** FibBase.nFib (offset 0x02): file format version; superseded by nFibNew. */
|
|
188
|
+
const FIB_NFIB = 0x0002;
|
|
189
|
+
/** FibBase flags word A (offset 0x0A). Bit layout, low to high:
|
|
190
|
+
* fDot 0x0001, fGlsy 0x0002, fComplex 0x0004, fHasPic 0x0008,
|
|
191
|
+
* cQuickSaves 0x00F0, fEncrypted 0x0100, fWhichTblStm 0x0200,
|
|
192
|
+
* fReadOnlyRecommended 0x0400, fWriteReservation 0x0800, fExtChar 0x1000,
|
|
193
|
+
* fLoadOverride 0x2000, fFarEast 0x4000, fObfuscated 0x8000. */
|
|
194
|
+
const FIB_FLAGS = 0x000a;
|
|
195
|
+
const FIB_F_COMPLEX = 0x0004;
|
|
196
|
+
const FIB_CQUICKSAVES_MASK = 0x00f0;
|
|
197
|
+
const FIB_CQUICKSAVES_SHIFT = 4;
|
|
198
|
+
const FIB_F_ENCRYPTED = 0x0100;
|
|
199
|
+
const FIB_F_WHICH_TBL_STM = 0x0200;
|
|
200
|
+
/** fObfuscated: XOR obfuscation (only meaningful when fEncrypted is set, but
|
|
201
|
+
* a file with either bit set is unreadable for us). */
|
|
202
|
+
const FIB_F_OBFUSCATED = 0x8000;
|
|
203
|
+
/** FibRgFcLcbBlob length prefix cbRgFcLcb (offset 0x98), in 8-byte units.
|
|
204
|
+
* FibRgCswNew follows it: cswNew (2 bytes) then nFibNew (2 bytes). */
|
|
205
|
+
const FIB_CB_RG_FC_LCB = 0x0098;
|
|
206
|
+
const FIB_RG_FC_LCB = 0x009a;
|
|
207
|
+
/** nFib of Word 2000. From this version on, cQuickSaves MUST be 0xF and no
|
|
208
|
+
* longer counts incremental saves (MS-DOC 2.5.1). */
|
|
209
|
+
const NFIB_WORD_2000 = 0x00d9;
|
|
210
|
+
function readFibBase(view) {
|
|
211
|
+
const flags = view.getUint16(FIB_FLAGS, true);
|
|
212
|
+
let nFib = view.getUint16(FIB_NFIB, true);
|
|
213
|
+
// nFibNew supersedes nFib when FibRgCswNew is present.
|
|
214
|
+
if (view.byteLength >= FIB_RG_FC_LCB) {
|
|
215
|
+
const cbRgFcLcb = view.getUint16(FIB_CB_RG_FC_LCB, true);
|
|
216
|
+
const cswNewOff = FIB_RG_FC_LCB + cbRgFcLcb * 8;
|
|
217
|
+
if (cswNewOff + 4 <= view.byteLength && view.getUint16(cswNewOff, true) > 0) {
|
|
218
|
+
nFib = view.getUint16(cswNewOff + 2, true);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const cQuickSaves = (flags & FIB_CQUICKSAVES_MASK) >> FIB_CQUICKSAVES_SHIFT;
|
|
222
|
+
// fComplex: the last save was incremental. cQuickSaves counts those saves
|
|
223
|
+
// only for Word 97 files; Word 2000+ always writes 0xF there.
|
|
224
|
+
const fastSaved = (flags & FIB_F_COMPLEX) !== 0
|
|
225
|
+
|| (nFib < NFIB_WORD_2000 && cQuickSaves > 0);
|
|
226
|
+
const encrypted = (flags & (FIB_F_ENCRYPTED | FIB_F_OBFUSCATED)) !== 0;
|
|
227
|
+
return {
|
|
228
|
+
flags,
|
|
229
|
+
nFib,
|
|
230
|
+
whichTable: (flags & FIB_F_WHICH_TBL_STM) ? 1 : 0,
|
|
231
|
+
encrypted,
|
|
232
|
+
fastSaved,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Fail closed on files this module cannot redact completely:
|
|
237
|
+
* - encrypted / XOR-obfuscated: the text is ciphertext, detection sees noise
|
|
238
|
+
* and the export would be the untouched ciphertext (L2);
|
|
239
|
+
* - fast-saved (incremental save): deleted text lives in WordDocument regions
|
|
240
|
+
* outside the piece table, so it never reaches the detector and cannot be
|
|
241
|
+
* located for scrubbing (H5). The user has to resave the file in Word.
|
|
242
|
+
*/
|
|
243
|
+
function assertSupportedFib(fib) {
|
|
244
|
+
if (fib.encrypted) {
|
|
245
|
+
throw new UnsupportedDocumentError('encrypted', 'This .doc file is encrypted or obfuscated. Remove the password in Word and save it again, or save as .docx.');
|
|
246
|
+
}
|
|
247
|
+
if (fib.fastSaved) {
|
|
248
|
+
throw new UnsupportedDocumentError('fast-saved', 'This .doc file was fast-saved (incremental save) and may hold deleted text outside the visible document. Open it in Word and Save As, or save as .docx.');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function containerHasPath(container, name) {
|
|
252
|
+
const needle = name.toLowerCase();
|
|
253
|
+
return container.FullPaths.some((p) => {
|
|
254
|
+
const rel = p.replace(/^Root Entry\//i, '').replace(/\/$/, '').toLowerCase();
|
|
255
|
+
return rel === needle || rel.startsWith(needle + '/');
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Cheap look at a legacy .doc without parsing the piece table: which refusal
|
|
260
|
+
* flags are set and which non-text streams the file carries. Hosts use it to
|
|
261
|
+
* show a warning next to the export (ObjectPool / Macros are copied through
|
|
262
|
+
* unchanged, see writeAnonymizedDoc) or to explain a refusal up front.
|
|
263
|
+
* Throws only when the file is not a .doc at all.
|
|
264
|
+
*/
|
|
265
|
+
export function inspectDoc(buffer) {
|
|
266
|
+
const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
267
|
+
const container = CFB.parse(data);
|
|
268
|
+
const wordDocEntry = CFB.find(container, '/WordDocument') ?? CFB.find(container, 'WordDocument');
|
|
269
|
+
if (!wordDocEntry?.content)
|
|
270
|
+
throw new Error('Invalid .doc file: missing WordDocument stream');
|
|
271
|
+
const wordDoc = toUint8Array(wordDocEntry.content);
|
|
272
|
+
const view = new DataView(wordDoc.buffer, wordDoc.byteOffset, wordDoc.byteLength);
|
|
273
|
+
if (view.byteLength < 0x20 || view.getUint16(0, true) !== 0xa5ec) {
|
|
274
|
+
throw new Error('Invalid .doc file: bad magic number');
|
|
275
|
+
}
|
|
276
|
+
const fib = readFibBase(view);
|
|
277
|
+
return {
|
|
278
|
+
encrypted: fib.encrypted,
|
|
279
|
+
fastSaved: fib.fastSaved,
|
|
280
|
+
streams: {
|
|
281
|
+
data: containerHasPath(container, 'Data'),
|
|
282
|
+
objectPool: containerHasPath(container, 'ObjectPool'),
|
|
283
|
+
macros: containerHasPath(container, 'Macros'),
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
185
287
|
// ─── Shared parsing ──────────────────────────────────────────────────────────
|
|
186
288
|
function parseDocStreams(buffer) {
|
|
187
289
|
const data = new Uint8Array(buffer);
|
|
@@ -195,9 +297,9 @@ function parseDocStreams(buffer) {
|
|
|
195
297
|
if (wIdent !== 0xa5ec) {
|
|
196
298
|
throw new Error('Invalid .doc file: bad magic number');
|
|
197
299
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
const tblName =
|
|
300
|
+
const fib = readFibBase(wordView);
|
|
301
|
+
assertSupportedFib(fib);
|
|
302
|
+
const tblName = fib.whichTable ? '1Table' : '0Table';
|
|
201
303
|
const tableEntry = CFB.find(container, '/' + tblName) ?? CFB.find(container, tblName);
|
|
202
304
|
if (!tableEntry?.content)
|
|
203
305
|
throw new Error(`Invalid .doc file: missing ${tblName} stream`);
|
|
@@ -284,44 +386,70 @@ function extractDocContent(parsed) {
|
|
|
284
386
|
return { text, cpMap };
|
|
285
387
|
}
|
|
286
388
|
// ─── Main: write anonymized .doc ─────────────────────────────────────────────
|
|
287
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Write a redacted copy of a legacy .doc.
|
|
391
|
+
*
|
|
392
|
+
* `replacements` are offsets into the text returned by readDocText. Main-text
|
|
393
|
+
* ranges are swapped for the replacement string through a rewritten piece
|
|
394
|
+
* table; ranges in subdocuments (footnotes, headers, comments) are overwritten
|
|
395
|
+
* in place. `valueReplacements` (same shape as writeAnonymizedDocx) add
|
|
396
|
+
* original values that were not located by offset, e.g. session entries.
|
|
397
|
+
*
|
|
398
|
+
* Every original value (from both sources) is then destroyed wherever else it
|
|
399
|
+
* still occurs as bytes, without moving any structure (see scrubStreamBytes).
|
|
400
|
+
*
|
|
401
|
+
* Refuses (UnsupportedDocumentError) fast-saved and encrypted files, see
|
|
402
|
+
* assertSupportedFib.
|
|
403
|
+
*
|
|
404
|
+
* Copied through untouched: ObjectPool (embedded OLE objects), Macros (VBA)
|
|
405
|
+
* and \x01CompObj. TODO(T196, T199): delete them with CFB.utils.cfb_del once
|
|
406
|
+
* the open-in-Word / LibreOffice experiment proves the result still opens.
|
|
407
|
+
* Until then hosts should call inspectDoc and warn when they are present.
|
|
408
|
+
*/
|
|
409
|
+
export async function writeAnonymizedDoc(buffer, replacements, valueReplacements = []) {
|
|
288
410
|
const parsed = parseDocStreams(buffer);
|
|
289
411
|
const { container, wordDocEntry, tableEntry, wordDoc: origWordDoc, wordView: wv, tableDoc: origTable, ccpText, pieces } = parsed;
|
|
290
412
|
// Author/title/company metadata lives outside the text streams; scrub it always
|
|
291
413
|
scrubOleMetadataStreams(container);
|
|
292
|
-
if (replacements.length === 0) {
|
|
293
|
-
// Still scrub the associated-strings table (author, template paths) in place
|
|
294
|
-
scrubSttbfAssoc(origTable, wv);
|
|
295
|
-
wordDocEntry.content = origWordDoc;
|
|
296
|
-
tableEntry.content = origTable;
|
|
297
|
-
const bytes = CFB.write(container, { type: 'array' });
|
|
298
|
-
return new Blob([new Uint8Array(bytes)], { type: 'application/msword' });
|
|
299
|
-
}
|
|
300
|
-
// ── Read remaining FIB pointers ──
|
|
301
|
-
const fcPlcfBteChpx = wv.getInt32(0x00fa, true);
|
|
302
|
-
const lcbPlcfBteChpx = wv.getInt32(0x00fe, true);
|
|
303
|
-
const fcPlcfBtePapx = wv.getInt32(0x0102, true);
|
|
304
|
-
const lcbPlcfBtePapx = wv.getInt32(0x0106, true);
|
|
305
|
-
const fcPlcfSed = wv.getInt32(0x00ca, true);
|
|
306
|
-
const lcbPlcfSed = wv.getInt32(0x00ce, true);
|
|
307
414
|
// ── Map replacement offsets (normalized text space) to CP space ──
|
|
308
415
|
// The offsets the caller passes are positions in the text produced by
|
|
309
416
|
// readDocText. Normalization drops/collapses control characters, so those
|
|
310
417
|
// offsets shift against CPs; apply the recorded map to land exactly.
|
|
311
418
|
const { text, cpMap } = extractDocContent(parsed);
|
|
312
419
|
const cpReplacements = [];
|
|
420
|
+
const originals = valueReplacements.map((v) => v.value);
|
|
313
421
|
for (const repl of normalizeReplacements(replacements)) {
|
|
314
422
|
if (repl.start < 0 || repl.end > text.length || repl.start >= repl.end) {
|
|
315
423
|
// Offsets that do not match the extracted text indicate a caller bug.
|
|
316
424
|
// Fail closed: never export a file whose redaction we cannot place.
|
|
317
425
|
throw new Error('Replacement offsets do not match the document text');
|
|
318
426
|
}
|
|
427
|
+
originals.push(text.slice(repl.start, repl.end));
|
|
319
428
|
cpReplacements.push({
|
|
320
429
|
start: cpMap[repl.start],
|
|
321
430
|
end: cpMap[repl.end - 1] + 1,
|
|
322
431
|
replacement: repl.replacement,
|
|
323
432
|
});
|
|
324
433
|
}
|
|
434
|
+
const needles = buildScrubNeedles(originals, [
|
|
435
|
+
...valueReplacements.map((v) => v.replacement),
|
|
436
|
+
...replacements.map((r) => r.replacement),
|
|
437
|
+
]);
|
|
438
|
+
if (replacements.length === 0) {
|
|
439
|
+
// No structural rewrite: scrub the string tables and remnant bytes in place
|
|
440
|
+
finalizeDocStreams(container, origWordDoc, origWordDoc.length, origTable, wv, needles);
|
|
441
|
+
wordDocEntry.content = origWordDoc;
|
|
442
|
+
tableEntry.content = origTable;
|
|
443
|
+
const bytes = CFB.write(container, { type: 'array' });
|
|
444
|
+
return new Blob([new Uint8Array(bytes)], { type: 'application/msword' });
|
|
445
|
+
}
|
|
446
|
+
// ── Read remaining FIB pointers ──
|
|
447
|
+
const fcPlcfBteChpx = wv.getInt32(0x00fa, true);
|
|
448
|
+
const lcbPlcfBteChpx = wv.getInt32(0x00fe, true);
|
|
449
|
+
const fcPlcfBtePapx = wv.getInt32(0x0102, true);
|
|
450
|
+
const lcbPlcfBtePapx = wv.getInt32(0x0106, true);
|
|
451
|
+
const fcPlcfSed = wv.getInt32(0x00ca, true);
|
|
452
|
+
const lcbPlcfSed = wv.getInt32(0x00ce, true);
|
|
325
453
|
// Main-text replacements go through the piece table (labeled placeholders).
|
|
326
454
|
// Replacements in subdocuments (footnotes, headers, comments) are redacted
|
|
327
455
|
// in place with length-preserving overwrites: shifting CPs there would
|
|
@@ -462,8 +590,10 @@ export async function writeAnonymizedDoc(buffer, replacements) {
|
|
|
462
590
|
fv.setInt32(0x00ca, newFcPlcfSed, true);
|
|
463
591
|
fv.setInt32(0x00ce, newPlcfSed.length, true);
|
|
464
592
|
}
|
|
465
|
-
// ──
|
|
466
|
-
|
|
593
|
+
// ── String tables, Data stream and remnant bytes ──
|
|
594
|
+
// The appended region of newWordDoc holds only replacement text and the
|
|
595
|
+
// FKPs built above, so the byte scrub covers the copied original part.
|
|
596
|
+
finalizeDocStreams(container, newWordDoc, origWordDoc.length, newTable, fv, needles);
|
|
467
597
|
// ── Write back to OLE2 container ──
|
|
468
598
|
// CFB.write sizes streams from entry.size, not content length; keep in sync
|
|
469
599
|
wordDocEntry.content = newWordDoc;
|
|
@@ -519,57 +649,312 @@ function overwritePieceBytes(target, pieces, cpStart, cpEnd, fillChar) {
|
|
|
519
649
|
}
|
|
520
650
|
}
|
|
521
651
|
// ─── Metadata scrubbing ──────────────────────────────────────────────────────
|
|
652
|
+
// FibRgFcLcb97 (MS-DOC 2.5.5): the fc/lcb pairs start at 0x9A, 8 bytes each,
|
|
653
|
+
// so entry i has fc at 0x9A + 8*i and lcb at 0x9E + 8*i.
|
|
654
|
+
/** Entry 21: SttbfBkmk, bookmark names. */
|
|
655
|
+
const FC_STTBF_BKMK = 0x0142;
|
|
656
|
+
const LCB_STTBF_BKMK = 0x0146;
|
|
657
|
+
/** Entry 32: SttbfAssoc, author, last-saved-by, template and data-source paths. */
|
|
522
658
|
const FC_STTBF_ASSOC = 0x019a;
|
|
523
659
|
const LCB_STTBF_ASSOC = 0x019e;
|
|
660
|
+
/** Entry 35: AutosaveSource. MS-DOC says lcb MUST be 0; older Word versions
|
|
661
|
+
* wrote the autosave path here (an Xst). */
|
|
662
|
+
const FC_AUTOSAVE_SOURCE = 0x01b2;
|
|
663
|
+
const LCB_AUTOSAVE_SOURCE = 0x01b6;
|
|
664
|
+
/** Entry 36: GrpXstAtnOwners, comment author names (array of Xst). */
|
|
665
|
+
const FC_GRP_XST_ATN_OWNERS = 0x01ba;
|
|
666
|
+
const LCB_GRP_XST_ATN_OWNERS = 0x01be;
|
|
667
|
+
/** Entry 51: SttbfRMark, revision (tracked change) author names. */
|
|
668
|
+
const FC_STTBF_RMARK = 0x0232;
|
|
669
|
+
const LCB_STTBF_RMARK = 0x0236;
|
|
670
|
+
/** Entry 71: SttbSavedBy, alternating user names and file paths of past saves. */
|
|
671
|
+
const FC_STTB_SAVED_BY = 0x02d2;
|
|
672
|
+
const LCB_STTB_SAVED_BY = 0x02d6;
|
|
524
673
|
/**
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
* disconnected via the FIB.
|
|
674
|
+
* Space-fill every string of an STTB (MS-DOC 2.9.271) in place. Handles the
|
|
675
|
+
* extended (0xFFFF marker, UTF-16) and the 8-bit layouts; cbExtra bytes after
|
|
676
|
+
* each string are kept. Throws when the layout does not add up so the caller
|
|
677
|
+
* can blank the region wholesale instead of trusting it.
|
|
530
678
|
*/
|
|
531
|
-
function
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
offset += 2;
|
|
552
|
-
for (let j = 0; j < cch; j++) {
|
|
553
|
-
v.setUint16(offset + j * 2, 0x0020, true);
|
|
554
|
-
}
|
|
555
|
-
offset += cch * 2 + cbExtra;
|
|
679
|
+
function scrubSttb(table, fc, lcb) {
|
|
680
|
+
const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
|
|
681
|
+
let offset = 0;
|
|
682
|
+
const extended = v.getUint16(0, true) === 0xffff;
|
|
683
|
+
if (extended)
|
|
684
|
+
offset = 2;
|
|
685
|
+
const count = v.getUint16(offset, true);
|
|
686
|
+
const cbExtra = v.getUint16(offset + 2, true);
|
|
687
|
+
offset += 4;
|
|
688
|
+
if (count > 4096)
|
|
689
|
+
throw new Error('bad sttb');
|
|
690
|
+
for (let i = 0; i < count; i++) {
|
|
691
|
+
let cch;
|
|
692
|
+
if (extended) {
|
|
693
|
+
cch = v.getUint16(offset, true);
|
|
694
|
+
offset += 2;
|
|
695
|
+
if (offset + cch * 2 > lcb)
|
|
696
|
+
throw new Error('bad sttb');
|
|
697
|
+
for (let j = 0; j < cch; j++) {
|
|
698
|
+
v.setUint16(offset + j * 2, 0x0020, true);
|
|
556
699
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
700
|
+
offset += cch * 2 + cbExtra;
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
cch = v.getUint8(offset);
|
|
704
|
+
offset += 1;
|
|
705
|
+
if (offset + cch > lcb)
|
|
706
|
+
throw new Error('bad sttb');
|
|
707
|
+
for (let j = 0; j < cch; j++) {
|
|
708
|
+
v.setUint8(offset + j, 0x20);
|
|
564
709
|
}
|
|
565
|
-
|
|
566
|
-
throw new Error('bad sttbf');
|
|
710
|
+
offset += cch + cbExtra;
|
|
567
711
|
}
|
|
712
|
+
if (offset > lcb)
|
|
713
|
+
throw new Error('bad sttb');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Space-fill every Xst (cch + UTF-16 chars, MS-DOC 2.9.352) of a packed group
|
|
718
|
+
* such as GrpXstAtnOwners. Throws on a layout mismatch.
|
|
719
|
+
*/
|
|
720
|
+
function scrubXstGroup(table, fc, lcb) {
|
|
721
|
+
const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
|
|
722
|
+
let offset = 0;
|
|
723
|
+
while (offset + 2 <= lcb) {
|
|
724
|
+
const cch = v.getUint16(offset, true);
|
|
725
|
+
offset += 2;
|
|
726
|
+
if (offset + cch * 2 > lcb)
|
|
727
|
+
throw new Error('bad xst group');
|
|
728
|
+
for (let j = 0; j < cch; j++)
|
|
729
|
+
v.setUint16(offset + j * 2, 0x0020, true);
|
|
730
|
+
offset += cch * 2;
|
|
731
|
+
}
|
|
732
|
+
if (offset !== lcb)
|
|
733
|
+
throw new Error('bad xst group');
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Read the fc/lcb pair, run `scrub` over the region and, if the structure
|
|
737
|
+
* cannot be parsed, zero the region and disconnect it via the FIB. Offsets
|
|
738
|
+
* of everything else in the Table stream are untouched either way.
|
|
739
|
+
*/
|
|
740
|
+
function scrubTableRegion(table, fibView, fcOffset, lcbOffset, scrub) {
|
|
741
|
+
if (lcbOffset + 4 > fibView.byteLength)
|
|
742
|
+
return;
|
|
743
|
+
const fc = fibView.getInt32(fcOffset, true);
|
|
744
|
+
const lcb = fibView.getInt32(lcbOffset, true);
|
|
745
|
+
if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
|
|
746
|
+
return;
|
|
747
|
+
try {
|
|
748
|
+
scrub(table, fc, lcb);
|
|
568
749
|
}
|
|
569
750
|
catch {
|
|
570
|
-
// Unknown layout: blank the whole range and drop the FIB reference
|
|
571
751
|
table.fill(0, fc, fc + lcb);
|
|
572
|
-
fibView.setInt32(
|
|
752
|
+
fibView.setInt32(lcbOffset, 0, true);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Zero the region and set its lcb to 0. Used for AutosaveSource, whose lcb
|
|
757
|
+
* MS-DOC requires to be 0 anyway; nothing references the bytes afterwards.
|
|
758
|
+
*/
|
|
759
|
+
function blankTableRegion(table, fibView, fcOffset, lcbOffset) {
|
|
760
|
+
if (lcbOffset + 4 > fibView.byteLength)
|
|
761
|
+
return;
|
|
762
|
+
const fc = fibView.getInt32(fcOffset, true);
|
|
763
|
+
const lcb = fibView.getInt32(lcbOffset, true);
|
|
764
|
+
if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
|
|
765
|
+
return;
|
|
766
|
+
table.fill(0, fc, fc + lcb);
|
|
767
|
+
fibView.setInt32(lcbOffset, 0, true);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Scrub every Table-stream string table that carries people or paths:
|
|
771
|
+
* SttbfAssoc (author, last-saved-by, template), SttbfRMark (revision
|
|
772
|
+
* authors), GrpXstAtnOwners (comment authors), SttbfBkmk (bookmark names),
|
|
773
|
+
* SttbSavedBy (save history) and the AutosaveSource path. Strings are
|
|
774
|
+
* space-filled in place so indices referenced from CHPX / PLCs stay valid.
|
|
775
|
+
*/
|
|
776
|
+
function scrubTableStringTables(table, fibView) {
|
|
777
|
+
scrubTableRegion(table, fibView, FC_STTBF_ASSOC, LCB_STTBF_ASSOC, scrubSttb);
|
|
778
|
+
scrubTableRegion(table, fibView, FC_STTBF_RMARK, LCB_STTBF_RMARK, scrubSttb);
|
|
779
|
+
scrubTableRegion(table, fibView, FC_STTBF_BKMK, LCB_STTBF_BKMK, scrubSttb);
|
|
780
|
+
scrubTableRegion(table, fibView, FC_STTB_SAVED_BY, LCB_STTB_SAVED_BY, scrubSttb);
|
|
781
|
+
scrubTableRegion(table, fibView, FC_GRP_XST_ATN_OWNERS, LCB_GRP_XST_ATN_OWNERS, scrubXstGroup);
|
|
782
|
+
blankTableRegion(table, fibView, FC_AUTOSAVE_SOURCE, LCB_AUTOSAVE_SOURCE);
|
|
783
|
+
}
|
|
784
|
+
// ─── Length-preserving byte scrub ────────────────────────────────────────────
|
|
785
|
+
/** Values shorter than this are not searched for: too many accidental hits. */
|
|
786
|
+
const MIN_SCRUB_NEEDLE = 4;
|
|
787
|
+
/**
|
|
788
|
+
* Derive the byte-scrub needles from the original values: each value itself,
|
|
789
|
+
* each whitespace-separated token and each token stripped of surrounding
|
|
790
|
+
* punctuation, keeping those of at least MIN_SCRUB_NEEDLE characters.
|
|
791
|
+
* Matching is case-insensitive (see scrubStreamBytes), which covers every
|
|
792
|
+
* case variant (as written, lower, UPPER, Capitalised) in one pass.
|
|
793
|
+
*
|
|
794
|
+
* A needle that occurs (case-insensitively) inside one of the `replacements`
|
|
795
|
+
* is dropped: a token such as "Person" from "Leaky Person" would otherwise
|
|
796
|
+
* blank the "[PERSON_1]" placeholder, and a surrogate that keeps a word of
|
|
797
|
+
* the original ("Smith & Co" -> "Nowak & Co") would lose it. The full
|
|
798
|
+
* original value is never dropped.
|
|
799
|
+
*/
|
|
800
|
+
export function buildScrubNeedles(originals, replacements = []) {
|
|
801
|
+
const protectedText = replacements.filter((r) => typeof r === 'string').map((r) => r.toLowerCase());
|
|
802
|
+
const values = new Set();
|
|
803
|
+
const out = new Set();
|
|
804
|
+
const add = (s, isValue) => {
|
|
805
|
+
const t = s.trim();
|
|
806
|
+
if (t.length < MIN_SCRUB_NEEDLE)
|
|
807
|
+
return;
|
|
808
|
+
if (isValue)
|
|
809
|
+
values.add(t);
|
|
810
|
+
else if (values.has(t))
|
|
811
|
+
return;
|
|
812
|
+
else {
|
|
813
|
+
const lower = t.toLowerCase();
|
|
814
|
+
if (protectedText.some((r) => r.includes(lower)))
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
out.add(t);
|
|
818
|
+
};
|
|
819
|
+
for (const original of originals) {
|
|
820
|
+
if (typeof original !== 'string')
|
|
821
|
+
continue;
|
|
822
|
+
add(original, true);
|
|
823
|
+
}
|
|
824
|
+
for (const original of originals) {
|
|
825
|
+
if (typeof original !== 'string')
|
|
826
|
+
continue;
|
|
827
|
+
for (const token of original.split(/\s+/)) {
|
|
828
|
+
add(token, false);
|
|
829
|
+
add(token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ''), false);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Longest first so a full value is blanked before its tokens are considered
|
|
833
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
834
|
+
}
|
|
835
|
+
let cp1252Reverse;
|
|
836
|
+
/** Reverse of CP1252_MAP (declared below), built on first use. */
|
|
837
|
+
function cp1252ReverseMap() {
|
|
838
|
+
if (!cp1252Reverse) {
|
|
839
|
+
cp1252Reverse = new Map(Object.entries(CP1252_MAP).map(([byte, code]) => [code, Number(byte)]));
|
|
840
|
+
}
|
|
841
|
+
return cp1252Reverse;
|
|
842
|
+
}
|
|
843
|
+
/** Encode to cp1252 bytes, or null when a character has no cp1252 form. */
|
|
844
|
+
function encodeCp1252(text) {
|
|
845
|
+
const out = new Uint8Array(text.length);
|
|
846
|
+
for (let i = 0; i < text.length; i++) {
|
|
847
|
+
const code = text.charCodeAt(i);
|
|
848
|
+
if (code < 0x80 || (code >= 0xa0 && code <= 0xff)) {
|
|
849
|
+
out[i] = code;
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
const mapped = cp1252ReverseMap().get(code);
|
|
853
|
+
if (mapped === undefined)
|
|
854
|
+
return null;
|
|
855
|
+
out[i] = mapped;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
function foldCodeUnit(code) {
|
|
861
|
+
if (code >= 0x41 && code <= 0x5a)
|
|
862
|
+
return code + 0x20;
|
|
863
|
+
if (code < 0x80)
|
|
864
|
+
return code;
|
|
865
|
+
return String.fromCharCode(code).toLowerCase().charCodeAt(0);
|
|
866
|
+
}
|
|
867
|
+
function foldCp1252Byte(byte) {
|
|
868
|
+
if (byte >= 0x41 && byte <= 0x5a)
|
|
869
|
+
return byte + 0x20;
|
|
870
|
+
if (byte < 0x80)
|
|
871
|
+
return byte;
|
|
872
|
+
return foldCodeUnit(cp1252ToChar(byte).charCodeAt(0) || byte);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Overwrite every case-insensitive occurrence of `needle` in `bytes` with
|
|
876
|
+
* spaces of the same encoding. UTF-16LE: units are read at every byte
|
|
877
|
+
* offset (strings in the Table stream are not always 2-byte aligned) and
|
|
878
|
+
* replaced by 0x20 0x00; cp1252: bytes replaced by 0x20. Returns the number
|
|
879
|
+
* of hits. Stream length never changes.
|
|
880
|
+
*/
|
|
881
|
+
export function scrubStreamBytes(bytes, needle, encoding) {
|
|
882
|
+
const folded = [];
|
|
883
|
+
if (encoding === 'cp1252') {
|
|
884
|
+
const encoded = encodeCp1252(needle);
|
|
885
|
+
if (!encoded)
|
|
886
|
+
return 0;
|
|
887
|
+
for (const b of encoded)
|
|
888
|
+
folded.push(foldCp1252Byte(b));
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
for (let i = 0; i < needle.length; i++)
|
|
892
|
+
folded.push(foldCodeUnit(needle.charCodeAt(i)));
|
|
893
|
+
}
|
|
894
|
+
const n = folded.length;
|
|
895
|
+
if (n === 0)
|
|
896
|
+
return 0;
|
|
897
|
+
const unit = encoding === 'utf16le' ? 2 : 1;
|
|
898
|
+
const span = n * unit;
|
|
899
|
+
const first = folded[0];
|
|
900
|
+
let hits = 0;
|
|
901
|
+
for (let i = 0; i + span <= bytes.length; i++) {
|
|
902
|
+
// Fast reject on the first unit before folding the rest
|
|
903
|
+
const u0 = unit === 2 ? bytes[i] | (bytes[i + 1] << 8) : bytes[i];
|
|
904
|
+
if ((unit === 2 ? foldCodeUnit(u0) : foldCp1252Byte(u0)) !== first)
|
|
905
|
+
continue;
|
|
906
|
+
let match = true;
|
|
907
|
+
for (let j = 1; j < n; j++) {
|
|
908
|
+
const pos = i + j * unit;
|
|
909
|
+
const u = unit === 2 ? bytes[pos] | (bytes[pos + 1] << 8) : bytes[pos];
|
|
910
|
+
if ((unit === 2 ? foldCodeUnit(u) : foldCp1252Byte(u)) !== folded[j]) {
|
|
911
|
+
match = false;
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (!match)
|
|
916
|
+
continue;
|
|
917
|
+
for (let j = 0; j < n; j++) {
|
|
918
|
+
bytes[i + j * unit] = 0x20;
|
|
919
|
+
if (unit === 2)
|
|
920
|
+
bytes[i + j * unit + 1] = 0x00;
|
|
921
|
+
}
|
|
922
|
+
hits++;
|
|
923
|
+
i += span - 1;
|
|
924
|
+
}
|
|
925
|
+
return hits;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Last pass over the three streams before the container is written:
|
|
929
|
+
* 1. string tables in Table (scrubTableStringTables);
|
|
930
|
+
* 2. remnant bytes of every original value (buildScrubNeedles):
|
|
931
|
+
* - WordDocument, first `wordDocScanLength` bytes (the copied original;
|
|
932
|
+
* the appended replacement text is excluded), UTF-16LE only;
|
|
933
|
+
* - Table, UTF-16LE and cp1252 (STTB remnants, 8-bit layouts);
|
|
934
|
+
* - Data (hyperlink field data, OLE previews), UTF-16LE only.
|
|
935
|
+
* cp1252 is deliberately NOT searched in Data or WordDocument: a 4-letter
|
|
936
|
+
* name has a real chance (about 2e-4 per name per MB) of matching bytes
|
|
937
|
+
* inside embedded JPEG/PNG data, and overwriting those would corrupt the
|
|
938
|
+
* picture. UTF-16 patterns (letter, 0x00, letter, 0x00) do not occur in
|
|
939
|
+
* compressed image data. Every overwrite is length-preserving.
|
|
940
|
+
*/
|
|
941
|
+
function finalizeDocStreams(container, wordDoc, wordDocScanLength, table, fibView, needles) {
|
|
942
|
+
scrubTableStringTables(table, fibView);
|
|
943
|
+
if (needles.length === 0)
|
|
944
|
+
return;
|
|
945
|
+
const wordDocOriginal = wordDoc.subarray(0, Math.min(wordDocScanLength, wordDoc.length));
|
|
946
|
+
for (const needle of needles) {
|
|
947
|
+
scrubStreamBytes(wordDocOriginal, needle, 'utf16le');
|
|
948
|
+
scrubStreamBytes(table, needle, 'utf16le');
|
|
949
|
+
scrubStreamBytes(table, needle, 'cp1252');
|
|
950
|
+
}
|
|
951
|
+
const dataEntry = CFB.find(container, '/Data') ?? CFB.find(container, 'Data');
|
|
952
|
+
if (dataEntry?.content) {
|
|
953
|
+
const data = toUint8Array(dataEntry.content);
|
|
954
|
+
for (const needle of needles)
|
|
955
|
+
scrubStreamBytes(data, needle, 'utf16le');
|
|
956
|
+
dataEntry.content = data;
|
|
957
|
+
dataEntry.size = data.length;
|
|
573
958
|
}
|
|
574
959
|
}
|
|
575
960
|
const OLE_META_STREAMS = ['SummaryInformation', 'DocumentSummaryInformation'];
|