@patrickdk77/pdf-defuse 0.2.3 → 0.2.4
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 +37 -6
- package/dist/findings.js +15 -2
- 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 +26 -0
- package/dist/walker.js +363 -13
- 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
|
|
|
@@ -128,7 +157,7 @@ A run that stops before it has seen the whole file rejects the file with a null
|
|
|
128
157
|
| `scoreWeights`, `scoreBands` | built in | Change the scoring |
|
|
129
158
|
| `filePlugins`, `scriptPlugins` | none | Keep chosen attached files or scripts |
|
|
130
159
|
| `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 |
|
|
160
|
+
| `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
161
|
| `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
162
|
|
|
134
163
|
To reject every file that contains JavaScript instead of removing the scripts:
|
|
@@ -210,7 +239,7 @@ const svgPlugin = {
|
|
|
210
239
|
|
|
211
240
|
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
241
|
|
|
213
|
-
A script plugin receives each script with its trigger, location
|
|
242
|
+
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
243
|
|
|
215
244
|
```js
|
|
216
245
|
const scriptPlugin = {
|
|
@@ -221,6 +250,8 @@ const scriptPlugin = {
|
|
|
221
250
|
};
|
|
222
251
|
```
|
|
223
252
|
|
|
253
|
+
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. [script-defuse](https://github.com/patrickdk77/script-defuse)'s `formScriptPlugin` keeps form calculations, formats and checks, and reports why it removed every other script.
|
|
254
|
+
|
|
224
255
|
## The defuse family
|
|
225
256
|
|
|
226
257
|
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/findings.js
CHANGED
|
@@ -45,6 +45,7 @@ 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.Xfa, 'strip', 50, 'An XFA form script, in <language>, runs on a form event');
|
|
48
49
|
def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, 'info', 40, 'JavaScript kept unchanged by plugin <plugin>');
|
|
49
50
|
def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info', 20, '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');
|
|
@@ -72,7 +73,8 @@ 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>');
|
|
@@ -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', 10, 'XFA form image kept by plugin <plugin> with nothing removed');
|
|
107
|
+
def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginScrubbed, 'info', 5, 'XFA form image cleaned and kept by plugin <plugin>');
|
|
108
|
+
def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, 'strip', 0, '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
|
@@ -134,6 +134,14 @@ export declare class Walker {
|
|
|
134
134
|
*/
|
|
135
135
|
private readonly fileDecisions;
|
|
136
136
|
private readonly jsReplace;
|
|
137
|
+
/** XFA packet streams with their cleaned text, by object number. */
|
|
138
|
+
private readonly xfaReplace;
|
|
139
|
+
/** Per XFA entry, by owner and path, the value phase A decided to write, or null when the form goes whole. */
|
|
140
|
+
private readonly xfaDone;
|
|
141
|
+
/** Set once an XFA form goes into the output, so the catalog keeps NeedsRendering. */
|
|
142
|
+
private xfaKept;
|
|
143
|
+
/** XFA packet streams, which stay packets whatever their /Type says. Some producers mark them as embedded files. */
|
|
144
|
+
private readonly xfaRole;
|
|
137
145
|
private jsTree?;
|
|
138
146
|
private efTree?;
|
|
139
147
|
private readonly keptDocScripts;
|
|
@@ -165,6 +173,8 @@ export declare class Walker {
|
|
|
165
173
|
* strip, or reject when the caller asked for that.
|
|
166
174
|
*/
|
|
167
175
|
private emitRemoval;
|
|
176
|
+
/** What a script plugin found in a script, at the script's location unless the plugin gave a finer one. */
|
|
177
|
+
private pushScript;
|
|
168
178
|
/**
|
|
169
179
|
* Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
|
|
170
180
|
* A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
|
|
@@ -239,6 +249,22 @@ export declare class Walker {
|
|
|
239
249
|
private handleAnnotsArray;
|
|
240
250
|
private handleOutline;
|
|
241
251
|
private handleNames;
|
|
252
|
+
/**
|
|
253
|
+
* An XFA form: its packets are read, every script goes to the script plugins, and what reaches outside the form
|
|
254
|
+
* goes. A form that cannot be read exactly goes whole, and so does every form when FORM/XFA is set to strip.
|
|
255
|
+
*/
|
|
256
|
+
private handleXfa;
|
|
257
|
+
/** Returns the XFA entry to write. Cleaned packet text goes into xfaReplace, keyed by the stream that holds it. */
|
|
258
|
+
private cleanXfa;
|
|
259
|
+
/** Runs the script plugins over an XFA script. Returns whether it stays, and its new text when a plugin rewrote it. */
|
|
260
|
+
private decideXfaScript;
|
|
261
|
+
/**
|
|
262
|
+
* An image an XFA form carries inline goes to the file plugins, as an attached file does: a viewer decodes it with
|
|
263
|
+
* decoders of its own. Returns whether it stays, and its new base64 when a plugin rewrote it.
|
|
264
|
+
*/
|
|
265
|
+
private decideXfaImage;
|
|
266
|
+
/** A link in an XFA form's rich text, checked as a link action's address is. Returns whether it stays. */
|
|
267
|
+
private decideXfaLink;
|
|
242
268
|
private handleAcroForm;
|
|
243
269
|
private handleField;
|
|
244
270
|
/** Whether a signature value covers every byte of the file but its own /Contents, which sits right after its key. */
|