@patrickdk77/pdf-defuse 0.2.2 → 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/dist/walker.js CHANGED
@@ -47,6 +47,7 @@ const sniff_1 = require("./sniff");
47
47
  const types_1 = require("./types");
48
48
  const uri_1 = require("./uri");
49
49
  const writer_1 = require("./writer");
50
+ const xfa_1 = require("./xfa");
50
51
  const ACTION_TYPES = new Set([
51
52
  'GoTo',
52
53
  'GoToR',
@@ -366,6 +367,14 @@ class Walker {
366
367
  */
367
368
  fileDecisions = new Map();
368
369
  jsReplace = new Map();
370
+ /** XFA packet streams with their cleaned text, by object number. */
371
+ xfaReplace = new Map();
372
+ /** Per XFA entry, by owner and path, the value phase A decided to write, or null when the form goes whole. */
373
+ xfaDone = new Map();
374
+ /** Set once an XFA form goes into the output, so the catalog keeps NeedsRendering. */
375
+ xfaKept = false;
376
+ /** XFA packet streams, which stay packets whatever their /Type says. Some producers mark them as embedded files. */
377
+ xfaRole = new Set();
369
378
  jsTree;
370
379
  efTree;
371
380
  keptDocScripts = [];
@@ -440,6 +449,12 @@ class Walker {
440
449
  this.push(f);
441
450
  return f;
442
451
  }
452
+ /** What a script plugin found in a script, at the script's location unless the plugin gave a finer one. */
453
+ pushScript(found, location) {
454
+ for (const f of found ?? []) {
455
+ this.push({ ...f, action: f.action === 'reject' ? 'strip' : f.action, location: f.location ?? location, data: f.data && { ...f.data } });
456
+ }
457
+ }
443
458
  /**
444
459
  * Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
445
460
  * A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
@@ -1192,7 +1207,7 @@ class Walker {
1192
1207
  return undefined;
1193
1208
  // A stream decided as a contained file stays one, whether or not it says /Type /EmbeddedFile, unless it is a page's
1194
1209
  // content: only the reference that made it a contained file goes then.
1195
- let kind = obj instanceof objects_1.PdfStream && this.fileDecisions.has(num) ? 'efStream' : this.kindOf(obj, num, await this.actionType(d));
1210
+ let kind = obj instanceof objects_1.PdfStream && this.xfaRole.has(num) ? 'generic' : obj instanceof objects_1.PdfStream && this.fileDecisions.has(num) ? 'efStream' : this.kindOf(obj, num, await this.actionType(d));
1196
1211
  if (kind === 'efStream' && this.contentRole.has(num))
1197
1212
  kind = 'generic';
1198
1213
  // Nothing reads a stream encrypted with a key the password does not give, and pdf.js draws a page without it.
@@ -1370,14 +1385,13 @@ class Walker {
1370
1385
  continue;
1371
1386
  }
1372
1387
  if (k === 'XFA') {
1373
- const f = this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'form');
1374
- if (f.action === 'strip') {
1375
- await this.reach(v);
1376
- continue;
1377
- }
1388
+ const x = await this.handleXfa(v, child, owner, p);
1389
+ if (x !== undefined)
1390
+ out.set(k, x);
1391
+ continue;
1378
1392
  }
1379
- // XFA rendering goes with any rewrite. A file passed through unchanged keeps it, so it is no removal.
1380
- if (k === 'NeedsRendering' && this.phase === 'B')
1393
+ // XFA rendering goes with the XFA form. A file passed through unchanged keeps it, so it is no removal.
1394
+ if (k === 'NeedsRendering' && this.phase === 'B' && !this.xfaKept)
1381
1395
  continue;
1382
1396
  if (k === 'Metadata') {
1383
1397
  // XMP, on the catalog, a page, an image or anything else.
@@ -1712,6 +1726,314 @@ class Walker {
1712
1726
  await this.alsoAction(d, ctx, owner, path, out, taken);
1713
1727
  return this.handleGeneric(d, { location: 'document names', shadow: ctx.shadow }, owner, path, taken, out);
1714
1728
  }
1729
+ // ---------- XFA ----------
1730
+ /**
1731
+ * An XFA form: its packets are read, every script goes to the script plugins, and what reaches outside the form
1732
+ * goes. A form that cannot be read exactly goes whole, and so does every form when FORM/XFA is set to strip.
1733
+ */
1734
+ async handleXfa(v, ctx, owner, path) {
1735
+ const key = owner + path;
1736
+ if (this.phase === 'B') {
1737
+ const done = this.xfaDone.get(key);
1738
+ return done ? this.tv(done, ctx, owner, path) : undefined;
1739
+ }
1740
+ if (ctx.shadow) {
1741
+ await this.reach(v);
1742
+ return undefined;
1743
+ }
1744
+ const cached = this.xfaDone.get(key);
1745
+ if (cached !== undefined)
1746
+ return cached === null ? undefined : this.tv(cached, ctx, owner, path);
1747
+ let out = null;
1748
+ if (this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'form').action !== 'strip') {
1749
+ try {
1750
+ out = await this.cleanXfa(v);
1751
+ }
1752
+ catch (e) {
1753
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
1754
+ throw e;
1755
+ this.passOn(e);
1756
+ const reason = e instanceof xfa_1.XfaReadError ? e.message : `it could not be decoded (${String(e?.message ?? e).slice(0, 80)})`;
1757
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.XfaUnreadable, 'form', { reason });
1758
+ }
1759
+ }
1760
+ this.xfaDone.set(key, out);
1761
+ if (out === null) {
1762
+ await this.reach(v);
1763
+ return undefined;
1764
+ }
1765
+ this.xfaKept = true;
1766
+ return this.tv(out, ctx, owner, path);
1767
+ }
1768
+ /** Returns the XFA entry to write. Cleaned packet text goes into xfaReplace, keyed by the stream that holds it. */
1769
+ async cleanXfa(v) {
1770
+ const streamRef = async (r) => {
1771
+ if (!(r instanceof objects_1.PdfRef))
1772
+ throw new xfa_1.XfaReadError('a packet that is not a stream');
1773
+ let ref = r;
1774
+ let o = await this.doc.getObject(ref);
1775
+ for (let i = 1; i < 32 && o instanceof objects_1.PdfRef; i++) {
1776
+ ref = o;
1777
+ o = await this.doc.getObject(ref);
1778
+ }
1779
+ if (!(o instanceof objects_1.PdfStream))
1780
+ throw new xfa_1.XfaReadError('a packet that is not a stream');
1781
+ if (!this.doc.canDecrypt(o, ref.num))
1782
+ throw new xfa_1.XfaReadError('a packet encrypted with a key the password does not give');
1783
+ return { ref, stream: o };
1784
+ };
1785
+ const value = await this.doc.resolve(v);
1786
+ const packets = [];
1787
+ if (value instanceof objects_1.PdfStream)
1788
+ packets.push(await streamRef(v));
1789
+ else if (Array.isArray(value)) {
1790
+ if (value.length % 2)
1791
+ throw new xfa_1.XfaReadError('a packet array of odd length');
1792
+ for (let i = 0; i < value.length; i += 2) {
1793
+ if (!(value[i] instanceof objects_1.PdfString))
1794
+ throw new xfa_1.XfaReadError('a packet without a name');
1795
+ packets.push({ name: value[i], ...(await streamRef(value[i + 1])) });
1796
+ }
1797
+ }
1798
+ else
1799
+ throw new xfa_1.XfaReadError('an XFA entry that is neither a stream nor a packet array');
1800
+ if (new Set(packets.map(p => p.ref.num)).size !== packets.length)
1801
+ throw new xfa_1.XfaReadError('a packet stream listed twice');
1802
+ for (const p of packets)
1803
+ this.xfaRole.add(p.ref.num);
1804
+ const bodies = [];
1805
+ for (const p of packets) {
1806
+ const bytes = await this.doc.decode(p.stream, p.ref.num, this.options.limits?.decompressedBytes);
1807
+ bodies.push(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString('latin1'));
1808
+ }
1809
+ const whole = bodies.join('');
1810
+ try {
1811
+ new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(whole, 'latin1'));
1812
+ }
1813
+ catch {
1814
+ throw new xfa_1.XfaReadError('bytes that are not UTF-8');
1815
+ }
1816
+ const plan = (0, xfa_1.planXfa)(whole, { stripMetadata: this.options.stripMetadata });
1817
+ const edits = [];
1818
+ const cut = [];
1819
+ const inCut = (a, b) => cut.some(([x, y]) => a >= x && b <= y);
1820
+ const where = (packet, som) => `XFA ${packet}${som ? `, ${som}` : ''}`;
1821
+ for (const r of plan.removals) {
1822
+ if (inCut(r.start, r.end))
1823
+ continue;
1824
+ const loc = where(r.packet, r.som);
1825
+ const what = short(r.what, 120);
1826
+ const f = r.kind === 'packet'
1827
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaPacket, loc, { packet: what })
1828
+ : r.kind === 'submit'
1829
+ ? this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.SubmitForm, loc, { target: what })
1830
+ : r.kind === 'execute'
1831
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExecute, loc, { connection: what })
1832
+ : r.kind === 'connection'
1833
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaConnection, loc, { connection: what })
1834
+ : r.kind === 'exObject'
1835
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalObject, loc, { object: what })
1836
+ : this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalReference, loc, { reference: what });
1837
+ if (f.action !== 'strip')
1838
+ continue;
1839
+ edits.push({ start: r.start, end: r.end, insert: '' });
1840
+ cut.push([r.start, r.end]);
1841
+ }
1842
+ for (const sc of plan.scripts) {
1843
+ if (inCut(sc.start, sc.end))
1844
+ continue;
1845
+ const d = await this.decideXfaScript(sc);
1846
+ if (!d.keep)
1847
+ edits.push({ start: sc.start, end: sc.end, insert: '' });
1848
+ else if (d.text !== undefined)
1849
+ edits.push({ start: sc.start, end: sc.end, insert: Buffer.from((0, xfa_1.escapeXmlText)(d.text), 'utf8').toString('latin1') });
1850
+ }
1851
+ for (const l of plan.links) {
1852
+ if (inCut(l.start, l.end))
1853
+ continue;
1854
+ if (!this.decideXfaLink(l.uri, where(l.packet, l.som)))
1855
+ edits.push({ start: l.start, end: l.end, insert: '' });
1856
+ }
1857
+ for (const img of plan.images) {
1858
+ if (inCut(img.start, img.end))
1859
+ continue;
1860
+ const d = await this.decideXfaImage(img, `${where(img.packet, img.som)}, image`);
1861
+ if (!d.keep)
1862
+ edits.push({ start: img.start, end: img.end, insert: '' });
1863
+ else if (d.data !== undefined)
1864
+ edits.push({ start: img.start, end: img.end, insert: d.data });
1865
+ }
1866
+ if (!edits.length)
1867
+ return v;
1868
+ const bounds = [0];
1869
+ for (const b of bodies)
1870
+ bounds.push(bounds[bounds.length - 1] + b.length);
1871
+ const per = (0, xfa_1.splitEdits)(bounds, edits);
1872
+ const kept = [];
1873
+ for (let k = 0; k < packets.length; k++) {
1874
+ const p = packets[k];
1875
+ if (per[k].length) {
1876
+ const body = (0, xfa_1.applyEdits)(bodies[k], per[k]);
1877
+ // A packet emptied whole goes from the array, name and all.
1878
+ if (packets.length > 1 && !body.trim()) {
1879
+ await this.reach(p.ref);
1880
+ continue;
1881
+ }
1882
+ this.xfaReplace.set(p.ref.num, Buffer.from(body, 'latin1'));
1883
+ }
1884
+ if (p.name)
1885
+ kept.push(p.name, p.ref);
1886
+ }
1887
+ if (value instanceof objects_1.PdfStream)
1888
+ return v;
1889
+ // The array is written in place, so an array object of its own is no longer referenced.
1890
+ if (v instanceof objects_1.PdfRef && !this.visited.has(v.num))
1891
+ this.reached.add(v.num);
1892
+ return kept;
1893
+ }
1894
+ /** Runs the script plugins over an XFA script. Returns whether it stays, and its new text when a plugin rewrote it. */
1895
+ async decideXfaScript(sc) {
1896
+ const location = `XFA ${sc.packet}${sc.som ? `, ${sc.som}` : ''}, ${sc.activity}`;
1897
+ const trigger = `xfa-${sc.activity}`;
1898
+ const verify = sc.language ? this.cfg.verify?.scripts.get(sc.text) : undefined;
1899
+ if (verify) {
1900
+ this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, location, { plugin: verify.plugin });
1901
+ return { keep: true };
1902
+ }
1903
+ const language = sc.language === 'formcalc' ? 'FormCalc' : sc.language === 'javascript' ? 'JavaScript' : short(sc.contentType || 'an unreadable form', 40);
1904
+ const f = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Xfa, location, { trigger, length: sc.text.length, language });
1905
+ let decision = { keep: f.action !== 'strip' && sc.language !== undefined };
1906
+ let answer;
1907
+ let found;
1908
+ if (f.action === 'strip' && sc.language) {
1909
+ for (const plugin of this.options.scriptPlugins ?? []) {
1910
+ const script = {
1911
+ text: sc.text,
1912
+ trigger,
1913
+ location,
1914
+ fieldName: sc.som || undefined,
1915
+ keptDocumentScripts: [...this.keptDocScripts],
1916
+ language: sc.language,
1917
+ form: 'xfa',
1918
+ };
1919
+ const data = { plugin: plugin.name };
1920
+ try {
1921
+ if (!(await plugin.accepts(script)))
1922
+ continue;
1923
+ const r = await plugin.process(script);
1924
+ if (Array.isArray(r.findings))
1925
+ found = r.findings;
1926
+ if (r.result === 'passed') {
1927
+ decision = { keep: true };
1928
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, location, data);
1929
+ this.kept.scripts.set(sc.text, { result: 'passed', plugin: plugin.name });
1930
+ }
1931
+ else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
1932
+ decision = { keep: true, text: r.text };
1933
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, location, data);
1934
+ // Written with its carriage returns as references, so the output check reads back this exact text.
1935
+ this.kept.scripts.set(r.text, { result: 'scrubbed', plugin: plugin.name });
1936
+ }
1937
+ else
1938
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, location, data);
1939
+ }
1940
+ catch (e) {
1941
+ this.passOn(e);
1942
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, location, data);
1943
+ }
1944
+ break;
1945
+ }
1946
+ if (decision.keep)
1947
+ f.action = 'info';
1948
+ }
1949
+ // A script in a language no plugin reads goes, whatever the override says.
1950
+ if (!sc.language && f.action === 'info')
1951
+ f.action = 'strip';
1952
+ this.push(f);
1953
+ if (answer)
1954
+ this.push(answer);
1955
+ this.pushScript(found, location);
1956
+ return decision;
1957
+ }
1958
+ /**
1959
+ * An image an XFA form carries inline goes to the file plugins, as an attached file does: a viewer decodes it with
1960
+ * decoders of its own. Returns whether it stays, and its new base64 when a plugin rewrote it.
1961
+ */
1962
+ async decideXfaImage(img, loc) {
1963
+ const type = short(img.contentType, 40);
1964
+ const b64 = img.data.replace(/\s+/g, '');
1965
+ // A last lone character holds six bits, no whole byte, so every decoder reads the same bytes without it.
1966
+ if (img.transferEncoding !== 'base64' || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) {
1967
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, loc, { type, reason: 'Its data is not base64' });
1968
+ return { keep: false };
1969
+ }
1970
+ const bytes = Buffer.from(b64, 'base64');
1971
+ const hash = (0, node_crypto_1.createHash)('md5').update(bytes).digest('hex');
1972
+ const expected = this.cfg.verify?.files.get(hash);
1973
+ if (expected) {
1974
+ this.emit(types_1.PdfCategory.Form, expected.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, loc, { plugin: expected.plugin });
1975
+ this.pushContained(expected.findings, loc, 'XFA image');
1976
+ return { keep: true };
1977
+ }
1978
+ const sniffed = (0, sniff_1.sniffType)(bytes.subarray(0, 1024));
1979
+ const file = { declaredType: img.contentType || undefined, sniffedType: sniffed, size: bytes.length, location: loc, depth: this.cfg.depth + 1, source: (0, io_1.bufferSource)(bytes) };
1980
+ const context = { depth: file.depth, deadline: this.cfg.deadline, [exports.RUN_OPTIONS]: this.options };
1981
+ for (const plugin of this.options.filePlugins ?? []) {
1982
+ try {
1983
+ if (!(await plugin.accepts(file)))
1984
+ continue;
1985
+ }
1986
+ catch (e) {
1987
+ this.passOn(e);
1988
+ continue;
1989
+ }
1990
+ const outSink = new io_1.SpillSink(this.cfg.temp, Number.POSITIVE_INFINITY);
1991
+ try {
1992
+ const out = await plugin.process(file, outSink, context);
1993
+ const r = typeof out === 'string' ? { result: out } : (out ?? {});
1994
+ await outSink.close();
1995
+ const data = { plugin: plugin.name };
1996
+ const written = outSink.length > 0 ? Buffer.from(await outSink.source().read(0, outSink.length)) : undefined;
1997
+ this.pushContained(r.findings, loc, 'XFA image');
1998
+ if (r.result === 'passed' || (r.result === 'scrubbed' && written)) {
1999
+ this.emit(types_1.PdfCategory.Form, r.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, loc, data);
2000
+ // Bytes the plugin wrote are the ones kept.
2001
+ this.kept.files.set(written ? outSink.digest() : hash, { result: r.result, plugin: plugin.name, findings: r.outputFindings });
2002
+ return { keep: true, data: written && !written.equals(bytes) ? written.toString('base64') : undefined };
2003
+ }
2004
+ this.emitRemoval(types_1.PdfCategory.Form, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, loc, data);
2005
+ return { keep: false };
2006
+ }
2007
+ catch (e) {
2008
+ this.passOn(e);
2009
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, loc, { plugin: plugin.name });
2010
+ return { keep: false };
2011
+ }
2012
+ finally {
2013
+ await outSink.dispose();
2014
+ }
2015
+ }
2016
+ const f = this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, loc, { type: type || sniffed || '' });
2017
+ return { keep: f.action !== 'strip' };
2018
+ }
2019
+ /** A link in an XFA form's rich text, checked as a link action's address is. Returns whether it stays. */
2020
+ decideXfaLink(uri, loc) {
2021
+ const verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2022
+ if (verdict.detail === types_1.PdfDetail.Safe) {
2023
+ const f = this.aggregate(types_1.PdfCategory.Link, types_1.PdfDetail.Safe, data => {
2024
+ const hosts = String(data.hosts ?? '')
2025
+ .split(', ')
2026
+ .filter(Boolean);
2027
+ if (verdict.host && hosts.length < 20 && !hosts.includes(verdict.host))
2028
+ hosts.push(verdict.host);
2029
+ data.hosts = hosts.join(', ');
2030
+ });
2031
+ return f.action !== 'strip';
2032
+ }
2033
+ const target = { uri: short(uri, 120), ...(verdict.reason ? { reason: verdict.reason } : {}) };
2034
+ const f = verdict.detail === types_1.PdfDetail.Url ? this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, loc, target) : this.emit(types_1.PdfCategory.Link, verdict.detail, loc, target);
2035
+ return f.action !== 'strip';
2036
+ }
1715
2037
  async handleAcroForm(d, ctx, owner, path) {
1716
2038
  const out = new objects_1.PdfDict();
1717
2039
  const taken = new Set();
@@ -2258,14 +2580,25 @@ class Walker {
2258
2580
  const f = this.cfg.factory.make(types_1.PdfCategory.JavaScript, ctx.jsDetail ?? types_1.PdfDetail.Annotation, ctx.location, { trigger: ctx.trigger ?? 'unknown', length });
2259
2581
  let decision = { keep: f.action !== 'strip' };
2260
2582
  let answer;
2583
+ let found;
2261
2584
  if (f.action === 'strip' && !ctx.shadow && src !== undefined) {
2262
2585
  for (const plugin of this.options.scriptPlugins ?? []) {
2263
- const script = { text: src, trigger: ctx.trigger ?? 'unknown', location: ctx.location, fieldName: ctx.fieldName, keptDocumentScripts: [...this.keptDocScripts] };
2586
+ const script = {
2587
+ text: src,
2588
+ trigger: ctx.trigger ?? 'unknown',
2589
+ location: ctx.location,
2590
+ fieldName: ctx.fieldName,
2591
+ keptDocumentScripts: [...this.keptDocScripts],
2592
+ language: 'javascript',
2593
+ form: 'acroform',
2594
+ };
2264
2595
  const data = { plugin: plugin.name };
2265
2596
  try {
2266
2597
  if (!(await plugin.accepts(script)))
2267
2598
  continue;
2268
2599
  const r = await plugin.process(script);
2600
+ if (Array.isArray(r.findings))
2601
+ found = r.findings;
2269
2602
  if (r.result === 'passed') {
2270
2603
  decision = { keep: true };
2271
2604
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, ctx.location, data);
@@ -2305,6 +2638,7 @@ class Walker {
2305
2638
  this.push(f);
2306
2639
  if (answer)
2307
2640
  this.push(answer);
2641
+ this.pushScript(found, ctx.location);
2308
2642
  if (first) {
2309
2643
  // One object holds one text, so a different rewrite is a refusal too, and only the slot can report it.
2310
2644
  if (first.keep && (!decision.keep || decision.text !== first.text)) {
@@ -2353,13 +2687,16 @@ class Walker {
2353
2687
  // Decided again for another slot: only a script can be decided differently, and the rest was settled before.
2354
2688
  }
2355
2689
  else if (s === 'URI') {
2356
- // Every reading a reader may take has to pass.
2690
+ // Every reading a reader may take has to pass. A relative reading stays, so it does not end the check.
2357
2691
  const readings = uriReadings(await this.doc.resolve(d.get('URI')));
2358
2692
  let uri = readings[0];
2359
2693
  let verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2360
- for (let i = 1; i < readings.length && verdict.detail === types_1.PdfDetail.Safe; i++) {
2694
+ for (let i = 1; i < readings.length && (verdict.detail === types_1.PdfDetail.Safe || verdict.detail === types_1.PdfDetail.Relative); i++) {
2695
+ const next = (0, uri_1.checkUri)(readings[i], this.uriBase);
2696
+ if (next.detail === types_1.PdfDetail.Safe && verdict.detail === types_1.PdfDetail.Relative)
2697
+ continue;
2361
2698
  uri = readings[i];
2362
- verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2699
+ verdict = next;
2363
2700
  }
2364
2701
  if (verdict.detail === types_1.PdfDetail.Safe) {
2365
2702
  // A link being removed for another reason is not "kept", so it is not counted as safe.
@@ -2376,7 +2713,8 @@ class Walker {
2376
2713
  keep = (f?.action ?? this.cfg.factory.actionFor(types_1.PdfCategory.Link, types_1.PdfDetail.Safe)) !== 'strip';
2377
2714
  }
2378
2715
  else {
2379
- const f = verdict.detail === types_1.PdfDetail.Url ? this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, loc, { uri: short(uri, 120) }) : this.emit(types_1.PdfCategory.Link, verdict.detail, loc, { uri: short(uri, 120) });
2716
+ const target = { uri: short(uri, 120), ...(verdict.reason ? { reason: verdict.reason } : {}) };
2717
+ const f = verdict.detail === types_1.PdfDetail.Url ? this.emit(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, loc, target) : this.emit(types_1.PdfCategory.Link, verdict.detail, loc, target);
2380
2718
  keep = f.action !== 'strip';
2381
2719
  }
2382
2720
  }
@@ -2831,6 +3169,7 @@ class Walker {
2831
3169
  async writeStream(out, nn, s, num, mapRef) {
2832
3170
  const dict = s.dict.clone();
2833
3171
  const replaceJs = this.jsReplace.get(num);
3172
+ const replaceXfa = this.xfaReplace.get(num);
2834
3173
  const fileDec = this.fileDecisions.get(num);
2835
3174
  let body;
2836
3175
  let compressed;
@@ -2841,6 +3180,14 @@ class Walker {
2841
3180
  dict.set('Length', replaceJs.length);
2842
3181
  body = [replaceJs];
2843
3182
  }
3183
+ else if (replaceXfa) {
3184
+ const z = zlib.deflateSync(replaceXfa);
3185
+ dict.set('Filter', new objects_1.PdfName('FlateDecode'));
3186
+ dict.delete('DecodeParms');
3187
+ dict.delete('DL');
3188
+ dict.set('Length', z.length);
3189
+ body = [z];
3190
+ }
2844
3191
  else if (fileDec?.replacement) {
2845
3192
  const sink = new io_1.SpillSink(this.cfg.temp, this.options.memoryThreshold ?? 8 * 1024 * 1024);
2846
3193
  compressed = sink;
@@ -2916,6 +3263,9 @@ class Walker {
2916
3263
  this.queue = [];
2917
3264
  this.scriptDecisions.clear();
2918
3265
  this.jsReplace.clear();
3266
+ this.xfaReplace.clear();
3267
+ this.xfaDone.clear();
3268
+ this.xfaRole.clear();
2919
3269
  this.annotPage.clear();
2920
3270
  this.altText.clear();
2921
3271
  this.refusedSlots.clear();
package/dist/xfa.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Reads an XFA form's XML, the XDP, and plans what a defused copy keeps. The text is the packets' bytes read as
3
+ * latin1, so every offset is a byte offset into the concatenated packets. The reader is strict: anything it does not
4
+ * read exactly as a viewer would, such as a DOCTYPE or an unknown entity, fails the whole form instead of being
5
+ * guessed at.
6
+ */
7
+ export interface XAttr {
8
+ name: string;
9
+ local: string;
10
+ /** Decoded. */
11
+ value: string;
12
+ /** From the whitespace before the name to the closing quote, so cutting it leaves the tag well formed. */
13
+ start: number;
14
+ end: number;
15
+ }
16
+ export interface XElement {
17
+ name: string;
18
+ local: string;
19
+ attrs: XAttr[];
20
+ start: number;
21
+ /** Just after the start tag's `>`. */
22
+ openEnd: number;
23
+ /** At the end tag's `<`, or openEnd for an empty element. */
24
+ closeStart: number;
25
+ end: number;
26
+ children: XElement[];
27
+ /** Character data directly inside, as ranges: text with entities, or CDATA without its markers. */
28
+ texts: Array<{
29
+ start: number;
30
+ end: number;
31
+ cdata: boolean;
32
+ }>;
33
+ parent?: XElement;
34
+ }
35
+ export declare class XfaReadError extends Error {
36
+ }
37
+ /** Decodes entity and character references in latin1 text read from UTF-8 bytes, then the UTF-8. */
38
+ export declare function decodeXmlText(raw: string): string;
39
+ /** Escapes text to go back between tags. A carriage return is written as a reference, so a reader keeps it. */
40
+ export declare function escapeXmlText(s: string): string;
41
+ /** Reads the XDP. It has one root element, and nothing but comments, processing instructions and space around it. */
42
+ export declare function readXml(s: string): XElement;
43
+ /** The text directly inside an element, decoded, with line ends as a reader gives them. */
44
+ export declare function textOf(s: string, el: XElement): string;
45
+ export declare const attrOf: (el: XElement, local: string) => XAttr | undefined;
46
+ /** What a script's `<script>` element holds, for the script plugins. */
47
+ export interface XfaScript {
48
+ /** The content between the tags, which a removal empties. */
49
+ start: number;
50
+ end: number;
51
+ text: string;
52
+ /** undefined for a content type no plugin reads, or content that is not plain text. */
53
+ language?: 'javascript' | 'formcalc';
54
+ contentType: string;
55
+ /** The SOM path of the node the script belongs to. */
56
+ som: string;
57
+ activity: string;
58
+ packet: string;
59
+ }
60
+ export type XfaRemovalKind = 'packet' | 'submit' | 'execute' | 'connection' | 'exObject' | 'reference';
61
+ export interface XfaRemoval {
62
+ start: number;
63
+ end: number;
64
+ kind: XfaRemovalKind;
65
+ packet: string;
66
+ som: string;
67
+ /** What the removed part names, for the finding. */
68
+ what: string;
69
+ }
70
+ export interface XfaLink {
71
+ /** The href attribute, which an unsafe link loses. */
72
+ start: number;
73
+ end: number;
74
+ uri: string;
75
+ packet: string;
76
+ som: string;
77
+ }
78
+ /** An image a template, form or data value carries inline, which a viewer decodes. */
79
+ export interface XfaImage {
80
+ /** The content between the tags. */
81
+ start: number;
82
+ end: number;
83
+ /** The text inside, still encoded. */
84
+ data: string;
85
+ contentType: string;
86
+ transferEncoding: string;
87
+ packet: string;
88
+ som: string;
89
+ }
90
+ export interface XfaPlan {
91
+ root: XElement;
92
+ scripts: XfaScript[];
93
+ removals: XfaRemoval[];
94
+ links: XfaLink[];
95
+ images: XfaImage[];
96
+ }
97
+ /**
98
+ * Whether an image or rich text href is a relative name, which stays unless it is written to read as another path.
99
+ * An address with a scheme, a drive letter or a leading slash or backslash points outside the PDF.
100
+ */
101
+ export declare function isLocalHref(v: string): boolean;
102
+ export declare function planXfa(s: string, options?: {
103
+ stripMetadata?: boolean;
104
+ }): XfaPlan;
105
+ export interface XfaEdit {
106
+ start: number;
107
+ end: number;
108
+ insert: string;
109
+ }
110
+ /** Applies edits that do not overlap, after dropping any that falls inside another. */
111
+ export declare function applyEdits(s: string, edits: XfaEdit[]): string;
112
+ /** Splits edits of the concatenation by packet stream. `bounds` holds each stream's start and, last, the end. */
113
+ export declare function splitEdits(bounds: number[], edits: XfaEdit[]): XfaEdit[][];