@patrickdk77/pdf-defuse 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -69,7 +69,7 @@ Removed by default:
69
69
  - Actions that reach outside the document: launching programs, links to other or embedded files, form submit and import, show and hide toggles, layer toggles, menu commands and media.
70
70
  - Links other than http, https and mailto, and links built to mislead. That covers credentials before the host, IP hosts, punycode or mixed-script hosts, encoded hosts, tooltips or alternate text that name a different site, links and push buttons that cover most of a page, and relative links with no base address.
71
71
  - Attached files, portfolios, XFA forms, slideshows, media and 3D annotations, and annotation types outside an allowlist.
72
- - Encryption with an empty password, or with the password you pass.
72
+ - Encryption with an empty password, or with a password you pass.
73
73
  - Earlier revisions, unreferenced objects and damaged structure. The output is one clean revision, unless it is a signed file that keeps its bytes, as described below.
74
74
 
75
75
  Kept: text, images, fonts, layout, bookmarks, form fields and their values, safe links and metadata.
@@ -121,7 +121,7 @@ A run that stops before it has seen the whole file rejects the file with a null
121
121
 
122
122
  | Option | Default | Purpose |
123
123
  |---|---|---|
124
- | `password` | `''` | Tried as the user password, then as the owner password, then as the empty password. See Passwords below |
124
+ | `password` | `''` | One password or a list. pdf-defuse tries each in order, as the user password and then as the owner password, and the empty password last. See Passwords below |
125
125
  | `limits` | none | `fileSize`, `objects`, `decompressedBytes`, `nestingDepth` and `timeMs`. A limit left out is not enforced |
126
126
  | `stripMetadata` | `false` | Remove the document information dictionary wherever it is referenced, and the XMP metadata of every object: the catalog, pages, images and the rest |
127
127
  | `actionOverrides` | none | Change the action for a category, or for a category and detail |
@@ -139,11 +139,13 @@ await disarmPdf(bytes, { actionOverrides: [{ category: 'JAVASCRIPT', action: 're
139
139
 
140
140
  ### Passwords
141
141
 
142
+ `password` takes one password or a list, because one upload can hold files with different passwords, such as a ZIP with one password and a PDF inside it with another. pdf-defuse tries each entry in order, as the user password and then as the owner password, and stops at the first that opens the file. The empty password comes after the list, so a file that opens without a password opens whatever the list holds. ENCRYPTED/USER_PASSWORD or ENCRYPTED/OWNER_PASSWORD says which kind of password opened the file. `pdfPlugin()` gives every attached PDF the list of the run that found it, and `pdfPlugin(options)` gives it the list in `options`.
143
+
142
144
  A revision 6 password, the AES-256 scheme of ISO 32000-2, is tried three ways: prepared with SASLprep, as that standard requires, prepared the way pdf.js prepares it, and as typed. The last opens files from tools that skip SASLprep. Other revisions use the password as typed.
143
145
 
144
146
  An empty owner password opens a file, as in qpdf, and ENCRYPTED/OWNER_PASSWORD reports it. pdf.js asks for a password there.
145
147
 
146
- A file that encrypts only its attached files, through a crypt filter with `/AuthEvent /EFOpen`, opens without the password, as in pdf.js, and ENCRYPTED/ATTACHMENTS_ONLY reports it. pdf-defuse removes the attached files the password does not decrypt and any other stream under their key, which ENCRYPTED/NO_KEY reports. pdf.js draws such a page blank.
148
+ A file that encrypts only its attached files, through a crypt filter with `/AuthEvent /EFOpen`, opens without a password, as in pdf.js, and ENCRYPTED/ATTACHMENTS_ONLY reports it. pdf-defuse removes the attached files that no password you pass decrypts, and any other stream under their key, which ENCRYPTED/NO_KEY reports. pdf.js draws such a page blank.
147
149
 
148
150
  ## Plugins
149
151
 
@@ -268,7 +270,7 @@ The `fileSize`, `objects` and `decompressedBytes` limits apply to one file and h
268
270
  pdf-defuse inspect <file> [options]
269
271
  pdf-defuse defuse <in> <out> [options]
270
272
 
271
- --password-file <path> read the password from a file, or set PDF_DEFUSE_PASSWORD
273
+ --password-file <path> read passwords from a file, one per line; PDF_DEFUSE_PASSWORD holds one
272
274
  --plugin <module> load plugins from a file or an installed package, repeatable
273
275
  --config <module> load a whole options object, repeatable; the other options apply on top
274
276
  --keep-type <type> keep attached files of this MIME type or file extension unchanged, repeatable
@@ -286,6 +288,8 @@ Use `-` for stdin or stdout. The tool copies stdin to a temporary file in `tempD
286
288
 
287
289
  The text report shows each control character as an escape such as `\u001b`. The JSON report escapes DEL and the C1 range too, which JSON itself does not require. A field name or a title from the PDF can hold an escape sequence that rewrites what the terminal shows, the verdict included.
288
290
 
291
+ `--password-file` reads one password per line. The tool drops a carriage return at the end of a line and skips blank lines, since the empty password is always tried. Spaces belong to the password. `PDF_DEFUSE_PASSWORD` holds one password, and a line break in it is part of that password. `--password-file` replaces it, and either one replaces a `password` that a `--config` module sets.
292
+
289
293
  `objects`, `nestingDepth` and `timeMs` take whole numbers. `fileSize` and `decompressedBytes` take sizes. Any other value is a usage error.
290
294
 
291
295
  A `--plugin` module exports `plugins`, an array, or `plugin`, a single plugin. Either can also sit on the default export, and a default export that is itself a plugin or an array of them works too. ES modules, CommonJS files and installed packages all load. Package names resolve from the working directory, and a package that exports only an `import` condition loads as well, through a subpath pattern such as `"./*"` too. `--plugin keep.js` loads `./keep.js` when the working directory has that file, and the installed package `keep.js` when it does not.
package/dist/cli.js CHANGED
@@ -50,7 +50,7 @@ const USAGE = `Usage:
50
50
  pdf-defuse defuse <in> <out> [options]
51
51
 
52
52
  Options:
53
- --password-file <path> read the password from a file, or set PDF_DEFUSE_PASSWORD
53
+ --password-file <path> read passwords from a file, one per line; PDF_DEFUSE_PASSWORD holds one
54
54
  --plugin <module> load plugins from a file or an installed package, repeatable
55
55
  --config <module> load a whole options object, repeatable; the other options apply on top
56
56
  --keep-type <type> keep contained files of this MIME type or file extension unchanged, repeatable
@@ -181,7 +181,11 @@ async function parse(argv) {
181
181
  };
182
182
  switch (a) {
183
183
  case '--password-file':
184
- password = (await fsp.readFile(next(), 'utf8')).replace(/\r?\n$/, '');
184
+ // The empty password is always tried, so a blank line adds nothing.
185
+ password = (await fsp.readFile(next(), 'utf8'))
186
+ .split('\n')
187
+ .map(line => line.replace(/\r$/, ''))
188
+ .filter(line => line !== '');
185
189
  break;
186
190
  case '--plugin':
187
191
  ordered.push(...pluginsFrom(await loadModule(next())));
@@ -61,7 +61,7 @@ interface DocumentIssues {
61
61
  memoryFallback: string[];
62
62
  }
63
63
  interface OpenOptions {
64
- password?: string;
64
+ password?: string | readonly string[];
65
65
  decompressedBytes?: number;
66
66
  objectLimit?: number;
67
67
  deadline?: number;
package/dist/document.js CHANGED
@@ -681,11 +681,16 @@ class PdfDocument {
681
681
  this.encryptRefs.add(num);
682
682
  const id = this.trailer.get('ID');
683
683
  const id0 = Array.isArray(id) && id[0] instanceof objects_1.PdfString ? id[0].bytes : new Uint8Array(0);
684
- const password = this.opts.password ?? '';
685
- let r = crypto_1.SecurityHandler.open(dict, id0, password);
686
- // A password meant for other files must not lock out one that opens without a password.
687
- if (r.status === 'password-required' && password !== '')
688
- r = crypto_1.SecurityHandler.open(dict, id0, '');
684
+ const given = this.opts.password ?? '';
685
+ // A password meant for other files must not lock out one that opens without a password, so the empty one comes last.
686
+ const passwords = new Set([...(typeof given === 'string' ? [given] : given), '']);
687
+ let r = { status: 'password-required' };
688
+ for (const password of passwords) {
689
+ this.checkTime();
690
+ r = crypto_1.SecurityHandler.open(dict, id0, password);
691
+ if (r.status !== 'password-required')
692
+ break;
693
+ }
689
694
  this.securityResult = r;
690
695
  if (r.status === 'ok')
691
696
  this.security = r.handler;
package/dist/objects.js CHANGED
@@ -169,10 +169,5 @@ function encodeTextString(text) {
169
169
  // biome-ignore lint/suspicious/noControlCharactersInRegex: the range PDFDocEncoding keeps as is starts with control codes.
170
170
  if (/^[\x00-\x17\x20-\x7e]*$/.test(text))
171
171
  return Buffer.from(text, 'latin1');
172
- const out = Buffer.alloc(2 + text.length * 2);
173
- out[0] = 0xfe;
174
- out[1] = 0xff;
175
- for (let i = 0; i < text.length; i++)
176
- out.writeUInt16BE(text.charCodeAt(i), 2 + i * 2);
177
- return out;
172
+ return Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from(text, 'utf16le').swap16()]);
178
173
  }
package/dist/sniff.js CHANGED
@@ -95,7 +95,14 @@ function family(mime) {
95
95
  * of "page.html." is html.
96
96
  */
97
97
  function extensionOf(name) {
98
- return /\.([^./\\]+)$/.exec((name ?? '').replace(/[. ]+$/, ''))?.[1].toLowerCase();
98
+ // A loop and lastIndexOf, since /[. ]+$/ backtracks: a name of 40,000 dots took seconds to match.
99
+ const n = name ?? '';
100
+ let end = n.length;
101
+ while (end > 0 && (n[end - 1] === '.' || n[end - 1] === ' '))
102
+ end--;
103
+ const dot = n.lastIndexOf('.', end - 1);
104
+ const ext = dot < 0 ? '' : n.slice(dot + 1, end);
105
+ return ext && !/[/\\]/.test(ext) ? ext.toLowerCase() : undefined;
99
106
  }
100
107
  function typeFromName(name) {
101
108
  const ext = extensionOf(name);
package/dist/types.d.ts CHANGED
@@ -269,8 +269,11 @@ export interface PdfScoreBands {
269
269
  critical: number;
270
270
  }
271
271
  export interface PdfOptions {
272
- /** Default '', tried as the user password and then as the owner password. */
273
- password?: string;
272
+ /**
273
+ * Default ''. Each password is tried in order, as the user password and then as the owner password. The empty
274
+ * password comes after them, so a file that opens without a password opens whatever the list holds.
275
+ */
276
+ password?: string | string[];
274
277
  /** A limit left out is not enforced. */
275
278
  limits?: PdfLimits;
276
279
  /** Default false. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/pdf-defuse",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",