@ni-c/imap-mcp 0.3.0 → 0.5.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.
Files changed (52) hide show
  1. package/README.md +35 -10
  2. package/dist/analyze.d.ts +33 -3
  3. package/dist/analyze.js +135 -23
  4. package/dist/attachments.d.ts +12 -0
  5. package/dist/attachments.js +52 -5
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +147 -14
  8. package/dist/extract/child.d.ts +1 -0
  9. package/dist/extract/child.js +83 -0
  10. package/dist/extract/index.d.ts +41 -0
  11. package/dist/extract/index.js +183 -0
  12. package/dist/extract/ooxml.d.ts +35 -0
  13. package/dist/extract/ooxml.js +634 -0
  14. package/dist/extract/pdf.d.ts +62 -0
  15. package/dist/extract/pdf.js +539 -0
  16. package/dist/extract/types.d.ts +56 -0
  17. package/dist/extract/types.js +13 -0
  18. package/dist/imap.d.ts +48 -2
  19. package/dist/imap.js +133 -28
  20. package/dist/message.d.ts +11 -0
  21. package/dist/message.js +26 -3
  22. package/dist/output-schema.d.ts +1 -0
  23. package/dist/output-schema.js +6 -0
  24. package/dist/resources.js +10 -3
  25. package/dist/result.d.ts +7 -1
  26. package/dist/result.js +32 -6
  27. package/dist/schema.d.ts +2 -0
  28. package/dist/schema.js +2 -0
  29. package/dist/server.js +15 -0
  30. package/dist/tools/read.js +473 -58
  31. package/dist/tools/write.js +18 -4
  32. package/package.json +11 -7
  33. package/dist/analyze.js.map +0 -1
  34. package/dist/attachments.js.map +0 -1
  35. package/dist/audit.js.map +0 -1
  36. package/dist/config.js.map +0 -1
  37. package/dist/download.js.map +0 -1
  38. package/dist/draft.js.map +0 -1
  39. package/dist/errors.js.map +0 -1
  40. package/dist/imap.js.map +0 -1
  41. package/dist/index.js.map +0 -1
  42. package/dist/message.js.map +0 -1
  43. package/dist/output-schema.js.map +0 -1
  44. package/dist/resources.js.map +0 -1
  45. package/dist/result.js.map +0 -1
  46. package/dist/schema.js.map +0 -1
  47. package/dist/server.js.map +0 -1
  48. package/dist/stream.js.map +0 -1
  49. package/dist/tools/annotations.js.map +0 -1
  50. package/dist/tools/catalogue.js.map +0 -1
  51. package/dist/tools/read.js.map +0 -1
  52. package/dist/tools/write.js.map +0 -1
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Entries this reader will inflate from one container. Counted on the entries
3
+ * the allowlist below admits, not on everything the archive lists: a report
4
+ * with six hundred embedded pictures is a report, not an attack, and the
5
+ * pictures are never read anyway.
6
+ */
7
+ const MAX_ZIP_ENTRIES = 512;
8
+ /**
9
+ * How large one admitted entry may declare itself, and how large all of them
10
+ * may together.
11
+ *
12
+ * Separate from `maxChars`, which used to be the per-entry budget — and a long
13
+ * contract with tracked changes writes a `document.xml` of several megabytes,
14
+ * far past the million characters that will ever be read from it. The reader
15
+ * slices what it inflates to `maxChars` anyway; these numbers bound the
16
+ * allocation, not the answer.
17
+ */
18
+ const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
19
+ const MAX_INFLATED_BYTES = 48 * 1024 * 1024;
20
+ /**
21
+ * How far the cell walk may scan for a closing tag it never finds, per sheet.
22
+ * Same reasoning as the closer budget in `htmlToText`: bound the product, not
23
+ * each scan.
24
+ */
25
+ const CLOSER_SCAN_BUDGET_FACTOR = 4;
26
+ const MAX_SHEETS = 32;
27
+ const MAX_ROWS = 5_000;
28
+ const MAX_COLS = 128;
29
+ const MAX_SLIDES = 200;
30
+ /**
31
+ * Ceiling on a `number-columns-repeated` count.
32
+ *
33
+ * LibreOffice writes `16384` on the trailing empty cell of every single row.
34
+ * Honouring that verbatim turns a 40 kB spreadsheet into hundreds of megabytes
35
+ * of tab characters, which is a denial of service written by a well-behaved
36
+ * office suite rather than by an attacker.
37
+ */
38
+ const MAX_REPEAT = 256;
39
+ /**
40
+ * A spreadsheet cell holds a value, not a chapter.
41
+ *
42
+ * Applied to every cell, shared strings included. A shared string is written
43
+ * once and referenced by index, so one long string behind sixty thousand
44
+ * cells is a kilobyte of archive standing for gigabytes of output — and the
45
+ * per-row budget below only helps if a single row cannot be that large.
46
+ */
47
+ const MAX_CELL_CHARS = 4_096;
48
+ /**
49
+ * Reads the text of an OOXML or OpenDocument container.
50
+ *
51
+ * The whole defence lives in the `filter` callback below, and it is worth
52
+ * saying why that specific place. `unzipSync` inflates an entry into a buffer
53
+ * sized by the *declared* uncompressed size out of the central directory — a
54
+ * number the sender chose, checked against nothing. The filter is the last
55
+ * point before that allocation, and returning `false` there means the entry is
56
+ * never inflated and never sized.
57
+ *
58
+ * Measured on fflate 0.8.3: a declared size far past the real one does not blow
59
+ * up resident memory on Linux, because the allocation is virtual and untouched
60
+ * pages cost nothing; and a declared size *below* the real one truncates the
61
+ * output to what was declared, because fflate does not grow a caller-sized
62
+ * buffer. The guard stays regardless — it is free, it is the only thing
63
+ * standing between an *honest* high-ratio entry and its real expansion, and a
64
+ * host that does not overcommit would pay the full price.
65
+ *
66
+ * This never recurses. An entry that is itself an archive is not in the name
67
+ * allowlist, so a nested bomb is not descended into; that is a property to keep
68
+ * rather than an omission to fix.
69
+ */
70
+ export async function extractZipDocument(kind, bytes, maxChars, toText) {
71
+ const { unzipSync, strFromU8 } = await import('fflate');
72
+ let admitted = 0;
73
+ let bytesLeft = MAX_INFLATED_BYTES;
74
+ let tooManyParts = false;
75
+ let entries;
76
+ try {
77
+ entries = unzipSync(bytes, {
78
+ filter: (file) => {
79
+ if (tooManyParts)
80
+ return false;
81
+ // Nothing here writes to disk, so this is not a traversal fix. The
82
+ // paths below are matched by prefix, and `xl/worksheets/../../x.xml`
83
+ // would match one of them; refusing the name is cheaper than reasoning
84
+ // about what it would mean.
85
+ if (!isSafeName(file.name))
86
+ return false;
87
+ // The allowlist. It is also what disposes of an entry called
88
+ // `__proto__`, which fflate would otherwise use as an object key: no
89
+ // document part is called that, so it is never admitted.
90
+ if (!wanted(kind, file.name))
91
+ return false;
92
+ admitted += 1;
93
+ if (admitted > MAX_ZIP_ENTRIES) {
94
+ tooManyParts = true;
95
+ return false;
96
+ }
97
+ // Deflate and stored only. fflate throws on any other method once the
98
+ // filter has said yes, and an error is a worse answer than a skip.
99
+ if (file.compression !== 0 && file.compression !== 8)
100
+ return false;
101
+ if (file.originalSize > MAX_ENTRY_BYTES)
102
+ return false;
103
+ if (file.originalSize > bytesLeft)
104
+ return false;
105
+ bytesLeft -= file.originalSize;
106
+ return true;
107
+ },
108
+ });
109
+ }
110
+ catch {
111
+ // Truncated archive, bad signature, unsupported method. The exception text
112
+ // quotes the file and is never passed on.
113
+ return { ok: false, reason: 'corrupt' };
114
+ }
115
+ if (tooManyParts)
116
+ return { ok: false, reason: 'too-many-parts' };
117
+ const read = (name) => {
118
+ const raw = entries[name];
119
+ return raw === undefined ? undefined : strFromU8(raw);
120
+ };
121
+ if (kind === 'xlsx') {
122
+ return sheetsResult(readXlsx(entries, strFromU8, maxChars), maxChars);
123
+ }
124
+ if (kind === 'ods') {
125
+ const content = read('content.xml');
126
+ if (content === undefined)
127
+ return { ok: false, reason: 'not-a-document' };
128
+ return sheetsResult(readOds(content, maxChars, toText), maxChars);
129
+ }
130
+ const parts = [];
131
+ let units = 0;
132
+ let declared;
133
+ let clipped = false;
134
+ let runs;
135
+ if (kind === 'docx') {
136
+ const document = read('word/document.xml');
137
+ if (document === undefined)
138
+ return { ok: false, reason: 'not-a-document' };
139
+ clipped = document.length > maxChars;
140
+ runs = wordRuns(document.slice(0, maxChars));
141
+ parts.push(toText(document, maxChars));
142
+ }
143
+ else if (kind === 'odt') {
144
+ const content = read('content.xml');
145
+ if (content === undefined)
146
+ return { ok: false, reason: 'not-a-document' };
147
+ clipped = content.length > maxChars;
148
+ parts.push(toText(content, maxChars));
149
+ }
150
+ else {
151
+ // pptx: one heading per slide, in slide order rather than in whatever order
152
+ // the archive happens to list them.
153
+ const all = Object.keys(entries)
154
+ .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
155
+ .toSorted((a, b) => slideNumber(a) - slideNumber(b));
156
+ if (all.length === 0)
157
+ return { ok: false, reason: 'not-a-document' };
158
+ const slides = all.slice(0, MAX_SLIDES);
159
+ if (all.length > slides.length)
160
+ declared = all.length;
161
+ for (const name of slides) {
162
+ units += 1;
163
+ parts.push(`== Slide ${units} ==\n${toText(read(name) ?? '', maxChars)}\n`);
164
+ }
165
+ }
166
+ const joined = parts.join('');
167
+ const text = joined.slice(0, maxChars);
168
+ if (text.trim() === '')
169
+ return { ok: false, reason: 'no-text-layer' };
170
+ return {
171
+ ok: true,
172
+ text,
173
+ ...(kind === 'pptx'
174
+ ? {
175
+ unitLabel: 'slides',
176
+ unitCount: units,
177
+ ...(declared === undefined ? {} : { declaredUnitCount: declared }),
178
+ }
179
+ : {}),
180
+ ...(runs === undefined
181
+ ? {}
182
+ : { hiddenRuns: runs.hidden, totalRuns: runs.total }),
183
+ clipped: clipped || joined.length > text.length,
184
+ };
185
+ }
186
+ function sheetsResult(result, maxChars) {
187
+ if (result === undefined)
188
+ return { ok: false, reason: 'not-a-document' };
189
+ const joined = result.sheets
190
+ .map((sheet) => `== Sheet: ${sheet.name} ==\n` +
191
+ sheet.rows.map((row) => row.join('\t')).join('\n'))
192
+ .join('\n\n');
193
+ // The row budget keeps this within a heading or two of `maxChars`; the slice
194
+ // is what makes the contract exact rather than approximate.
195
+ const text = joined.slice(0, maxChars);
196
+ if (text.trim() === '')
197
+ return { ok: false, reason: 'no-text-layer' };
198
+ return {
199
+ ok: true,
200
+ text,
201
+ unitLabel: 'sheets',
202
+ unitCount: result.sheets.length,
203
+ clipped: result.clipped || joined.length > text.length,
204
+ };
205
+ }
206
+ /* ------------------------------------------------------------------ xlsx -- */
207
+ function readXlsx(entries, strFromU8, maxChars) {
208
+ const at = (name) => {
209
+ const raw = entries[name];
210
+ return raw === undefined ? undefined : strFromU8(raw);
211
+ };
212
+ const shared = sharedStrings(at('xl/sharedStrings.xml'));
213
+ const order = sheetOrder(at('xl/workbook.xml'), at('xl/_rels/workbook.xml.rels'));
214
+ const available = Object.keys(entries)
215
+ .filter((name) => /^xl\/worksheets\/[^/]+\.xml$/.test(name))
216
+ .toSorted();
217
+ if (available.length === 0)
218
+ return undefined;
219
+ // The rels file is how a tab's name is tied to its file, and `sheet1.xml` is
220
+ // a convention rather than a rule — it is not necessarily the first tab. When
221
+ // the mapping is missing or does not resolve, fall back to archive order with
222
+ // generated names rather than presenting a guess as a fact.
223
+ const planned = order.length > 0
224
+ ? order.filter((entry) => available.includes(entry.path))
225
+ : [];
226
+ const plan = planned.length > 0
227
+ ? planned
228
+ : available.map((path, index) => ({ name: `Sheet ${index + 1}`, path }));
229
+ const budget = { left: maxChars, clipped: false };
230
+ const sheets = [];
231
+ for (const entry of plan.slice(0, MAX_SHEETS)) {
232
+ if (budget.left <= 0) {
233
+ budget.clipped = true;
234
+ break;
235
+ }
236
+ const xml = at(entry.path);
237
+ if (xml === undefined)
238
+ continue;
239
+ sheets.push({ name: entry.name, rows: worksheetRows(xml, shared, budget) });
240
+ }
241
+ return { sheets, clipped: budget.clipped };
242
+ }
243
+ /** `<si>` entries in document order; a run-split string is its `<t>` pieces. */
244
+ function sharedStrings(xml) {
245
+ if (xml === undefined)
246
+ return [];
247
+ const out = [];
248
+ let i = 0;
249
+ while (out.length < 200_000) {
250
+ const open = xml.indexOf('<si>', i);
251
+ if (open < 0)
252
+ break;
253
+ const close = xml.indexOf('</si>', open);
254
+ if (close < 0)
255
+ break;
256
+ out.push(cell(textRuns(xml.slice(open, close))));
257
+ i = close + 5;
258
+ }
259
+ return out;
260
+ }
261
+ /** Concatenates every `<t>…</t>` in a fragment. Forward-only. */
262
+ function textRuns(fragment) {
263
+ const parts = [];
264
+ let i = 0;
265
+ for (;;) {
266
+ const open = fragment.indexOf('<t', i);
267
+ if (open < 0)
268
+ break;
269
+ const gt = fragment.indexOf('>', open);
270
+ if (gt < 0)
271
+ break;
272
+ // `<t>` and `<t xml:space="preserve">`, but not `<tab/>` or another element
273
+ // whose name merely starts with a t.
274
+ const head = fragment.slice(open + 2, gt);
275
+ if (head !== '' && !head.startsWith(' ') && !head.startsWith('/')) {
276
+ i = gt + 1;
277
+ continue;
278
+ }
279
+ if (fragment[gt - 1] === '/') {
280
+ i = gt + 1;
281
+ continue;
282
+ }
283
+ const close = fragment.indexOf('</t>', gt);
284
+ if (close < 0)
285
+ break;
286
+ parts.push(decodeEntities(fragment.slice(gt + 1, close)));
287
+ i = close + 4;
288
+ }
289
+ return parts.join('');
290
+ }
291
+ function sheetOrder(workbook, rels) {
292
+ if (workbook === undefined)
293
+ return [];
294
+ const targets = new Map();
295
+ if (rels !== undefined) {
296
+ const pattern = /<Relationship\b[^>]*>/g;
297
+ for (let match = pattern.exec(rels); match !== null && targets.size < MAX_SHEETS * 4; match = pattern.exec(rels)) {
298
+ const id = attribute(match[0], 'Id');
299
+ const target = attribute(match[0], 'Target');
300
+ if (id !== undefined && target !== undefined)
301
+ targets.set(id, target);
302
+ }
303
+ }
304
+ const out = [];
305
+ const pattern = /<sheet\b[^>]*>/g;
306
+ for (let match = pattern.exec(workbook); match !== null && out.length < MAX_SHEETS; match = pattern.exec(workbook)) {
307
+ const name = attribute(match[0], 'name');
308
+ const id = attribute(match[0], 'r:id') ?? attribute(match[0], 'id');
309
+ if (name === undefined || id === undefined)
310
+ continue;
311
+ const target = targets.get(id);
312
+ if (target === undefined)
313
+ continue;
314
+ const path = `xl/${target.replace(/^\/?xl\//, '').replace(/^\.\//, '')}`;
315
+ out.push({ name: decodeEntities(name), path });
316
+ }
317
+ return out;
318
+ }
319
+ function worksheetRows(xml, shared, budget) {
320
+ const rows = [];
321
+ let scan = xml.length * CLOSER_SCAN_BUDGET_FACTOR;
322
+ let i = 0;
323
+ let row;
324
+ const finish = (cells) => {
325
+ rows.push(cells);
326
+ budget.left -= cells.reduce((sum, value) => sum + value.length + 1, 0);
327
+ if (budget.left > 0)
328
+ return true;
329
+ budget.clipped = true;
330
+ return false;
331
+ };
332
+ while (i < xml.length && rows.length < MAX_ROWS) {
333
+ const lt = xml.indexOf('<', i);
334
+ if (lt < 0)
335
+ break;
336
+ const gt = xml.indexOf('>', lt);
337
+ if (gt < 0)
338
+ break;
339
+ const tag = xml.slice(lt, gt + 1);
340
+ // `<row>` and `<row r="3">`, not `<rowBreaks>`.
341
+ if (tag === '<row>' || tag.startsWith('<row ')) {
342
+ row = [];
343
+ i = gt + 1;
344
+ continue;
345
+ }
346
+ if (tag === '</row>') {
347
+ if (row !== undefined && !finish(row))
348
+ return rows;
349
+ row = undefined;
350
+ i = gt + 1;
351
+ continue;
352
+ }
353
+ if (!tag.startsWith('<c ') && tag !== '<c>') {
354
+ i = gt + 1;
355
+ continue;
356
+ }
357
+ const selfClosing = tag.endsWith('/>');
358
+ let end = gt + 1;
359
+ let body = '';
360
+ if (!selfClosing) {
361
+ const close = xml.indexOf('</c>', gt);
362
+ if (close < 0 || scan <= 0)
363
+ break;
364
+ scan -= close - gt;
365
+ body = xml.slice(gt + 1, close);
366
+ end = close + 4;
367
+ }
368
+ if (row !== undefined && row.length < MAX_COLS) {
369
+ const column = columnIndex(attribute(tag, 'r'));
370
+ // A cell reference places the value, so a gap in the row stays a gap
371
+ // rather than shifting every later column one to the left.
372
+ if (column !== undefined) {
373
+ while (row.length < Math.min(column, MAX_COLS))
374
+ row.push('');
375
+ }
376
+ row.push(cellValue(tag, body, shared));
377
+ }
378
+ i = end;
379
+ }
380
+ if (row !== undefined)
381
+ finish(row);
382
+ return rows;
383
+ }
384
+ function cellValue(tag, body, shared) {
385
+ const type = attribute(tag, 't');
386
+ if (type === 'inlineStr')
387
+ return cell(textRuns(body));
388
+ if (type === 's') {
389
+ const index = Number(between(body, '<v>', '</v>') ?? '');
390
+ return Number.isInteger(index) ? (shared[index] ?? '') : '';
391
+ }
392
+ const value = between(body, '<v>', '</v>');
393
+ if (value !== undefined)
394
+ return cell(decodeEntities(value));
395
+ return cell(textRuns(body));
396
+ }
397
+ /** `B` → 1, `AA4` → 26. The row part is ignored; the walk supplies it. */
398
+ function columnIndex(reference) {
399
+ if (reference === undefined)
400
+ return undefined;
401
+ const letters = /^([A-Za-z]+)/.exec(reference)?.[1];
402
+ if (letters === undefined || letters.length > 3)
403
+ return undefined;
404
+ let index = 0;
405
+ for (const character of letters.toUpperCase()) {
406
+ index = index * 26 + (character.charCodeAt(0) - 64);
407
+ }
408
+ return index - 1;
409
+ }
410
+ /* -------------------------------------------------------------------- ods -- */
411
+ function readOds(xml, maxChars, toText) {
412
+ const sheets = [];
413
+ const budget = { left: maxChars, clipped: false };
414
+ let i = 0;
415
+ while (sheets.length < MAX_SHEETS) {
416
+ if (budget.left <= 0) {
417
+ budget.clipped = true;
418
+ break;
419
+ }
420
+ const open = xml.indexOf('<table:table ', i);
421
+ if (open < 0)
422
+ break;
423
+ const gt = xml.indexOf('>', open);
424
+ if (gt < 0)
425
+ break;
426
+ const name = decodeEntities(attribute(xml.slice(open, gt + 1), 'table:name') ??
427
+ `Sheet ${sheets.length + 1}`);
428
+ const close = xml.indexOf('</table:table>', gt);
429
+ const body = xml.slice(gt + 1, close < 0 ? xml.length : close);
430
+ sheets.push({ name, rows: odsRows(body, toText, budget) });
431
+ if (close < 0)
432
+ break;
433
+ i = close + 14;
434
+ }
435
+ return { sheets, clipped: budget.clipped };
436
+ }
437
+ function odsRows(xml, toText, budget) {
438
+ const rows = [];
439
+ let i = 0;
440
+ while (i < xml.length && rows.length < MAX_ROWS) {
441
+ const open = xml.indexOf('<table:table-row', i);
442
+ if (open < 0)
443
+ break;
444
+ const close = xml.indexOf('</table:table-row>', open);
445
+ const body = xml.slice(open, close < 0 ? xml.length : close);
446
+ const row = [];
447
+ let j = 0;
448
+ while (row.length < MAX_COLS) {
449
+ const at = body.indexOf('<table:table-cell', j);
450
+ if (at < 0)
451
+ break;
452
+ const gt = body.indexOf('>', at);
453
+ if (gt < 0)
454
+ break;
455
+ const tag = body.slice(at, gt + 1);
456
+ const cellClose = tag.endsWith('/>')
457
+ ? -1
458
+ : body.indexOf('</table:table-cell>', gt);
459
+ // An OpenDocument cell holds `<text:p>`, not the `<t>` of OOXML, so the
460
+ // markup walk does the reading here rather than the run collector.
461
+ // Trimmed, because the markup walk emits a space for every tag it drops
462
+ // and a cell padded with them stops lining up as a column.
463
+ const value = cellClose < 0
464
+ ? ''
465
+ : cell(toText(body.slice(gt + 1, cellClose), MAX_CELL_CHARS)).trim();
466
+ // Clamped, and this is the guard that matters most in this file: every
467
+ // row an office suite writes ends in a cell repeated 16 384 times.
468
+ const repeat = Math.min(Math.max(Number(attribute(tag, 'table:number-columns-repeated') ?? '1') || 1, 1), MAX_REPEAT);
469
+ for (let n = 0; n < repeat && row.length < MAX_COLS; n += 1)
470
+ row.push(value);
471
+ j = cellClose < 0 ? gt + 1 : cellClose + 19;
472
+ }
473
+ // Trailing empty cells are padding, not data.
474
+ while (row.length > 0 && row[row.length - 1] === '')
475
+ row.pop();
476
+ rows.push(row);
477
+ budget.left -= row.reduce((sum, value) => sum + value.length + 1, 0);
478
+ if (budget.left <= 0) {
479
+ budget.clipped = true;
480
+ break;
481
+ }
482
+ if (close < 0)
483
+ break;
484
+ i = close + 18;
485
+ }
486
+ // The same padding, one dimension up.
487
+ while (rows.length > 0 && rows[rows.length - 1].length === 0)
488
+ rows.pop();
489
+ return rows;
490
+ }
491
+ /* ------------------------------------------------------------------- docx -- */
492
+ /**
493
+ * Counts the runs of a Word document, and how many of them a reader would not
494
+ * see: marked hidden, set at two points or below, or coloured white.
495
+ *
496
+ * The same signal the PDF reader reports, for the same reason — the text
497
+ * still goes to the model, and the header says that some of it was placed
498
+ * where a person would not have found it. Forward-only cursors, so a document
499
+ * of a hundred thousand runs with no closing tags costs one pass, not one pass
500
+ * per run.
501
+ */
502
+ function wordRuns(xml) {
503
+ let total = 0;
504
+ let hidden = 0;
505
+ let i = 0;
506
+ let nextProps = xml.indexOf('<w:rPr>');
507
+ let nextPropsEnd = xml.indexOf('</w:rPr>');
508
+ let nextClose = xml.indexOf('</w:r>');
509
+ const advance = (cursor, needle, from) => {
510
+ let at = cursor;
511
+ while (at >= 0 && at < from)
512
+ at = xml.indexOf(needle, at + 1);
513
+ return at;
514
+ };
515
+ for (;;) {
516
+ const open = xml.indexOf('<w:r', i);
517
+ if (open < 0)
518
+ break;
519
+ i = open + 4;
520
+ const next = xml[open + 4];
521
+ // `<w:r>` and `<w:r w:rsidR="…">`, not `<w:rPr>` or `<w:rFonts>`.
522
+ if (next !== '>' && next !== ' ')
523
+ continue;
524
+ total += 1;
525
+ nextProps = advance(nextProps, '<w:rPr>', open);
526
+ nextClose = advance(nextClose, '</w:r>', open);
527
+ if (nextProps < 0 || (nextClose >= 0 && nextClose < nextProps))
528
+ continue;
529
+ nextPropsEnd = advance(nextPropsEnd, '</w:rPr>', nextProps);
530
+ if (nextPropsEnd < 0)
531
+ break;
532
+ if (isHiddenRun(xml.slice(nextProps, nextPropsEnd)))
533
+ hidden += 1;
534
+ }
535
+ return { total, hidden };
536
+ }
537
+ function isHiddenRun(properties) {
538
+ return (properties.includes('<w:vanish') ||
539
+ properties.includes('<w:webHidden') ||
540
+ /<w:color\b[^>]*w:val="(?:ffffff|white)"/i.test(properties) ||
541
+ /<w:sz\b[^>]*w:val="[1-4]"/.test(properties));
542
+ }
543
+ /* ------------------------------------------------------------------ misc -- */
544
+ /**
545
+ * Entries this server will inflate, by kind.
546
+ *
547
+ * An allowlist rather than a denylist, checked before anything is decompressed.
548
+ * Everything an attacker could add to a container — a second archive, a font, a
549
+ * media file, an entry with a hostile name — is simply never read.
550
+ */
551
+ function wanted(kind, name) {
552
+ if (kind === 'docx')
553
+ return name === 'word/document.xml';
554
+ if (kind === 'odt' || kind === 'ods')
555
+ return name === 'content.xml';
556
+ if (kind === 'pptx')
557
+ return /^ppt\/slides\/slide\d+\.xml$/.test(name);
558
+ return (name === 'xl/workbook.xml' ||
559
+ name === 'xl/_rels/workbook.xml.rels' ||
560
+ name === 'xl/sharedStrings.xml' ||
561
+ /^xl\/worksheets\/[^/]+\.xml$/.test(name));
562
+ }
563
+ function isSafeName(name) {
564
+ if (name.startsWith('/') || name.includes('\\'))
565
+ return false;
566
+ if (/^[A-Za-z]:/.test(name))
567
+ return false;
568
+ return !name.split('/').includes('..');
569
+ }
570
+ function slideNumber(name) {
571
+ return Number(/(\d+)\.xml$/.exec(name)?.[1] ?? 0);
572
+ }
573
+ function attribute(tag, name) {
574
+ const pattern = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*"([^"]*)"`);
575
+ return pattern.exec(tag)?.[1];
576
+ }
577
+ function between(source, open, close) {
578
+ const start = source.indexOf(open);
579
+ if (start < 0)
580
+ return undefined;
581
+ const end = source.indexOf(close, start + open.length);
582
+ if (end < 0)
583
+ return undefined;
584
+ return source.slice(start + open.length, end);
585
+ }
586
+ /**
587
+ * A cell value: no tab or newline, because either would end the column or the
588
+ * row it is in and the reader has no way to tell that from real structure; and
589
+ * no more than {@link MAX_CELL_CHARS}, because a cell is a value.
590
+ */
591
+ function cell(value) {
592
+ const flat = value.replace(/[\t\r\n]+/g, ' ');
593
+ return (flat.length > MAX_CELL_CHARS ? flat.slice(0, MAX_CELL_CHARS) : flat).toWellFormed();
594
+ }
595
+ /**
596
+ * The five predefined XML entities and bounded numeric references.
597
+ *
598
+ * Deliberately nothing else. There is no entity table here, so a document that
599
+ * declares `<!ENTITY lol …>` gets its `&lol;` back as five literal characters —
600
+ * which is what makes billion-laughs and `SYSTEM "file:///etc/passwd"` non-events
601
+ * rather than defended-against attacks.
602
+ */
603
+ function decodeEntities(value) {
604
+ return value.replace(ENTITY, (match, hex, decimal, name) => hex !== undefined
605
+ ? codePoint(parseInt(hex, 16))
606
+ : decimal !== undefined
607
+ ? codePoint(Number(decimal))
608
+ : (PREDEFINED.get(String(name)) ?? match));
609
+ }
610
+ /**
611
+ * One alternation, one pass — the same shape as `decodeCharacterReferences`
612
+ * in `../analyze.ts`, and for the same reason: a sequence of `replace` calls
613
+ * decodes `&#x26;#104;` twice, and a bounded digit run leaves `&#0000000104;`
614
+ * standing where an XML parser reads an `h`. Duplicated rather than shared
615
+ * because this module is reached from the child, where a relative import of
616
+ * `../analyze.js` does not resolve.
617
+ */
618
+ const ENTITY = /&(?:#[xX]([0-9a-fA-F]+);|#([0-9]+);|(lt|gt|quot|amp|apos);)/g;
619
+ const PREDEFINED = new Map([
620
+ ['lt', '<'],
621
+ ['gt', '>'],
622
+ ['quot', '"'],
623
+ ['amp', '&'],
624
+ ['apos', "'"],
625
+ ]);
626
+ /** One character from a numeric reference, U+FFFD where no parser has one. */
627
+ function codePoint(value) {
628
+ if (!Number.isInteger(value) || value < 1 || value > 0x10ffff)
629
+ return String.fromCodePoint(0xfffd);
630
+ if (value >= 0xd800 && value <= 0xdfff)
631
+ return String.fromCodePoint(0xfffd);
632
+ return String.fromCodePoint(value);
633
+ }
634
+ //# sourceMappingURL=ooxml.js.map
@@ -0,0 +1,62 @@
1
+ import type { ExtractResponse } from './types.js';
2
+ /**
3
+ * How far one compressed stream may expand, and how far all of them may
4
+ * together, before the document is refused without a parser seeing it.
5
+ *
6
+ * This is the memory guard for PDFs, and it has to live here because nothing
7
+ * else bounds it. PDF.js inflates a stream into memory in full, and that memory
8
+ * is a typed array — external to the V8 heap, so no heap limit applies to it.
9
+ * Measured: a 3.4 MB file whose one content stream inflates to 1 GB took the
10
+ * process to 2.1 GB of resident memory within a second, and the extraction
11
+ * timeout only decides when that stops growing, not how far it gets. Deflate
12
+ * reaches roughly 1000:1 on repetitive input, so the 10 MB the size limit
13
+ * admits by default could stand for 10 GB.
14
+ *
15
+ * Every honest document is far below both numbers: a content stream is tens of
16
+ * kilobytes to a few megabytes, an embedded font a few hundred kilobytes.
17
+ */
18
+ export declare const MAX_STREAM_BYTES: number;
19
+ export declare const MAX_TOTAL_STREAM_BYTES: number;
20
+ /**
21
+ * Reads the text layer of a PDF.
22
+ *
23
+ * The API surface used here is deliberately five calls wide — `getDocument`,
24
+ * `getPage`, `getTextContent`, `view`, `destroy` — and it must stay that way.
25
+ * `getJSActions` surfaces the document's own JavaScript, `getAttachments`
26
+ * returns embedded files (a PDF can carry an executable past `sniffContent`,
27
+ * which only ever looks at the outer `%PDF`), and `getAnnotations` carries
28
+ * actions and URIs. None of them is needed to read text, and each is a door.
29
+ *
30
+ * Nothing here reaches the network, and that is worth stating because it is one
31
+ * careless line away from being false. unpdf only sets `standardFontDataUrl`
32
+ * and `cMapUrl` when it can resolve `pdfjs-dist`, which is not a dependency of
33
+ * this package, so both stay unset; the document is passed as bytes, never as a
34
+ * URL, so pdf.js never constructs its network stream. **Do not "fix" a missing
35
+ * font or CMap by pointing either option at a CDN.** It is the most-suggested
36
+ * workaround on the internet, and it would hand this server its first outbound
37
+ * HTTP client — the one property SECURITY.md is built on. The cost of leaving
38
+ * them unset is known and accepted: a PDF whose text needs a predefined CJK
39
+ * CMap does not extract.
40
+ */
41
+ export declare function extractPdf(bytes: Uint8Array, maxChars: number): Promise<ExtractResponse>;
42
+ /**
43
+ * Whether the document's compressed streams expand past the limits above.
44
+ *
45
+ * One forward pass over the file, finding every `stream … endstream` and
46
+ * decoding the ones whose filter can grow: Flate, LZW and RunLength, each
47
+ * optionally behind an ASCIIHex or ASCII85 wrapper. The decoders stop the
48
+ * moment they cross the remaining budget, so the work this costs is bounded by
49
+ * the budget plus one stream, whatever the file holds — and a decoder that
50
+ * hits a corrupt byte counts what it produced up to there, which is also what
51
+ * pdf.js would have got.
52
+ *
53
+ * The data is delimited the way pdf.js delimits it: by `/Length` where the
54
+ * number is direct or a resolvable reference and actually lands on
55
+ * `endstream`, and by the first `endstream` otherwise. Using only the keyword
56
+ * would let a sender end the scan early by writing the word inside their own
57
+ * compressed bytes.
58
+ *
59
+ * Image codecs — DCT, JPX, JBIG2, CCITT — are not decoded here, because text
60
+ * extraction never decodes them either.
61
+ */
62
+ export declare function expandsTooFar(bytes: Uint8Array): Promise<boolean>;