@patrickdk77/pdf-defuse 0.2.3 → 0.3.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/README.md CHANGED
@@ -67,12 +67,24 @@ Removed by default:
67
67
 
68
68
  - JavaScript wherever it lives: document scripts, open actions, page, annotation and field triggers, links, bookmarks, chained actions and media actions.
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
- - 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
- - Attached files, portfolios, XFA forms, slideshows, media and 3D annotations, and annotation types outside an allowlist.
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 whose path is written to read as another path.
71
+ - Attached files, portfolios, slideshows, media and 3D annotations, and annotation types outside an allowlist.
72
+ - From XFA forms, the scripts no plugin keeps, submits, web service calls, data connections, external objects and anything that names a file or address outside the PDF. The form itself stays, as XFA forms below describes.
72
73
  - Encryption with an empty password, or with a password you pass.
73
74
  - 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
75
 
75
- Kept: text, images, fonts, layout, bookmarks, form fields and their values, safe links and metadata.
76
+ Kept: text, images, fonts, layout, bookmarks, form fields and their values, XFA forms and their data, safe links, relative links and metadata.
77
+
78
+ A relative link is an ordinary way to point at a file beside the PDF, so it stays and LINK/RELATIVE reports it as `info`. Climbing with `..` from the start, as `../manual/page.html` does, is ordinary too. LINK/PATH_TRICK removes a relative link whose path is written to read as another path, and its `reason` says which of these it found:
79
+
80
+ - a control character, which readers drop before they read the address;
81
+ - a backslash, which browsers read as a slash;
82
+ - an encoded dot, slash, backslash or NUL;
83
+ - a segment of only dots and spaces, which Windows reads as `..` or as nothing;
84
+ - a path that names a folder and then climbs above where it started;
85
+ - a colon in the first segment, which reads as a scheme once the address is decoded, as `%6Aavascript:` does.
86
+
87
+ The catalog's `/URI /Base` stays in the output, so a relative link resolved against a hostile base gets the base's verdict. A link given as a name has two readings, `/x` and `x`, and both must pass. Override LINK/RELATIVE to `strip` to remove relative links as well.
76
88
 
77
89
  Every file that is not rejected is rewritten, so the output holds only what pdf-defuse read. That includes a file with nothing to remove, whose status stays `clean`.
78
90
 
@@ -86,6 +98,23 @@ A rewrite invalidates a signature, so a signed file can keep its bytes instead.
86
98
 
87
99
  Before anything reaches your sink, pdf-defuse inspects its own output. It rejects the file if anything removable is left, if the page count changed, or if any page's content changed length.
88
100
 
101
+ ## XFA forms
102
+
103
+ An XFA form keeps its layout, its fields, its data and the scripts a plugin keeps. pdf-defuse reads the form's XML from the `/XFA` stream or packet array and removes everything else that can act:
104
+
105
+ - Every `<script>` in the template and form packets goes to the script plugins with `form: 'xfa'` and a `language` of `javascript` or `formcalc`. XFA reads a script with no content type as FormCalc, and so does pdf-defuse. The trigger is `xfa-` and the event, such as `xfa-click`, `xfa-calculate` or `xfa-script-object`, and `fieldName` holds the SOM path of the node the script belongs to. JAVASCRIPT/XFA reports each script. pdf-defuse empties a script no plugin keeps and leaves its element in place. A script in another language, or one with markup inside it, goes whatever the overrides say.
106
+ - `<submit>` goes as ACTION/SUBMIT_FORM, `<execute>` as FORM/XFA_EXECUTE and `<exObject>` as FORM/XFA_EXTERNAL_OBJECT. `<connect>` goes as FORM/XFA_CONNECTION, and so does every connection in the connectionSet but the schema's `xsdConnection`.
107
+ - FORM/XFA_EXTERNAL_REFERENCE removes what points outside the PDF. That is an `href` holding an address or an absolute path, a `usehref` to another file, the `url` and `server` attributes of the template, the schema connection's `<uri>`, and the config's `uri`, `submitUrl`, `xsl`, `outputXSL` and `base`. In government forms these are mostly paths on the designer's own machine. A relative image name such as `.\logo.png` or `..\images\seal.png` stays unless it is written to read as another path, by the rules LINK/PATH_TRICK uses, and such a name goes as FORM/XFA_EXTERNAL_REFERENCE. XFA names are Windows paths, so a backslash there separates folders.
108
+ - A rich text link, `<a href>`, gets the checks a link action gets and the same LINK findings, so a relative one stays. An unsafe link loses its `href` and keeps its text.
109
+ - An inline image goes to the file plugins the way an attached file does, whether it sits in the template, the form packet or a data value with an image type. Viewers decode these images with decoders of their own, and CVE-2010-0188 reached one with a crafted TIFF. FORM/XFA_IMAGE removes an image no plugin takes. FORM/PLUGIN_PASSED, PLUGIN_SCRUBBED, PLUGIN_REMOVED and PLUGIN_FAILED report what a plugin did with one.
110
+ - pdf-defuse keeps the template, datasets, config, localeSet, connectionSet, form and xmpmeta packets, and drops xmpmeta under `stripMetadata`. Every other packet goes as FORM/XFA_PACKET, stylesheet, pdf, xfdf, sourceSet and signature among them. A packet stream left empty leaves the packet array, name and all.
111
+
112
+ pdf-defuse writes each changed packet stream back compressed and keeps the catalog's `/NeedsRendering`, so a dynamic form still draws from its XFA. A changed template no longer matches the form packet's checksum, so a viewer merges the template and the data again. The values a user entered live in the data and stay.
113
+
114
+ The reader is strict. The XML must be UTF-8 and well formed, under one `xdp` root element. A DOCTYPE, an entity other than the five XML defines, or anything else it cannot read exactly as a viewer would removes the whole form as FORM/XFA_UNREADABLE, whatever the overrides say. A packet array that holds anything but streams goes the same way. The reader takes the whole form into memory, so `memoryThreshold` does not apply to it. `limits.decompressedBytes` caps each packet.
115
+
116
+ FORM/XFA is `info` and only reports the form. Override it to `strip` to remove every XFA form whole, as 0.2.3 did. `/NeedsRendering` goes with it.
117
+
89
118
  ## Findings
90
119
 
91
120
  Every finding has two enums and a string. `category` is the kind, such as `ENCRYPTED` or `JAVASCRIPT`. `detail` is the exact variant, such as `EMPTY_PASSWORD` or `OPEN_ACTION`. `description` is a plain sentence that is safe to show the uploader. A description can name the attached file it is about. pdf-defuse cuts that name to 60 characters and replaces control and direction characters with `?`, and `data` keeps the name whole.
@@ -95,7 +124,7 @@ Every finding has two enums and a string. `category` is the kind, such as `ENCRY
95
124
  { category: 'ENCRYPTED', detail: 'RC4_40', description: 'Encryption is RC4 with a 40-bit key', action: 'info' }
96
125
  ```
97
126
 
98
- `action` says what happens: `reject` refuses the file, `strip` removes the content and `info` only reports it. An override to `strip` removes the content for METADATA/XMP, METADATA/INFO_DICTIONARY, LINK/SAFE and EMBEDDED_FILE/TYPE_MISMATCH. Some `info` findings name content that every rewrite drops anyway, such as SIGNATURE/USAGE_RIGHTS, STRUCTURE/ESCAPED_NAMES and the ENCRYPTED findings that name the algorithm. A `strip` override on one of those changes only the status. The other `info` findings, such as FORM/FIELDS, SIGNATURE/SIGNED, STRUCTURE/VERSION_UPGRADED and the PLUGIN_PASSED and PLUGIN_SCRUBBED findings, name nothing that can be removed. A `strip` override on one of them leaves the finding in the output, so the output fails its own inspection and the upload is rejected.
127
+ `action` says what happens: `reject` refuses the file, `strip` removes the content and `info` only reports it. An override to `strip` removes the content for METADATA/XMP, METADATA/INFO_DICTIONARY, LINK/SAFE, LINK/RELATIVE and EMBEDDED_FILE/TYPE_MISMATCH. Some `info` findings name content that every rewrite drops anyway, such as SIGNATURE/USAGE_RIGHTS, STRUCTURE/ESCAPED_NAMES and the ENCRYPTED findings that name the algorithm. A `strip` override on one of those changes only the status. The other `info` findings, such as FORM/FIELDS, SIGNATURE/SIGNED, STRUCTURE/VERSION_UPGRADED and the PLUGIN_PASSED and PLUGIN_SCRUBBED findings, name nothing that can be removed. A `strip` override on one of them leaves the finding in the output, so the output fails its own inspection and the upload is rejected.
99
128
 
100
129
  Some content goes whatever an override says. Its finding then shows `strip`, or `reject` if you override it to that. This covers a script reference an action loses under the re-checking rules described under Plugins, a reference to an object stream or cross-reference stream, and an attached file a plugin removed or failed on, or that the password does not decrypt. It also covers any other stream encrypted with a key the password does not give, which ENCRYPTED/NO_KEY reports. It also covers a reference in `/Annots` to an object that already has another role, and a reference to a file specification that is the catalog, a page, or the names or information dictionary and carries an embedded file. Such an object keeps its role and its own entries, `/EF` included, so that embedded file stays in the output. Only the reference that treated the object as a file specification goes.
101
130
 
@@ -111,6 +140,8 @@ The score runs from 0 to 100. Each kind of finding has a weight: a launch action
111
140
 
112
141
  `scoreWeights` comes first, then the built-in weights. A kind of finding pdf-defuse does not know, such as one another package's plugin returned, scores at its `weight`, or 0 without one.
113
142
 
143
+ A script or file a plugin answered for scores only by the findings the plugin returned, at their weights. pdf-defuse adds nothing of its own for it, and the PLUGIN_PASSED and PLUGIN_SCRUBBED findings that record the answer weigh 0, in the upload's score and in the output's. A form whose scripts a plugin keeps, and in which the plugin finds nothing, scores 0. With no plugin to judge them, the same scripts score at pdf-defuse's own weights.
144
+
114
145
  The bands are NONE at 0, LOW from 1, MEDIUM from 25, HIGH from 50 and CRITICAL from 80.
115
146
 
116
147
  A run that stops before it has seen the whole file rejects the file with a null score and the UNKNOWN level. Runs stop at a limit, at encryption they cannot open, and at a file they cannot parse or that ends before its trailer. A file with no page to read gets the same result. This holds whatever `actionOverrides` say, because an override can make the finding that stopped the run look harmless. A file rejected for what it holds, for example JavaScript under an override that rejects it, keeps its score. So does a file whose output failed its own inspection.
@@ -128,7 +159,7 @@ A run that stops before it has seen the whole file rejects the file with a null
128
159
  | `scoreWeights`, `scoreBands` | built in | Change the scoring |
129
160
  | `filePlugins`, `scriptPlugins` | none | Keep chosen attached files or scripts |
130
161
  | `tempDir` | `os.tmpdir()` | Where temporary files go |
131
- | `memoryThreshold` | 8 MiB | `disarmPdfSource` keeps its copy of the upload in memory up to this size and in a temporary file above it. Each decoded attachment above this size goes through a temporary file. Decoded object streams share this much memory, and the ones that do not fit go to temporary files, so each is decoded once. A decoded attachment's file is deleted once its plugins have decided it. Scrubbed attachment copies waiting for the write, and an attachment decoded again so a plugin can check its further names, share this much memory between them. One that does not fit goes to a temporary file. A script that decodes larger than this is removed without going to the script plugins |
162
+ | `memoryThreshold` | 8 MiB | `disarmPdfSource` keeps its copy of the upload in memory up to this size and in a temporary file above it. Each decoded attachment above this size goes through a temporary file. Decoded object streams share this much memory, and the ones that do not fit go to temporary files, so each is decoded once. A decoded attachment's file is deleted once its plugins have decided it. Scrubbed attachment copies waiting for the write, and an attachment decoded again so a plugin can check its further names, share this much memory between them. One that does not fit goes to a temporary file. A script that decodes larger than this is removed without going to the script plugins. An XFA form goes into memory whole |
132
163
  | `preserveSignatures` | `true` | Keep the bytes of a clean file that is signed over all of it, as described under What it removes and what it keeps. `false` rewrites it like any other file, which invalidates the signature |
133
164
 
134
165
  To reject every file that contains JavaScript instead of removing the scripts:
@@ -149,7 +180,9 @@ A file that encrypts only its attached files, through a crypt filter with `/Auth
149
180
 
150
181
  ## Plugins
151
182
 
152
- Attached files and scripts are removed unless a plugin keeps them. A plugin says which files or scripts it accepts, then scrubs them, passes them through unchanged, or removes them after all. The first plugin that accepts handles it. If a plugin throws, or scrubs and writes nothing, the item is removed. An error with a `DEFUSE_LIMIT` or `DEFUSE_IO` code stops the whole run instead, as described under The defuse family. A file plugin that passes a file keeps its original bytes, or the bytes it wrote to the sink if it wrote any.
183
+ Attached files and scripts are removed unless a plugin keeps them. A plugin says which files or scripts it accepts, then scrubs them, passes them through unchanged, or removes them after all. The first plugin that accepts handles it. If a plugin throws, or scrubs and writes nothing, the item is removed.
184
+
185
+ The plugin's answer is final. For a script, the report then holds JAVASCRIPT/PLUGIN_PASSED, PLUGIN_SCRUBBED or PLUGIN_REMOVED and the plugin's findings, and none of pdf-defuse's own, such as JAVASCRIPT/OPEN_ACTION. An attached file gets EMBEDDED_FILE/PLUGIN_PASSED, PLUGIN_SCRUBBED or PLUGIN_REMOVED and the plugin's findings. pdf-defuse reports an item itself only when no plugin answered: a script no plugin accepts gets its JAVASCRIPT finding, and one whose plugin failed gets that finding and JAVASCRIPT/PLUGIN_FAILED. A file no plugin accepts is EMBEDDED_FILE/NO_PLUGIN, and a file whose plugin failed is EMBEDDED_FILE/PLUGIN_FAILED, which weighs what NO_PLUGIN does. An error with a `DEFUSE_LIMIT` or `DEFUSE_IO` code stops the whole run instead, as described under The defuse family. A file plugin that passes a file keeps its original bytes, or the bytes it wrote to the sink if it wrote any.
153
186
 
154
187
  Five plugins are bundled:
155
188
 
@@ -210,7 +243,7 @@ const svgPlugin = {
210
243
 
211
244
  The third argument to `process` holds `depth`, the depth of the file itself, and `deadline` when `limits.timeMs` is set. `deadline` is the `Date.now()` value the whole run must finish by. `pdfPlugin` hands it to the attached PDF's run, so an upload and everything attached to it share one time limit. A plugin that keeps working past it delays the result, because the run is rejected for time only once the plugin returns.
212
245
 
213
- A script plugin receives each script with its trigger, location and field name. The text has its NUL characters removed, as pdf.js removes them before it runs a script. Document-level scripts arrive first, and each later script sees which of them were kept. Several actions can share one script object. Once a plugin keeps it, the plugins see it again for each other action that runs it, with that action's trigger. That includes an action reached through a key the action rules do not cover, such as the `/A` of a bookmark pdf-defuse does not recognize, which arrives with the trigger `unknown`. The script keeps its first answer. An action that gets a different answer, or a different rewrite, loses its reference to the script. Past a set amount of this re-checking, later actions lose the script without the plugins being asked. Every action that loses the script gets a `strip` finding at its own location. A chain of actions with no script in it is never checked again.
246
+ A script plugin receives each script with its trigger, location, field name, `language` and `form`. `form` is `acroform` for the PDF's own scripts and `xfa` for an XFA form's, and only an XFA script can have the `language` `formcalc`. The text has its NUL characters removed, as pdf.js removes them before it runs a script. Document-level scripts arrive first, and each later script sees which of them were kept. Several actions can share one script object. Once a plugin keeps it, the plugins see it again for each other action that runs it, with that action's trigger. That includes an action reached through a key the action rules do not cover, such as the `/A` of a bookmark pdf-defuse does not recognize, which arrives with the trigger `unknown`. The script keeps its first answer. An action that gets a different answer, or a different rewrite, loses its reference to the script. Past a set amount of this re-checking, later actions lose the script without the plugins being asked. Every action that loses the script gets a `strip` finding at its own location. A chain of actions with no script in it is never checked again.
214
247
 
215
248
  ```js
216
249
  const scriptPlugin = {
@@ -221,6 +254,8 @@ const scriptPlugin = {
221
254
  };
222
255
  ```
223
256
 
257
+ A script plugin can also return `findings` with its result, saying what it found in the script. They go into the report at the script's location, unless a finding gives a finer one, such as the line. A `reject` among them is recorded as `strip`, since the plugin decides one script, not the file. Their `weight` is what they add to the score. For a script the plugin passed, the output's report holds them again. [script-defuse](https://github.com/patrickdk77/script-defuse)'s `formScriptPlugin` keeps form calculations, formats and checks, and reports why it removed every other script.
258
+
224
259
  ## The defuse family
225
260
 
226
261
  pdf-defuse is meant to sit beside other packages that each defuse one kind of file, such as images, HTML, Office documents or archives. A master package routes each upload to the right one. A package that finds a file inside a file hands it back through the master's router, so a PDF in a ZIP in a PDF goes through the same router at every level.
package/dist/engine.js CHANGED
@@ -62,7 +62,6 @@ function encryptionFindings(doc, factory) {
62
62
  out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownCryptFilter, undefined, r.detail ? { filter: r.detail } : undefined));
63
63
  return out;
64
64
  }
65
- const PLUGIN_KEPT = new Set([types_1.PdfDetail.PluginPassed, types_1.PdfDetail.PluginScrubbed]);
66
65
  /**
67
66
  * `incomplete` marks a run that could not inspect the file: it stopped early, or found no page to read. finish()
68
67
  * then rejects it without a score, whatever the actions say, because an override can make the finding that stopped
@@ -75,8 +74,9 @@ function finish(findings, options, incomplete, version = '', pages, isOutput = f
75
74
  // In an upload, a plugin rewrite or a metadata strip changes the file, so the output must be written.
76
75
  // In an output being verified, the change is already there.
77
76
  const strippable = findings.some(f => f.action === 'strip' || (!isOutput && (f.detail === types_1.PdfDetail.PluginScrubbed || f.detail === types_1.PdfDetail.Stripped)));
78
- // The upload's score leaves out what plugins kept; the output's score counts it.
79
- const { score, risk } = (0, score_1.scoreFindings)(isOutput ? findings : findings.filter(f => !PLUGIN_KEPT.has(f.detail)), options);
77
+ // What a plugin decided counts at the weights of the plugin's own findings; the PLUGIN_* findings that record its
78
+ // answer weigh nothing.
79
+ const { score, risk } = (0, score_1.scoreFindings)(findings, options);
80
80
  return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, version, pages };
81
81
  }
82
82
  /**
package/dist/findings.js CHANGED
@@ -45,8 +45,9 @@ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Chained, 'strip', 50, 'Jav
45
45
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Rendition, 'strip', 50, 'A media action carries JavaScript');
46
46
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, 'strip', 50, 'A link uses a javascript: URL');
47
47
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, 'strip', 50, 'A JavaScript entry sits outside any action');
48
- def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, 'info', 40, 'JavaScript kept unchanged by plugin <plugin>');
49
- def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info', 20, 'JavaScript rewritten and kept by plugin <plugin>');
48
+ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Xfa, 'strip', 50, 'An XFA form script, in <language>, runs on a form event');
49
+ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, 'info', 0, 'JavaScript kept unchanged by plugin <plugin>');
50
+ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'JavaScript rewritten and kept by plugin <plugin>');
50
51
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, 'strip', 0, 'Plugin <plugin> failed, so the JavaScript was removed');
51
52
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'JavaScript removed by plugin <plugin>');
52
53
  // ACTION
@@ -72,13 +73,14 @@ def(types_1.PdfCategory.Link, types_1.PdfDetail.LookalikeHost, 'strip', 40, "A l
72
73
  def(types_1.PdfCategory.Link, types_1.PdfDetail.EncodedHost, 'strip', 40, "A link's host is percent-encoded");
73
74
  def(types_1.PdfCategory.Link, types_1.PdfDetail.TextMismatch, 'strip', 40, "A link's tooltip names a different site");
74
75
  def(types_1.PdfCategory.Link, types_1.PdfDetail.FullPage, 'strip', 20, 'A link covers most of the page');
75
- def(types_1.PdfCategory.Link, types_1.PdfDetail.Relative, 'strip', 30, "A relative link with no base address, which resolves against the file's own location");
76
+ def(types_1.PdfCategory.Link, types_1.PdfDetail.Relative, 'info', 0, "A relative link, which resolves against the file's own location");
77
+ def(types_1.PdfCategory.Link, types_1.PdfDetail.PathTrick, 'strip', 40, 'A relative link whose path is written to read as another path<: {reason}|>');
76
78
  // EMBEDDED_FILE
77
79
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, 'strip', 30, '<Attached file "{name}"|An attached file> (<{sniffed}|{type}|unknown type>) removed. No plugin accepts this type');
78
80
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.TypeMismatch, 'info', 40, 'The types of <attached file "{name}"|an unnamed attached file> disagree: <its name says {nameType}|its name has no known extension>, <it is declared {declared}|it has no declared type>, and <its content looks like {sniffed}|its content is not recognized>');
79
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginPassed, 'info', 20, 'Attached file kept by plugin <plugin> with nothing removed');
80
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info', 10, 'Attached file cleaned and kept by plugin <plugin>');
81
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip', 0, 'Plugin <plugin> failed, so the attached file was removed');
81
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginPassed, 'info', 0, 'Attached file kept by plugin <plugin> with nothing removed');
82
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'Attached file cleaned and kept by plugin <plugin>');
83
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip', 30, 'Plugin <plugin> failed, so the attached file was removed');
82
84
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'Attached file removed by plugin <plugin>');
83
85
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.Portfolio, 'strip', 30, 'PDF is a portfolio of files');
84
86
  // MEDIA
@@ -93,7 +95,18 @@ def(types_1.PdfCategory.Media, types_1.PdfDetail.Slideshow, 'strip', 30, 'Embedd
93
95
  def(types_1.PdfCategory.Annotation, types_1.PdfDetail.UnknownSubtype, 'strip', 30, 'An annotation of a type outside the allowlist');
94
96
  // FORM
95
97
  def(types_1.PdfCategory.Form, types_1.PdfDetail.Fields, 'info', 0, 'PDF has fillable form fields');
96
- def(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'strip', 40, 'PDF has an XFA form, which carries its own scripts');
98
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'info', 10, 'PDF has an XFA form. Its scripts, links and connections are checked like the rest of the file');
99
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaUnreadable, 'strip', 40, 'An XFA form that could not be read exactly was removed whole: <reason>');
100
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaPacket, 'strip', 30, 'An XFA <packet> packet removed');
101
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExecute, 'strip', 50, 'An XFA form calls a web service or database');
102
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaConnection, 'strip', 40, 'An XFA form connects to a web service or data source');
103
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalObject, 'strip', 60, 'An XFA form embeds an external object');
104
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalReference, 'strip', 40, 'An XFA form names something outside the PDF to load');
105
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, 'strip', 20, 'An XFA form image (<type|unknown type>) removed. <reason|No plugin accepts this type>');
106
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginPassed, 'info', 0, 'XFA form image kept by plugin <plugin> with nothing removed');
107
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'XFA form image cleaned and kept by plugin <plugin>');
108
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, 'strip', 20, 'Plugin <plugin> failed, so the XFA form image was removed');
109
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'XFA form image removed by plugin <plugin>');
97
110
  def(types_1.PdfCategory.Form, types_1.PdfDetail.CalculationOrder, 'strip', 20, 'Form calculation order removed with the calculation scripts it ran');
98
111
  // SIGNATURE
99
112
  def(types_1.PdfCategory.Signature, types_1.PdfDetail.Signed, 'info', 0, 'PDF is digitally signed. A rewrite invalidates the signature');
package/dist/types.d.ts CHANGED
@@ -78,6 +78,7 @@ export declare enum PdfDetail {
78
78
  TextMismatch = "TEXT_MISMATCH",
79
79
  FullPage = "FULL_PAGE",
80
80
  Relative = "RELATIVE",
81
+ PathTrick = "PATH_TRICK",
81
82
  NoPlugin = "NO_PLUGIN",
82
83
  TypeMismatch = "TYPE_MISMATCH",
83
84
  Portfolio = "PORTFOLIO",
@@ -91,6 +92,13 @@ export declare enum PdfDetail {
91
92
  Fields = "FIELDS",
92
93
  Xfa = "XFA",
93
94
  CalculationOrder = "CALCULATION_ORDER",
95
+ XfaUnreadable = "XFA_UNREADABLE",
96
+ XfaPacket = "XFA_PACKET",
97
+ XfaExecute = "XFA_EXECUTE",
98
+ XfaConnection = "XFA_CONNECTION",
99
+ XfaExternalObject = "XFA_EXTERNAL_OBJECT",
100
+ XfaExternalReference = "XFA_EXTERNAL_REFERENCE",
101
+ XfaImage = "XFA_IMAGE",
94
102
  Signed = "SIGNED",
95
103
  Certified = "CERTIFIED",
96
104
  UsageRights = "USAGE_RIGHTS",
@@ -235,20 +243,27 @@ export interface ContainedFilePlugin {
235
243
  }
236
244
  export interface ContainedScript {
237
245
  text: string;
238
- /** "document", "open", "page-open", "field-format", "link", "bookmark", ... */
246
+ /** "document", "open", "page-open", "field-format", "link", "bookmark", ... For an XFA script, "xfa-" and its event: "xfa-click", "xfa-calculate", "xfa-script-object". */
239
247
  trigger: string;
240
248
  location: string;
249
+ /** For an XFA script, the SOM path of the node that holds it. */
241
250
  fieldName?: string;
242
251
  /** Names of document-level scripts a plugin kept. */
243
252
  keptDocumentScripts: string[];
253
+ /** 'formcalc' only for an XFA script written in FormCalc. */
254
+ language: 'javascript' | 'formcalc';
255
+ /** 'xfa' for a script in an XFA form, which runs against the XFA object model instead of Acrobat's. */
256
+ form: 'acroform' | 'xfa';
244
257
  }
245
258
  export interface ScriptPlugin {
246
259
  kind: 'script';
247
260
  name: string;
248
261
  accepts(script: ContainedScript): boolean | Promise<boolean>;
262
+ /** `findings` are what the plugin found in the script. They go into the report at the script's location. */
249
263
  process(script: ContainedScript): Promise<{
250
264
  result: 'scrubbed' | 'passed' | 'removed';
251
265
  text?: string;
266
+ findings?: DefuseFinding[];
252
267
  }>;
253
268
  }
254
269
  export interface PdfActionOverride {
package/dist/types.js CHANGED
@@ -88,6 +88,7 @@ var PdfDetail;
88
88
  PdfDetail["TextMismatch"] = "TEXT_MISMATCH";
89
89
  PdfDetail["FullPage"] = "FULL_PAGE";
90
90
  PdfDetail["Relative"] = "RELATIVE";
91
+ PdfDetail["PathTrick"] = "PATH_TRICK";
91
92
  // EMBEDDED_FILE
92
93
  PdfDetail["NoPlugin"] = "NO_PLUGIN";
93
94
  PdfDetail["TypeMismatch"] = "TYPE_MISMATCH";
@@ -105,6 +106,13 @@ var PdfDetail;
105
106
  PdfDetail["Fields"] = "FIELDS";
106
107
  PdfDetail["Xfa"] = "XFA";
107
108
  PdfDetail["CalculationOrder"] = "CALCULATION_ORDER";
109
+ PdfDetail["XfaUnreadable"] = "XFA_UNREADABLE";
110
+ PdfDetail["XfaPacket"] = "XFA_PACKET";
111
+ PdfDetail["XfaExecute"] = "XFA_EXECUTE";
112
+ PdfDetail["XfaConnection"] = "XFA_CONNECTION";
113
+ PdfDetail["XfaExternalObject"] = "XFA_EXTERNAL_OBJECT";
114
+ PdfDetail["XfaExternalReference"] = "XFA_EXTERNAL_REFERENCE";
115
+ PdfDetail["XfaImage"] = "XFA_IMAGE";
108
116
  // SIGNATURE
109
117
  PdfDetail["Signed"] = "SIGNED";
110
118
  PdfDetail["Certified"] = "CERTIFIED";
package/dist/uri.d.ts CHANGED
@@ -2,6 +2,8 @@ import { PdfDetail as D } from './types';
2
2
  interface UriVerdict {
3
3
  /** LINK detail, or JAVASCRIPT URL for javascript: and vbscript: links. */
4
4
  detail: D;
5
+ /** Why a relative link is a PATH_TRICK. */
6
+ reason?: string;
5
7
  host?: string;
6
8
  /** For a safe target, the hosts a label is compared with, in order. They do not depend on the label. */
7
9
  sites?: string[];
@@ -24,6 +26,16 @@ export declare class LabelSites {
24
26
  * with the hosts a link's labels name.
25
27
  */
26
28
  export declare function checkUri(rawUri: string, base?: string): UriVerdict;
29
+ /**
30
+ * Why a relative path is written to read as another path, or undefined for an ordinary one. Climbing with .. from the
31
+ * start, as ../images/a.png does, is ordinary. Odd is a control character a reader drops, a backslash a browser reads
32
+ * as a slash, an encoded dot, slash, backslash or NUL, a segment of only dots and spaces, which Windows reads as .. or
33
+ * as nothing, and a path that names a folder and then climbs above where it started. Taken from xml-defuse. An XFA
34
+ * image name is a Windows path, so `backslash: 'separator'` reads a backslash as one.
35
+ */
36
+ export declare function pathTrick(raw: string, opts?: {
37
+ backslash?: 'trick' | 'separator';
38
+ }): string | undefined;
27
39
  /** Host names written in free text, such as a tooltip. */
28
40
  export declare function hostsIn(text: string): string[];
29
41
  export {};
package/dist/uri.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LabelSites = void 0;
4
4
  exports.checkUri = checkUri;
5
+ exports.pathTrick = pathTrick;
5
6
  exports.hostsIn = hostsIn;
6
7
  const types_1 = require("./types");
7
8
  const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
@@ -60,10 +61,19 @@ function checkUri(rawUri, base) {
60
61
  return v;
61
62
  www = v.host;
62
63
  }
64
+ // A relative reference cannot have a colon in its first segment (RFC 3986), and a reader that decodes the address
65
+ // first, as %6Aavascript: asks it to, reads a scheme there.
66
+ const head = uri.split(/[/?#]/, 1)[0];
67
+ if (head.includes(':'))
68
+ return { detail: types_1.PdfDetail.PathTrick, reason: 'a colon in the first segment, which reads as a scheme once the address is decoded' };
69
+ const trick = pathTrick(rawUri);
70
+ if (trick)
71
+ return { detail: types_1.PdfDetail.PathTrick, reason: trick };
63
72
  if (base) {
73
+ // The base stays in the output, so a link resolved against a hostile one is as hostile as the base.
64
74
  const b = checkUri(base);
65
75
  if (b.detail !== types_1.PdfDetail.Safe)
66
- return { detail: types_1.PdfDetail.Relative };
76
+ return { detail: b.detail === types_1.PdfDetail.PathTrick ? types_1.PdfDetail.PathTrick : b.detail, reason: b.reason };
67
77
  try {
68
78
  uri = new URL(uri, base.trim()).toString();
69
79
  m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(uri);
@@ -137,6 +147,46 @@ function hostVerdict(host, raw) {
137
147
  return { detail: types_1.PdfDetail.LookalikeHost };
138
148
  return undefined;
139
149
  }
150
+ /**
151
+ * Why a relative path is written to read as another path, or undefined for an ordinary one. Climbing with .. from the
152
+ * start, as ../images/a.png does, is ordinary. Odd is a control character a reader drops, a backslash a browser reads
153
+ * as a slash, an encoded dot, slash, backslash or NUL, a segment of only dots and spaces, which Windows reads as .. or
154
+ * as nothing, and a path that names a folder and then climbs above where it started. Taken from xml-defuse. An XFA
155
+ * image name is a Windows path, so `backslash: 'separator'` reads a backslash as one.
156
+ */
157
+ function pathTrick(raw, opts = {}) {
158
+ const u = raw.trim();
159
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: finds the control characters a reader drops from a path.
160
+ if (/[\x00-\x1f\x7f]/.test(u))
161
+ return 'a control character, which readers drop before they read the address';
162
+ const end = u.search(/[?#]/);
163
+ let path = end < 0 ? u : u.slice(0, end);
164
+ if (path.includes('\\')) {
165
+ if (opts.backslash !== 'separator')
166
+ return 'a backslash, which browsers read as a slash';
167
+ path = path.replaceAll('\\', '/');
168
+ }
169
+ if (/%(2e|2f|5c|00)/i.test(path))
170
+ return 'an encoded dot, slash, backslash or NUL in the path';
171
+ let depth = 0;
172
+ let named = false;
173
+ for (const seg of path.split('/')) {
174
+ if (seg === '' || seg === '.')
175
+ continue;
176
+ if (seg === '..') {
177
+ if (depth > 0)
178
+ depth--;
179
+ else if (named)
180
+ return 'a path that names a folder and then climbs above where it started';
181
+ continue;
182
+ }
183
+ if (/^[. ]+$/.test(seg))
184
+ return 'a segment of only dots and spaces, which Windows reads as .. or as nothing';
185
+ depth++;
186
+ named = true;
187
+ }
188
+ return undefined;
189
+ }
140
190
  /** Host names written in free text, such as a tooltip. */
141
191
  function hostsIn(text) {
142
192
  const out = [];
package/dist/walker.d.ts CHANGED
@@ -6,7 +6,10 @@ import { type ByteSink, type DefuseFinding, type PdfOptions, type PluginContext
6
6
  interface KeptItem {
7
7
  result: 'passed' | 'scrubbed';
8
8
  plugin: string;
9
- /** For a file: the findings inside the kept bytes, which go into the output's report. */
9
+ /**
10
+ * What goes into the output's report for it: for a file, the findings inside the kept bytes, and for a script the
11
+ * plugin passed, what the plugin found in it.
12
+ */
10
13
  findings?: DefuseFinding[];
11
14
  }
12
15
  export interface VerifyExpectations {
@@ -103,6 +106,8 @@ export declare class Walker {
103
106
  private againBudget;
104
107
  /** A script's findings from visits of removed content, held until the walk ends so a later real visit that keeps it can correct them. */
105
108
  private readonly shadowScripts;
109
+ /** Scripts a plugin answered for. Its answer is final, so what a shadow visit made of them is not reported. */
110
+ private readonly answeredScripts;
106
111
  /** Information dictionaries and XMP streams removed for the caller. */
107
112
  private readonly strippedMeta;
108
113
  /** Bytes of scrubbed attachments held in memory until the write. */
@@ -134,6 +139,14 @@ export declare class Walker {
134
139
  */
135
140
  private readonly fileDecisions;
136
141
  private readonly jsReplace;
142
+ /** XFA packet streams with their cleaned text, by object number. */
143
+ private readonly xfaReplace;
144
+ /** Per XFA entry, by owner and path, the value phase A decided to write, or null when the form goes whole. */
145
+ private readonly xfaDone;
146
+ /** Set once an XFA form goes into the output, so the catalog keeps NeedsRendering. */
147
+ private xfaKept;
148
+ /** XFA packet streams, which stay packets whatever their /Type says. Some producers mark them as embedded files. */
149
+ private readonly xfaRole;
137
150
  private jsTree?;
138
151
  private efTree?;
139
152
  private readonly keptDocScripts;
@@ -165,6 +178,8 @@ export declare class Walker {
165
178
  * strip, or reject when the caller asked for that.
166
179
  */
167
180
  private emitRemoval;
181
+ /** What a script plugin found in a script, at the script's location unless the plugin gave a finer one. */
182
+ private pushScript;
168
183
  /**
169
184
  * Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
170
185
  * A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
@@ -239,6 +254,22 @@ export declare class Walker {
239
254
  private handleAnnotsArray;
240
255
  private handleOutline;
241
256
  private handleNames;
257
+ /**
258
+ * An XFA form: its packets are read, every script goes to the script plugins, and what reaches outside the form
259
+ * goes. A form that cannot be read exactly goes whole, and so does every form when FORM/XFA is set to strip.
260
+ */
261
+ private handleXfa;
262
+ /** Returns the XFA entry to write. Cleaned packet text goes into xfaReplace, keyed by the stream that holds it. */
263
+ private cleanXfa;
264
+ /** Runs the script plugins over an XFA script. Returns whether it stays, and its new text when a plugin rewrote it. */
265
+ private decideXfaScript;
266
+ /**
267
+ * An image an XFA form carries inline goes to the file plugins, as an attached file does: a viewer decodes it with
268
+ * decoders of its own. Returns whether it stays, and its new base64 when a plugin rewrote it.
269
+ */
270
+ private decideXfaImage;
271
+ /** A link in an XFA form's rich text, checked as a link action's address is. Returns whether it stays. */
272
+ private decideXfaLink;
242
273
  private handleAcroForm;
243
274
  private handleField;
244
275
  /** Whether a signature value covers every byte of the file but its own /Contents, which sits right after its key. */