@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 +42 -7
- package/dist/engine.js +3 -3
- package/dist/findings.js +20 -7
- package/dist/types.d.ts +16 -1
- package/dist/types.js +8 -0
- package/dist/uri.d.ts +12 -0
- package/dist/uri.js +51 -1
- package/dist/walker.d.ts +32 -1
- package/dist/walker.js +387 -20
- package/dist/xfa.d.ts +113 -0
- package/dist/xfa.js +407 -0
- package/package.json +1 -1
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
|
|
71
|
-
- Attached files, portfolios,
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
79
|
-
|
|
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.
|
|
49
|
-
def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.
|
|
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, '
|
|
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',
|
|
80
|
-
def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info',
|
|
81
|
-
def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip',
|
|
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, '
|
|
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.
|
|
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
|
-
/**
|
|
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. */
|