@patrickdk77/pdf-defuse 0.1.2 → 0.2.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/engine.js CHANGED
@@ -39,7 +39,8 @@ function encryptionFindings(doc, factory) {
39
39
  return [];
40
40
  const out = [];
41
41
  if (r.status === 'ok') {
42
- out.push(factory.make(types_1.PdfCategory.Encrypted, r.password === 'empty' ? types_1.PdfDetail.EmptyPassword : r.password === 'owner' ? types_1.PdfDetail.OwnerPassword : types_1.PdfDetail.UserPassword));
42
+ const detail = { empty: types_1.PdfDetail.EmptyPassword, user: types_1.PdfDetail.UserPassword, owner: types_1.PdfDetail.OwnerPassword, none: types_1.PdfDetail.AttachmentsOnly }[r.password];
43
+ out.push(factory.make(types_1.PdfCategory.Encrypted, detail));
43
44
  const bits = r.handler.keyBits;
44
45
  const method = r.handler.streamMethod === 'Identity' ? r.handler.stringMethod : r.handler.streamMethod;
45
46
  if (method === 'AES256')
@@ -135,6 +136,8 @@ async function analyzeInner(run, source, options, depth, deadline, verify) {
135
136
  findings.push(...encryptionFindings(doc, factory));
136
137
  if (doc.securityResult && doc.securityResult.status !== 'ok')
137
138
  return finish(findings, options, true, doc.headerVersion);
139
+ if (doc.missingHeader)
140
+ findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MissingHeader));
138
141
  if (doc.headerOffset > 0)
139
142
  findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.LeadingBytes, undefined, { bytes: doc.headerOffset }));
140
143
  if (doc.trailingBytes > 0)
@@ -180,6 +183,14 @@ async function analyzeInner(run, source, options, depth, deadline, verify) {
180
183
  findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'cross-reference table', { reason: '/XRefStm entries for objects the table marks free', count: issues.xrefStmOverFree }));
181
184
  if (issues.badObjStm.size)
182
185
  findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'object stream', { reason: 'a header pdf.js cannot follow', count: issues.badObjStm.size }));
186
+ if (issues.objStmType.size)
187
+ findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'object stream', { reason: 'a /Type other than /ObjStm', count: issues.objStmType.size }));
188
+ if (issues.directRoot)
189
+ findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'trailer', { reason: 'a catalog written in the trailer' }));
190
+ if (issues.directStreams.size)
191
+ findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { reason: 'streams written inside other objects', count: issues.directStreams.size }));
192
+ if (issues.objStmStreams.size)
193
+ findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'object stream', { reason: 'streams stored in an object stream', count: issues.objStmStreams.size }));
183
194
  if (issues.refGen.size)
184
195
  findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { reason: 'references whose generation does not match the object', count: issues.refGen.size }));
185
196
  if (issues.duplicateKeys.size)
package/dist/filters.js CHANGED
@@ -484,6 +484,14 @@ function syncDecoder(name, parms) {
484
484
  return [new RunLengthDecoder()];
485
485
  case 'Crypt':
486
486
  return [];
487
+ case 'BrotliDecode': {
488
+ // PDFium applies a predictor named here and pdf.js ignores it, so a predicted stream reads two ways and is not
489
+ // decoded.
490
+ const predictor = parms?.get('Predictor') ?? null;
491
+ if (predictor !== null && predictor !== 1)
492
+ throw new UnsupportedFilterError('A predictor on BrotliDecode, which readers apply differently');
493
+ return [];
494
+ }
487
495
  default:
488
496
  return undefined;
489
497
  }
@@ -506,6 +514,11 @@ const SLICE = 4096;
506
514
  * context for Flate, and a few thousand nested stages overflow the stack, so a longer chain counts as unsupported.
507
515
  */
508
516
  const MAX_FILTERS = 32;
517
+ /**
518
+ * Brotli decoders in one chain. Each can take a 16 MB window from a few hundred bytes of input, and the stages of a
519
+ * chain all run at once.
520
+ */
521
+ const MAX_BROTLI = 1;
509
522
  /**
510
523
  * Where raw deflate data starts: after the zlib header when there is one. The Adler-32 trailer is then never
511
524
  * checked, as viewers do not check it.
@@ -541,20 +554,54 @@ async function* inflateChunks(input) {
541
554
  yield r.value;
542
555
  }
543
556
  }
544
- const inflater = zlib.createInflateRaw({ finishFlush: zlib.constants.Z_SYNC_FLUSH });
545
- const src = stream.Readable.from(rest());
546
- src.on('error', e => inflater.destroy(e));
547
- src.pipe(inflater);
557
+ // Truncated data ends quietly with everything decoded. Corrupt data throws: Node drops the output of the write that
558
+ // failed, so the bytes before the error are not all there.
559
+ yield* through(rest(), zlib.createInflateRaw({ finishFlush: zlib.constants.Z_SYNC_FLUSH }));
560
+ }
561
+ /** Pipes `input` through a zlib transform and yields its output. */
562
+ async function* through(input, transform) {
563
+ const src = stream.Readable.from(input);
564
+ src.on('error', e => transform.destroy(e));
565
+ src.pipe(transform);
548
566
  try {
549
- // Truncated data ends quietly with everything decoded. Corrupt data throws: Node drops the output of the write
550
- // that failed, so the bytes before the error are not all there.
551
- for await (const c of inflater)
567
+ for await (const c of transform)
552
568
  yield c;
553
569
  }
554
570
  finally {
555
571
  src.destroy();
556
- inflater.destroy();
572
+ transform.destroy();
573
+ }
574
+ }
575
+ /**
576
+ * BrotliDecode: RFC 7932 data with no header. pdf.js decodes such a stream whole or not at all, so data that is
577
+ * truncated, corrupt or followed by more bytes throws here. Empty data decodes to nothing, as in pdf.js.
578
+ */
579
+ async function* brotliChunks(input) {
580
+ const it = input[Symbol.asyncIterator]();
581
+ let first;
582
+ while (!first?.length) {
583
+ const r = await it.next();
584
+ if (r.done)
585
+ return;
586
+ first = r.value;
587
+ }
588
+ let fed = 0;
589
+ async function* rest(head) {
590
+ fed += head.length;
591
+ yield head;
592
+ for (;;) {
593
+ const r = await it.next();
594
+ if (r.done)
595
+ return;
596
+ fed += r.value.length;
597
+ yield r.value;
598
+ }
557
599
  }
600
+ const decoder = zlib.createBrotliDecompress();
601
+ yield* through(rest(first), decoder);
602
+ // Node stops reading at the end of the Brotli data and ignores what follows it.
603
+ if (decoder.bytesWritten < fed)
604
+ throw new Error('Data after the end of the Brotli stream');
558
605
  }
559
606
  /**
560
607
  * Decodes a stream chunk by chunk through its whole filter chain. No stage may produce more than `limit` bytes, and
@@ -589,12 +636,16 @@ async function* decodeChunks(input, dict, limit, checkTime) {
589
636
  (Array.isArray(f) ? p instanceof objects_1.PdfDict : Array.isArray(p) && p[0] instanceof objects_1.PdfDict)) {
590
637
  throw new UnsupportedFilterError('Filter entries that readers take in different ways');
591
638
  }
639
+ if (filters.filter(x => x.name === 'BrotliDecode').length > MAX_BROTLI)
640
+ throw new UnsupportedFilterError('More than one BrotliDecode in one chain');
592
641
  for (const { name, parms } of filters) {
593
642
  const decs = syncDecoder(name, parms);
594
643
  if (!decs)
595
644
  throw new UnsupportedFilterError(`Unsupported filter ${name}`);
596
645
  if (name === 'FlateDecode' || name === 'Fl')
597
646
  chain = counted(inflateChunks(chain));
647
+ else if (name === 'BrotliDecode')
648
+ chain = counted(brotliChunks(chain));
598
649
  for (const d of decs) {
599
650
  const upstream = chain;
600
651
  chain = counted((async function* () {
package/dist/findings.js CHANGED
@@ -13,6 +13,8 @@ def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.EmptyPassword, 'strip', 10,
13
13
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UserPassword, 'strip', 0, 'PDF decrypted with the supplied user password');
14
14
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.OwnerPassword, 'strip', 0, 'PDF decrypted with the supplied owner password');
15
15
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.PasswordRequired, 'reject', 0, 'PDF requires a password, and the one supplied did not open it');
16
+ def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.AttachmentsOnly, 'strip', 10, 'Only the attached files are encrypted, and the password supplied does not open them');
17
+ def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.NoKey, 'strip', 10, "Content encrypted with the attached files' key was removed, since the password supplied does not give that key");
16
18
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.CertificateHandler, 'reject', 0, "PDF encrypted for specific recipients' certificates");
17
19
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownHandler, 'reject', 0, 'PDF uses an unrecognized security handler');
18
20
  def(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownCryptFilter, 'reject', 0, 'PDF uses an unrecognized encryption filter');
@@ -30,6 +32,7 @@ def(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'strip', 1
30
32
  def(types_1.PdfCategory.Corrupted, types_1.PdfDetail.StreamLengthWrong, 'strip', 10, "A stream's declared length is missing or wrong");
31
33
  def(types_1.PdfCategory.Corrupted, types_1.PdfDetail.LeadingBytes, 'strip', 5, 'Data before the PDF header');
32
34
  def(types_1.PdfCategory.Corrupted, types_1.PdfDetail.TrailingBytes, 'strip', 10, 'Data after the end of the PDF');
35
+ def(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MissingHeader, 'strip', 5, 'No PDF header at the start of the file');
33
36
  // JAVASCRIPT
34
37
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Document, 'strip', 70, 'Document-level JavaScript runs when the PDF opens');
35
38
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.OpenAction, 'strip', 70, 'JavaScript runs when the PDF opens');
package/dist/io.d.ts CHANGED
@@ -26,7 +26,7 @@ export declare class Reader {
26
26
  private readonly order;
27
27
  constructor(source: ByteSource, size: number, blockSize?: number, maxBlocks?: number);
28
28
  private block;
29
- /** Reads up to `length` bytes at `offset`; shorter only at the end of the source. */
29
+ /** Reads up to `length` bytes at `offset`; shorter only at the end of the source, and empty outside it. */
30
30
  read(offset: number, length: number): Promise<Uint8Array>;
31
31
  /** Yields the range in chunks without caching them. */
32
32
  chunks(offset: number, length: number, chunkSize?: number): AsyncGenerator<Uint8Array>;
package/dist/io.js CHANGED
@@ -44,11 +44,23 @@ const crypto = __importStar(require("node:crypto"));
44
44
  const fsp = __importStar(require("node:fs/promises"));
45
45
  const os = __importStar(require("node:os"));
46
46
  const path = __importStar(require("node:path"));
47
+ /**
48
+ * The bytes [start, end) a read covers in a source of `size` bytes, or undefined when it covers none. Offsets read
49
+ * from a file can be negative or fractional, and such a read gets no bytes rather than bytes from somewhere else.
50
+ */
51
+ function span(offset, length, size) {
52
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= size || !(length >= 1))
53
+ return undefined;
54
+ return [offset, Math.min(size, offset + Math.floor(length))];
55
+ }
47
56
  /** A source over bytes already in memory. Reads return views, not copies. */
48
57
  function bufferSource(bytes) {
49
58
  return {
50
59
  size: async () => bytes.length,
51
- read: async (offset, length) => bytes.subarray(Math.max(0, offset), Math.min(bytes.length, offset + length)),
60
+ read: async (offset, length) => {
61
+ const s = span(offset, length, bytes.length);
62
+ return s ? bytes.subarray(s[0], s[1]) : new Uint8Array(0);
63
+ },
52
64
  };
53
65
  }
54
66
  /** A source over a local file, read at offsets through one file handle. */
@@ -68,8 +80,11 @@ function fileSource(filePath) {
68
80
  },
69
81
  async read(offset, length) {
70
82
  const h = await open();
71
- const total = await this.size();
72
- const n = Math.max(0, Math.min(length, total - offset));
83
+ // Node reads position -1 from the file's current position, so a negative offset never reaches it.
84
+ const s = span(offset, length, await this.size());
85
+ if (!s)
86
+ return Buffer.alloc(0);
87
+ const n = s[1] - offset;
73
88
  const buf = Buffer.allocUnsafe(n);
74
89
  let done = 0;
75
90
  while (done < n) {
@@ -208,11 +223,12 @@ class Reader {
208
223
  }
209
224
  return data;
210
225
  }
211
- /** Reads up to `length` bytes at `offset`; shorter only at the end of the source. */
226
+ /** Reads up to `length` bytes at `offset`; shorter only at the end of the source, and empty outside it. */
212
227
  async read(offset, length) {
213
- if (offset >= this.size || length <= 0)
228
+ const s = span(offset, length, this.size);
229
+ if (!s)
214
230
  return new Uint8Array(0);
215
- const end = Math.min(this.size, offset + length);
231
+ const end = s[1];
216
232
  if (end - offset > this.blockSize * 4)
217
233
  return this.source.read(offset, end - offset);
218
234
  const first = Math.floor(offset / this.blockSize);
@@ -234,8 +250,11 @@ class Reader {
234
250
  }
235
251
  /** Yields the range in chunks without caching them. */
236
252
  async *chunks(offset, length, chunkSize = 262144) {
253
+ const s = span(offset, length, this.size);
254
+ if (!s)
255
+ return;
237
256
  let pos = offset;
238
- const end = Math.min(this.size, offset + length);
257
+ const end = s[1];
239
258
  while (pos < end) {
240
259
  const n = Math.min(chunkSize, end - pos);
241
260
  const c = await this.source.read(pos, n);
@@ -292,8 +311,12 @@ class TempDir {
292
311
  return size;
293
312
  }),
294
313
  read: (offset, length) => keep(async () => {
295
- const b = await source.read(offset, length);
296
- const want = size === undefined ? b.length : Math.max(0, Math.min(length, size - offset));
314
+ // A read outside the source is not passed on: a caller's source, such as a ranged S3 request, may fail on it.
315
+ const s = span(offset, length, size ?? Number.MAX_SAFE_INTEGER);
316
+ if (!s)
317
+ return new Uint8Array(0);
318
+ const b = await source.read(offset, s[1] - offset);
319
+ const want = size === undefined ? b.length : s[1] - offset;
297
320
  if (b.length !== want)
298
321
  throw Object.assign(new Error(`The source returned ${b.length} bytes at offset ${offset} where ${want} were expected`), { code: 'EIO' });
299
322
  return b;
package/dist/parser.d.ts CHANGED
@@ -1,10 +1,53 @@
1
1
  import { type PdfObject, PdfRef } from './objects';
2
2
  export declare class ParseError extends Error {
3
3
  }
4
- /** Thrown when the window ended before the object did. The caller retries with more bytes. */
4
+ /** The data ended inside an object. */
5
+ export declare class EndOfData extends ParseError {
6
+ }
7
+ /**
8
+ * Thrown when the window ended before the object did. The caller retries with more bytes. `idle` says the window ended
9
+ * in a run of whitespace, and of comments when `comments` is set, that started at window position `from`, so the
10
+ * caller can skip the rest of the run in the source instead of holding it. `body` says it ended in the body of a stream
11
+ * written inside an object, and `line` before the end of the line after a "stream" keyword. The caller can find either
12
+ * end in the source.
13
+ */
5
14
  export declare class NeedMoreData extends Error {
15
+ readonly idle?: {
16
+ from: number;
17
+ comments: boolean;
18
+ } | undefined;
19
+ readonly body?: {
20
+ start: number;
21
+ declared?: number;
22
+ } | undefined;
23
+ readonly line?: {
24
+ from: number;
25
+ } | undefined;
26
+ constructor(idle?: {
27
+ from: number;
28
+ comments: boolean;
29
+ } | undefined, body?: {
30
+ start: number;
31
+ declared?: number;
32
+ } | undefined, line?: {
33
+ from: number;
34
+ } | undefined);
35
+ }
36
+ /** Thrown when a stream inside an object has a /Length the caller has not read yet. The caller reads it and retries. */
37
+ export declare class NeedObject extends Error {
38
+ readonly ref: PdfRef;
39
+ constructor(ref: PdfRef);
6
40
  }
7
41
  export declare const isWhite: (c: number) => boolean;
42
+ /**
43
+ * Where pdf.js ends a stream body it searches for: at "endstream", or at "endsteam" or "endstrea" before a space, tab,
44
+ * CR or LF, which it takes too. Returns the first such place in `b` from `from` on and the keyword's length. A
45
+ * misspelling whose next byte `b` does not hold is left for a longer window.
46
+ */
47
+ export declare function endstreamIn(b: Uint8Array, from: number): {
48
+ at: number;
49
+ length: number;
50
+ } | undefined;
8
51
  interface ParseHooks {
9
52
  /** Called when a name used #xx to encode a letter or digit. */
10
53
  onEscapedName?(name: string): void;
@@ -19,6 +62,35 @@ interface ParseHooks {
19
62
  /** Called every 65536 values, so a caller can stop a long parse at its deadline. */
20
63
  onProgress?(): void;
21
64
  }
65
+ /** How to read a stream written inside an object, as pdf.js reads one, and the end of the line after "stream". */
66
+ export interface DirectStreams {
67
+ /** Where window byte `pos` sits in the source. */
68
+ at(pos: number): number;
69
+ /** The value of an indirect /Length. Throws NeedObject when the caller has not read it yet. */
70
+ length(ref: PdfRef): number | undefined;
71
+ /**
72
+ * A body the caller found in the source, by where it starts there: its length, or null when no "endstream" follows.
73
+ * The window then holds one byte for the body and goes on after "endstream".
74
+ */
75
+ known(start: number): {
76
+ length: number;
77
+ } | null | undefined;
78
+ /**
79
+ * The line after a "stream" keyword that the caller read in the source, by where it starts there: whether it held
80
+ * more than spaces. The window then goes on where the body starts.
81
+ */
82
+ line(start: number): {
83
+ text: boolean;
84
+ } | undefined;
85
+ }
86
+ export interface ParseOptions {
87
+ /** Ends a dictionary or array that the end of the data cuts off, as pdf.js reads a trailer it recovers. */
88
+ recover?: boolean;
89
+ /** Reads a dictionary followed by "stream" anywhere inside an object as a stream. */
90
+ streams?: DirectStreams;
91
+ /** Reads the object itself as a stream in the same way, for an object stored in an object stream. */
92
+ topStream?: boolean;
93
+ }
22
94
  /**
23
95
  * Parses PDF objects from a byte window. `eof` tells the parser whether the window ends at the end of the source,
24
96
  * so running off the end means NeedMoreData rather than a syntax error.
@@ -27,6 +99,7 @@ export declare class Parser {
27
99
  readonly buf: Uint8Array;
28
100
  readonly eof: boolean;
29
101
  readonly hooks: ParseHooks;
102
+ readonly options: ParseOptions;
30
103
  pos: number;
31
104
  depth: number;
32
105
  /** Estimated heap bytes of what this parser has built, with the length of every name and string. */
@@ -34,12 +107,21 @@ export declare class Parser {
34
107
  /** The part of `cost` that does not grow with the length of a name or string. */
35
108
  private overhead;
36
109
  private values;
37
- constructor(buf: Uint8Array, pos?: number, eof?: boolean, hooks?: ParseHooks);
110
+ /** The part of `cost` that the dictionaries of streams written inside the object take. */
111
+ directCost: number;
112
+ constructor(buf: Uint8Array, pos?: number, eof?: boolean, hooks?: ParseHooks, options?: ParseOptions);
113
+ /** In recovery, whether nothing but whitespace and comments is left before the end of the data. */
114
+ private cutOff;
38
115
  /** Rough heap costs: a slot for a number or keyword, an object for a name or reference, a buffer for a string. */
39
116
  private charge;
40
117
  private more;
41
118
  peek(): number;
42
119
  skipWhitespace(): void;
120
+ /**
121
+ * Skips whitespace and comments, and says whether anything follows them. A window that ends first asks for more,
122
+ * unless it ends the data.
123
+ */
124
+ private skipToToken;
43
125
  /** Reads a regular-character token without consuming delimiters. */
44
126
  readToken(): string;
45
127
  matchKeyword(word: string): boolean;
@@ -55,9 +137,16 @@ export declare class Parser {
55
137
  /**
56
138
  * Skips junk inside a container instead of losing the whole object: an unknown bare keyword, or a delimiter that
57
139
  * cannot start an object, such as "]" in a dictionary or ">>" in an array. qpdf reads each of these as null. The
58
- * container's own closing delimiter was checked before this is called.
140
+ * container's own closing delimiter was checked before this is called. Where a key belongs, pdf.js also skips a
141
+ * number, "R", "true", "false" or "null" one token at a time, and so does this.
59
142
  */
60
143
  private skipBadToken;
144
+ /**
145
+ * After a dictionary inside an object, reads the stream that follows it, if any, as pdf.js does: the body ends at
146
+ * /Length when "endstream" follows there, and at the first "endstream" otherwise. Returns undefined when no stream
147
+ * follows. The body stays in the source; the stream records where it is.
148
+ */
149
+ private directStream;
61
150
  /**
62
151
  * After a dictionary, checks for the "stream" keyword. Returns the position of the first body byte,
63
152
  * or -1 when the object is not a stream.