@patrickdk77/pdf-defuse 0.1.2 → 0.2.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/dist/walker.js CHANGED
@@ -307,6 +307,8 @@ class Walker {
307
307
  inlinePages = new Map();
308
308
  /** Objects first reached as file specifications, which stay file specifications whatever their content says. */
309
309
  fileRole = new Set();
310
+ /** Page content streams, which pdf.js draws whatever their /Type says. */
311
+ contentRole = new Set();
310
312
  /** Objects listed in /Annots and kept there though they are not annotations, which get the annotation rules. */
311
313
  annotRole = new Set();
312
314
  /** Objects that only removed content refers to, which are not unreferenced. */
@@ -829,6 +831,11 @@ class Walker {
829
831
  if (slot)
830
832
  this.inlinePages.set(slot, { index, box });
831
833
  }
834
+ const contents = own?.get('Contents');
835
+ const parts = contents instanceof objects_1.PdfRef ? await this.doc.getObject(contents) : contents;
836
+ for (const c of Array.isArray(parts) ? parts : [contents])
837
+ if (c instanceof objects_1.PdfRef)
838
+ this.contentRole.add(c.num);
832
839
  // Many pages can share one /Annots array, or inherit one. Each array is keyed by the owner and path the walk
833
840
  // reaches it at: the last reference that leads to it, or the dictionary that holds it.
834
841
  const annots = await read('Annots');
@@ -1057,7 +1064,8 @@ class Walker {
1057
1064
  this.strippedMeta.has(num) ||
1058
1065
  filterRefs.has(num) ||
1059
1066
  doc.encryptRefs.has(num) ||
1060
- doc.encryptRef?.num === num)
1067
+ doc.encryptRef?.num === num ||
1068
+ doc.issues.objStmType.has(num))
1061
1069
  continue;
1062
1070
  doc.checkTime();
1063
1071
  let obj;
@@ -1122,7 +1130,8 @@ class Walker {
1122
1130
  if (!(x instanceof objects_1.PdfRef))
1123
1131
  continue;
1124
1132
  const s = await this.doc.getObject(x);
1125
- if (s instanceof objects_1.PdfStream && classify(s) !== 'structural')
1133
+ // A stream that cannot be decrypted is not written, so it adds nothing.
1134
+ if (s instanceof objects_1.PdfStream && classify(s) !== 'structural' && this.doc.canDecrypt(s, x.num))
1126
1135
  total += await this.doc.plainLength(s, x.num);
1127
1136
  }
1128
1137
  if (arrayNum !== undefined)
@@ -1181,8 +1190,18 @@ class Walker {
1181
1190
  const info = d;
1182
1191
  if (special === 'info' && ['Author', 'Creator', 'Producer', 'Title', 'Subject', 'Keywords'].some(k => info.has(k)) && this.aggregate(types_1.PdfCategory.Metadata, types_1.PdfDetail.InfoDictionary).action === 'strip')
1183
1192
  return undefined;
1184
- // A stream decided as a contained file stays one, whether or not it says /Type /EmbeddedFile.
1185
- const kind = obj instanceof objects_1.PdfStream && this.fileDecisions.has(num) ? 'efStream' : this.kindOf(obj, num, await this.actionType(d));
1193
+ // A stream decided as a contained file stays one, whether or not it says /Type /EmbeddedFile, unless it is a page's
1194
+ // content: only the reference that made it a contained file goes then.
1195
+ let kind = obj instanceof objects_1.PdfStream && this.fileDecisions.has(num) ? 'efStream' : this.kindOf(obj, num, await this.actionType(d));
1196
+ if (kind === 'efStream' && this.contentRole.has(num))
1197
+ kind = 'generic';
1198
+ // Nothing reads a stream encrypted with a key the password does not give, and pdf.js draws a page without it.
1199
+ // An attached file reports its own removal.
1200
+ if (obj instanceof objects_1.PdfStream && kind !== 'efStream' && kind !== 'structural' && !this.doc.canDecrypt(obj, num)) {
1201
+ if (this.phase === 'A' && !ctx.shadow)
1202
+ this.emitRemoval(types_1.PdfCategory.Encrypted, types_1.PdfDetail.NoKey, ctx.location);
1203
+ return undefined;
1204
+ }
1186
1205
  switch (kind) {
1187
1206
  case 'structural':
1188
1207
  this.doc.issues.structuralRefs++;
@@ -1509,7 +1528,8 @@ class Walker {
1509
1528
  for (const [k, v] of d.entries()) {
1510
1529
  const p = `${path}/${k}`;
1511
1530
  taken.add(k);
1512
- if (k === 'Annots') {
1531
+ // A null /Annots or /Contents is the same as none. The writer puts null where a reference led to no object.
1532
+ if (k === 'Annots' && v !== null) {
1513
1533
  let x;
1514
1534
  if (Array.isArray(v))
1515
1535
  x = await this.handleAnnotsArray(v, pctx, owner, p);
@@ -1534,7 +1554,7 @@ class Walker {
1534
1554
  if (x !== undefined)
1535
1555
  out.set(k, x);
1536
1556
  }
1537
- else if (k === 'Contents' && !node && !(v instanceof objects_1.PdfRef) && !Array.isArray(v)) {
1557
+ else if (k === 'Contents' && !node && v !== null && !(v instanceof objects_1.PdfRef) && !Array.isArray(v)) {
1538
1558
  const f = this.emit(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, `${pctx.location} content`);
1539
1559
  if (f.action !== 'strip') {
1540
1560
  const x = await this.tv(v, pctx, owner, p);
@@ -1884,7 +1904,7 @@ class Walker {
1884
1904
  // Only the first 4096 characters of a label are read. Many links can share one chain and one label, so the
1885
1905
  // outcome is kept per chain object and label.
1886
1906
  const labels = shownLabels.map(t => t?.slice(0, 4096));
1887
- const key = v instanceof objects_1.PdfRef ? `${v.num}:${(0, node_crypto_1.createHash)('md5').update(JSON.stringify(labels)).digest('hex')}` : undefined;
1907
+ const key = v instanceof objects_1.PdfRef ? `${v.num}:${(0, node_crypto_1.createHash)('sha256').update(JSON.stringify(labels)).digest('hex')}` : undefined;
1888
1908
  let found = key === undefined ? undefined : this.labelChecks.get(key);
1889
1909
  if (found === undefined) {
1890
1910
  found = null;
@@ -2614,12 +2634,17 @@ class Walker {
2614
2634
  const record = (keep, replacement) => {
2615
2635
  this.fileDecisions.set(ref.num, { keep, replacement, types });
2616
2636
  // A stream something else reached first and kept goes too.
2617
- if (!keep && this.visited.has(ref.num))
2637
+ if (!keep && this.visited.has(ref.num) && !this.contentRole.has(ref.num))
2618
2638
  this.dropped.add(ref.num);
2619
2639
  return keep;
2620
2640
  };
2621
2641
  if (ctx.shadow)
2622
2642
  return false;
2643
+ // No plugin can read a file whose key the password did not give, so it goes whatever the overrides say.
2644
+ if (!this.doc.canDecrypt(st, ref.num)) {
2645
+ this.emitRemoval(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, loc, { name: name ?? '', reason: 'encrypted' });
2646
+ return record(false);
2647
+ }
2623
2648
  const threshold = this.options.memoryThreshold ?? 8 * 1024 * 1024;
2624
2649
  // The first bytes alone stay in memory, however low the threshold.
2625
2650
  const decoded = new io_1.SpillSink(this.cfg.temp, whole ? threshold : Number.POSITIVE_INFINITY);
@@ -2901,6 +2926,7 @@ class Walker {
2901
2926
  this.chainSites.clear();
2902
2927
  this.scriptFree.clear();
2903
2928
  this.fileRole.clear();
2929
+ this.contentRole.clear();
2904
2930
  this.inlinePages.clear();
2905
2931
  return this.doc.release();
2906
2932
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/pdf-defuse",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Sanitize and clean PDF files. Detects and removes JavaScript, attached files, launch actions, unsafe links and empty-password encryption, scores the risk, and writes a disarmed copy with bounded memory and no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "patrickdk77",
@@ -25,7 +25,7 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "node": ">=22.0.0"
28
+ "node": ">=18.0.0"
29
29
  },
30
30
  "scripts": {
31
31
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('build-test',{recursive:true,force:true})\"",
@@ -74,7 +74,7 @@
74
74
  "@cantoo/pdf-lib": "^2.11.1",
75
75
  "@types/chai": "^5.2.3",
76
76
  "@types/mocha": "^10.0.10",
77
- "@types/node": "^22.20.5",
77
+ "@types/node": "^18.19.130",
78
78
  "chai": "^6.3.0",
79
79
  "husky": "^9.1.7",
80
80
  "lint-staged": "^17.6.0",