@patrickdk77/pdf-defuse 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/uri.js ADDED
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LabelSites = void 0;
4
+ exports.checkUri = checkUri;
5
+ exports.hostsIn = hostsIn;
6
+ const types_1 = require("./types");
7
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
8
+ /** A host as LabelSites compares it. */
9
+ const siteName = (h) => h
10
+ .toLowerCase()
11
+ .replace(/^www\./, '')
12
+ .replace(/\.$/, '');
13
+ /**
14
+ * The hosts a label names, as a lookup: has() answers whether a host is one of them or a subdomain of one, or one of
15
+ * them is a subdomain of the host, at a cost that depends on the host and not on how many the label names. No
16
+ * public-suffix list is needed: evil.co.uk is not under mybank.co.uk.
17
+ */
18
+ class LabelSites {
19
+ hosts = new Set();
20
+ /** Every domain some label host is a subdomain of. */
21
+ parents = new Set();
22
+ constructor(names) {
23
+ for (const n of names) {
24
+ const x = siteName(n);
25
+ this.hosts.add(x);
26
+ for (let i = x.indexOf('.'); i >= 0; i = x.indexOf('.', i + 1))
27
+ this.parents.add(x.slice(i + 1));
28
+ }
29
+ }
30
+ get size() {
31
+ return this.hosts.size;
32
+ }
33
+ has(host) {
34
+ const y = siteName(host);
35
+ if (this.hosts.has(y) || this.parents.has(y))
36
+ return true;
37
+ for (let i = y.indexOf('.'); i >= 0; i = y.indexOf('.', i + 1))
38
+ if (this.hosts.has(y.slice(i + 1)))
39
+ return true;
40
+ return false;
41
+ }
42
+ }
43
+ exports.LabelSites = LabelSites;
44
+ /**
45
+ * Classifies a link target. `base` is the catalog's URI Base, if any. The walker compares a safe target's `sites`
46
+ * with the hosts a link's labels name.
47
+ */
48
+ function checkUri(rawUri, base) {
49
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: removes the control characters a browser drops from a URL.
50
+ let uri = rawUri.trim().replace(/[\x00-\x1f\x7f]/g, '');
51
+ if (uri.startsWith('\\\\') || uri.startsWith('//') || uri.startsWith('\\/') || uri.startsWith('/\\'))
52
+ return { detail: types_1.PdfDetail.NetworkPath };
53
+ let m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(uri);
54
+ let www;
55
+ if (!m) {
56
+ // pdf.js reads "www." with two or more dots as an http address before any base applies. Both readings must pass.
57
+ if (uri.startsWith('www.') && (uri.match(/\./g)?.length ?? 0) >= 2) {
58
+ const v = checkUri(`http://${uri}`);
59
+ if (v.detail !== types_1.PdfDetail.Safe)
60
+ return v;
61
+ www = v.host;
62
+ }
63
+ if (base) {
64
+ const b = checkUri(base);
65
+ if (b.detail !== types_1.PdfDetail.Safe)
66
+ return { detail: types_1.PdfDetail.Relative };
67
+ try {
68
+ uri = new URL(uri, base.trim()).toString();
69
+ m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(uri);
70
+ }
71
+ catch {
72
+ return { detail: types_1.PdfDetail.Relative };
73
+ }
74
+ }
75
+ if (!m)
76
+ return { detail: types_1.PdfDetail.Relative };
77
+ }
78
+ const scheme = m[1].toLowerCase();
79
+ if (scheme === 'javascript' || scheme === 'vbscript')
80
+ return { detail: types_1.PdfDetail.Url };
81
+ if (scheme === 'data')
82
+ return { detail: types_1.PdfDetail.DataUrl };
83
+ if (scheme === 'smb' || scheme === 'cifs' || scheme === 'afp' || scheme === 'nfs')
84
+ return { detail: types_1.PdfDetail.NetworkPath };
85
+ if (scheme === 'file') {
86
+ let host = '';
87
+ try {
88
+ host = new URL(uri).hostname;
89
+ }
90
+ catch {
91
+ /* treat as local */
92
+ }
93
+ if (host && host.toLowerCase() !== 'localhost')
94
+ return { detail: types_1.PdfDetail.NetworkPath };
95
+ return { detail: types_1.PdfDetail.FileUrl };
96
+ }
97
+ if (scheme === 'mailto') {
98
+ let addr = uri.slice(7).split('?')[0];
99
+ try {
100
+ addr = decodeURIComponent(addr);
101
+ }
102
+ catch {
103
+ /* keep the address as written */
104
+ }
105
+ const domain = addr.includes('@') ? addr.slice(addr.lastIndexOf('@') + 1).toLowerCase() : '';
106
+ const v = hostVerdict(domain, domain);
107
+ return v ?? { detail: types_1.PdfDetail.Safe, host: domain, sites: www ? [www, domain] : [domain] };
108
+ }
109
+ if (scheme !== 'http' && scheme !== 'https')
110
+ return { detail: types_1.PdfDetail.OtherScheme };
111
+ // Raw authority, as written, before the URL parser normalizes it.
112
+ const rawAuth = /^[a-zA-Z]+:[\\/]*([^/\\?#]*)/.exec(uri)?.[1] ?? '';
113
+ let parsed;
114
+ try {
115
+ parsed = new URL(uri);
116
+ }
117
+ catch {
118
+ return { detail: types_1.PdfDetail.OtherScheme };
119
+ }
120
+ if (parsed.username || parsed.password || rawAuth.includes('@'))
121
+ return { detail: types_1.PdfDetail.Credentials };
122
+ // The parser lowercases, punycodes and turns every IPv4 shorthand into dotted form, as browsers do.
123
+ const host = parsed.hostname.replace(/\.$/, '');
124
+ const v = hostVerdict(host, rawAuth);
125
+ return v ?? { detail: types_1.PdfDetail.Safe, host, sites: www ? [www, host] : [host] };
126
+ }
127
+ function hostVerdict(host, raw) {
128
+ if (!host)
129
+ return { detail: types_1.PdfDetail.OtherScheme };
130
+ if (raw.includes('%'))
131
+ return { detail: types_1.PdfDetail.EncodedHost };
132
+ if (host.startsWith('[') || IPV4.test(host))
133
+ return { detail: types_1.PdfDetail.IpHost };
134
+ if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host))
135
+ return { detail: types_1.PdfDetail.IpHost };
136
+ if (/\P{ASCII}/u.test(host) || host.split('.').some(label => label.startsWith('xn--')))
137
+ return { detail: types_1.PdfDetail.LookalikeHost };
138
+ return undefined;
139
+ }
140
+ /** Host names written in free text, such as a tooltip. */
141
+ function hostsIn(text) {
142
+ const out = [];
143
+ // The first lookahead caps a candidate at a DNS name's length, so a long run of host characters costs linear time.
144
+ // A host ends at any character that cannot continue it, after any dots, except "@" or "_", which make it part of
145
+ // an address or a name.
146
+ const re = /\b(?:https?:\/\/)?(?=[a-z0-9.-]{1,254}(?![a-z0-9.-]))((?:[a-z0-9-]+\.)+[a-z]{2,})(?=\.*(?:[^a-z0-9.@_-]|$))/gi;
147
+ for (let m = re.exec(text); m; m = re.exec(text))
148
+ out.push(m[1].toLowerCase());
149
+ return out;
150
+ }
@@ -0,0 +1,302 @@
1
+ import { type PdfDocument } from './document';
2
+ import { type FindingFactory } from './findings';
3
+ import { type TempDir } from './io';
4
+ import { type ByteSink, type DefuseFinding, type PdfOptions, type PluginContext } from './types';
5
+ /** A script or file a plugin kept, as the output verification expects to meet it again. */
6
+ interface KeptItem {
7
+ result: 'passed' | 'scrubbed';
8
+ plugin: string;
9
+ /** For a file: the findings inside the kept bytes, which go into the output's report. */
10
+ findings?: DefuseFinding[];
11
+ }
12
+ export interface VerifyExpectations {
13
+ /** Keyed by script text. */
14
+ scripts: Map<string, KeptItem>;
15
+ /** Keyed by the MD5 of the decoded file. */
16
+ files: Map<string, KeptItem>;
17
+ }
18
+ interface WalkerConfig {
19
+ options: PdfOptions;
20
+ depth: number;
21
+ temp: TempDir;
22
+ factory: FindingFactory;
23
+ verify?: VerifyExpectations;
24
+ deadline?: number;
25
+ }
26
+ /** Thrown when a contained file plugin's nested run hits the nesting limit. The code makes every package pass it up. */
27
+ export declare class NestingLimitError extends Error {
28
+ readonly code = "DEFUSE_LIMIT";
29
+ readonly limit = "nesting";
30
+ }
31
+ /** An I/O error inside an attached PDF's own run, carried up so the upload's run fails on it as on its own. */
32
+ export declare class NestedIoError extends Error {
33
+ readonly code = "DEFUSE_IO";
34
+ constructor(cause: unknown);
35
+ }
36
+ /**
37
+ * The code of an error that stops the whole tree of files, from any package: DEFUSE_LIMIT for a time or nesting
38
+ * limit, DEFUSE_IO for an I/O error. Packages cannot share error classes, so the code alone marks one.
39
+ */
40
+ export declare function stopsTree(e: unknown): 'DEFUSE_LIMIT' | 'DEFUSE_IO' | undefined;
41
+ /** The key under which the walker hands its run's options to a plugin. No other package can name it. */
42
+ export declare const RUN_OPTIONS: unique symbol;
43
+ /** A plugin context as the walker makes it. */
44
+ export type RunContext = PluginContext & {
45
+ [RUN_OPTIONS]?: PdfOptions;
46
+ };
47
+ export declare class Walker {
48
+ private readonly doc;
49
+ private readonly cfg;
50
+ readonly findings: DefuseFinding[];
51
+ /** A time or nesting limit a plugin threw, which a nested run passes up instead of reporting. */
52
+ passedOn?: unknown;
53
+ pages: number;
54
+ /** Per page, the total length of its content streams, for verifying the output. */
55
+ pageContent: number[];
56
+ /** Scripts and files kept by plugins, for verifying the output. */
57
+ readonly kept: VerifyExpectations;
58
+ /** Actions with a script down their chains, once fully read, which a further slot asks about again. */
59
+ private readonly scriptActions;
60
+ /**
61
+ * Where the walk first removed something, found by comparing what it would write with what it read. The engine
62
+ * never passes an upload through unchanged once this is set.
63
+ */
64
+ removedAt?: string;
65
+ /**
66
+ * Set when a signature field the walk reached holds a signature over every byte of the file but its own /Contents,
67
+ * so nothing was added after signing.
68
+ */
69
+ signed: boolean;
70
+ private phase;
71
+ private readonly perKey;
72
+ private readonly aggregates;
73
+ private readonly pageInfo;
74
+ /** Pages written directly in /Kids, by the owner and path the walk reaches them at. */
75
+ private readonly inlinePages;
76
+ /** Objects first reached as file specifications, which stay file specifications whatever their content says. */
77
+ private readonly fileRole;
78
+ /** Objects listed in /Annots and kept there though they are not annotations, which get the annotation rules. */
79
+ private readonly annotRole;
80
+ /** Objects that only removed content refers to, which are not unreferenced. */
81
+ private readonly reached;
82
+ /** Pages in document order: an object number, or the dictionary of a page written directly in /Kids. */
83
+ private readonly pageOrder;
84
+ /** Objects whose role comes from the referrer that defines it, whatever else reaches them first. */
85
+ private rootNum?;
86
+ private namesNum?;
87
+ private infoNum?;
88
+ /** Slots that lose their reference to a shared object that other slots keep. Keyed by owner and path. */
89
+ private readonly refusedSlots;
90
+ /** Bumped each time a script is asked again for another slot and would not be kept there. */
91
+ private refusals;
92
+ /** The refusals that reported nothing themselves: past the work budget, or for a different rewrite. */
93
+ private quiet;
94
+ private deciding;
95
+ /**
96
+ * Actions and additional-actions dictionaries with no script anywhere down their chains, once fully read. Only a
97
+ * script can be decided differently for another slot, so these are never asked about again.
98
+ */
99
+ private readonly scriptFree;
100
+ /** Work allowed for deciding shared scripts again, in units: 1024 per slot, 64 per step down a chain, 1 per 16 decoded bytes. */
101
+ private againBudget;
102
+ /** 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. */
103
+ private readonly shadowScripts;
104
+ /** Information dictionaries and XMP streams removed for the caller. */
105
+ private readonly strippedMeta;
106
+ /** Bytes of scrubbed attachments held in memory until the write. */
107
+ private retained;
108
+ /** A second name for a contained file whose content was already decided, by stream number and name. */
109
+ private readonly nameDecisions;
110
+ /** Per annotation, the first page that shows it, and the smallest page box among the pages that show it. */
111
+ private readonly annotPage;
112
+ /**
113
+ * Per annotations array, keyed by the owner and path the walk reaches it at: the first page that shows it, and the
114
+ * smallest page box among the pages that show it, whether as their own /Annots or inherited.
115
+ */
116
+ private readonly annotLists;
117
+ private readonly altText;
118
+ private readonly special;
119
+ private order;
120
+ private readonly visited;
121
+ private readonly shadowVisited;
122
+ private readonly dropped;
123
+ /** `slot` is the owner and path of an ordinary key that queued the reference. */
124
+ private queue;
125
+ private qhead;
126
+ private readonly scriptDecisions;
127
+ /**
128
+ * `types` is set when the content went through the type check, so a further name can be checked against it:
129
+ * `name` is the name the content was decided under, and `plugin` the plugin that decided it. `size` is known once
130
+ * the whole file was decoded, which happens only when a plugin or the output check reads it. `decoded` is the
131
+ * content decoded again for the further names, once a plugin read it under one, until the walk ends.
132
+ */
133
+ private readonly fileDecisions;
134
+ private readonly jsReplace;
135
+ private jsTree?;
136
+ private efTree?;
137
+ private readonly keptDocScripts;
138
+ /** What a link's label check found, by chain object and a hash of the labels: a finding's data, or null. */
139
+ private readonly labelChecks;
140
+ /** Per web link action object, the hosts a label is compared with and the address each comes from. */
141
+ private readonly chainSites;
142
+ private readonly keptCalcFields;
143
+ /** Set once a field's calculation script is removed, so the calculation order is narrowed. */
144
+ private calcRemoved;
145
+ /** The fields the calculation order lists, by object number. */
146
+ private readonly coFields;
147
+ private coPresent;
148
+ private readonly treeNodes;
149
+ private uriBase?;
150
+ private readonly newNum;
151
+ private synthJs;
152
+ private synthEf;
153
+ constructor(doc: PdfDocument, cfg: WalkerConfig);
154
+ private get options();
155
+ private emit;
156
+ /**
157
+ * Records a finding whose action is final. Past 200 of one kind, further ones only raise a count, on a finding
158
+ * that carries the strictest action among those it counts.
159
+ */
160
+ private push;
161
+ /**
162
+ * Reports content that goes whatever the overrides say, such as a slot refused past the work budget. Its action is
163
+ * strip, or reject when the caller asked for that.
164
+ */
165
+ private emitRemoval;
166
+ /**
167
+ * Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
168
+ * A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
169
+ */
170
+ private pushContained;
171
+ /**
172
+ * Throws on an error a plugin threw that stops the whole tree. An I/O error fails this run as one of its own would,
173
+ * so its cause goes up.
174
+ */
175
+ private passOn;
176
+ private aggregate;
177
+ analyze(): Promise<void>;
178
+ private enqueue;
179
+ private drain;
180
+ /**
181
+ * A slot that reaches an action through a key the action rules do not cover, such as the /A of a dictionary of no
182
+ * known kind. Readers run the action from there, so a script down its chain, decided from another slot, is asked
183
+ * about again for this one.
184
+ */
185
+ private reachAgain;
186
+ /** An action's type as readers read it, resolving an /S that is a reference. */
187
+ private actionType;
188
+ /** Follows references, as far as 32, to a value and the number of the object that holds it. */
189
+ private lastRef;
190
+ /** Visits one indirect object: findings, keep or drop, children. */
191
+ private processRef;
192
+ /**
193
+ * Marks what removed content refers to, so it does not count as unreferenced. Nothing is reported or decided: what
194
+ * only removed content reaches is not written either way, and a real visit still reads it.
195
+ */
196
+ private reach;
197
+ /** A rectangle as pdf.js reads one: four numbers, the array and each number resolved one level, with an area. */
198
+ private box;
199
+ /**
200
+ * Reads the page tree in document order as pdf.js does, and records which page every annotation sits on. What a
201
+ * page inherits comes up its /Parent chain, as readers look it up.
202
+ */
203
+ private walkPageTree;
204
+ /** Maps annotations to the alt text of the structure element that holds them. */
205
+ private buildAltText;
206
+ /**
207
+ * Collects name-tree entries with any key form: strings, names or references to them. pdf.js reads an entry whatever
208
+ * its key, so any other key is reported and read as an empty string, and its entry is collected all the same.
209
+ */
210
+ private collectNameTree;
211
+ private prepassDocumentScripts;
212
+ private postWalk;
213
+ /** Total content-stream length of every page, so the output can be checked page by page. */
214
+ contentSignature(): Promise<number[]>;
215
+ /**
216
+ * What an object is: the catalog and the pages by the role the trailer and the page tree give them, anything
217
+ * else by its content. A page whose content is an annotation stays an annotation, so the annotation rules apply.
218
+ */
219
+ private kindOf;
220
+ private transformTop;
221
+ /**
222
+ * Whether no script lies down an action's chain. A reference counts only once its object was read to the end of its
223
+ * own chain, so a chain that loops back counts as holding one.
224
+ */
225
+ private scriptFreeChain;
226
+ /** Transforms any value. Returns undefined when the value must be removed. */
227
+ private tv;
228
+ /** Rules every dictionary gets, applied to the keys a specific handler did not take. */
229
+ private handleGeneric;
230
+ private handleCatalog;
231
+ /**
232
+ * `num` is undefined for a page written directly in /Kids. `node` is a /Pages node, whose /Annots and /AA its pages
233
+ * inherit, as pdf.js reads them.
234
+ */
235
+ private handlePage;
236
+ /** Keeps only references to real, kept annotations. */
237
+ private handleAnnotsArray;
238
+ private handleOutline;
239
+ private handleNames;
240
+ private handleAcroForm;
241
+ private handleField;
242
+ /** Whether a signature value covers every byte of the file but its own /Contents, which sits right after its key. */
243
+ private signsWholeFile;
244
+ private handleAnnot;
245
+ /**
246
+ * A link whose tooltip or alt text names a different site than a web address its click opens. Readers perform
247
+ * the /Next actions too, so the whole chain is checked; a chain too long to check counts as a mismatch.
248
+ */
249
+ private labelMismatch;
250
+ /** Walks content being removed, so its findings are still reported. */
251
+ private shadowScan;
252
+ /**
253
+ * A reference that must lead to an action. Anything else loses the reference but is not dropped,
254
+ * since it may be legitimate where else it is used. In additional-actions dictionaries only kept scripts stay.
255
+ */
256
+ private actionRef;
257
+ /**
258
+ * For a slot that reaches an object already decided from another slot: `run` asks the plugins again under this
259
+ * slot's trigger. The object keeps its first decision, and this slot loses its reference when a script under it
260
+ * would not be kept here. Past a set budget of this work, the slot loses it without asking.
261
+ */
262
+ private decideForSlot;
263
+ /**
264
+ * An annotation, catalog, page or names dictionary whose content also makes it an action runs as that action
265
+ * from any slot that points at it. Its action keys get the action rules. When those remove the action, /S goes,
266
+ * so no reader runs it.
267
+ */
268
+ private alsoAction;
269
+ private handleAA;
270
+ /**
271
+ * Decodes a script only when something will read it, and never past the memory threshold. `num` is the object
272
+ * that holds the text, at the end of any chain of references, so a rewrite lands where a reader looks.
273
+ */
274
+ private scriptText;
275
+ /**
276
+ * Runs the script plugins. Returns whether the script stays. A script already decided under `key` is asked about
277
+ * again for another trigger that reaches it: it keeps its first decision, and a different answer counts as a refusal.
278
+ */
279
+ private decideScript;
280
+ /** `rest` false handles only the action's own keys (/S, /JS, /Next), for an object that is also something else. */
281
+ private handleAction;
282
+ private handleFilespecList;
283
+ /**
284
+ * A value whose key makes it a file specification, under /FS, /AF or the EmbeddedFiles tree. That key decides what
285
+ * it is, whatever its content also looks like, so its file is always decided; only the catalog, a page and the
286
+ * names and information dictionaries keep the roles they have.
287
+ */
288
+ private handleFilespecValue;
289
+ private handleFilespec;
290
+ /**
291
+ * Decodes a contained file and runs the file plugins. The content is decided once per embedded file stream; every
292
+ * further name that reaches it is still checked against that content.
293
+ */
294
+ private decideFile;
295
+ /** Writes the kept objects as a new single-revision PDF. */
296
+ write(sink: ByteSink): Promise<void>;
297
+ private writeStream;
298
+ /** Frees per-object state once the output is written; only the plugin expectations stay. */
299
+ releaseState(): Promise<void>;
300
+ cleanup(): Promise<void>;
301
+ }
302
+ export {};