@patrickdk77/pdf-defuse 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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',
@@ -335,6 +336,8 @@ class Walker {
335
336
  againBudget = 16 * 1024 * 1024;
336
337
  /** 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. */
337
338
  shadowScripts = new Map();
339
+ /** Scripts a plugin answered for. Its answer is final, so what a shadow visit made of them is not reported. */
340
+ answeredScripts = new Set();
338
341
  /** Information dictionaries and XMP streams removed for the caller. */
339
342
  strippedMeta = new Set();
340
343
  /** Bytes of scrubbed attachments held in memory until the write. */
@@ -366,6 +369,14 @@ class Walker {
366
369
  */
367
370
  fileDecisions = new Map();
368
371
  jsReplace = new Map();
372
+ /** XFA packet streams with their cleaned text, by object number. */
373
+ xfaReplace = new Map();
374
+ /** Per XFA entry, by owner and path, the value phase A decided to write, or null when the form goes whole. */
375
+ xfaDone = new Map();
376
+ /** Set once an XFA form goes into the output, so the catalog keeps NeedsRendering. */
377
+ xfaKept = false;
378
+ /** XFA packet streams, which stay packets whatever their /Type says. Some producers mark them as embedded files. */
379
+ xfaRole = new Set();
369
380
  jsTree;
370
381
  efTree;
371
382
  keptDocScripts = [];
@@ -440,6 +451,12 @@ class Walker {
440
451
  this.push(f);
441
452
  return f;
442
453
  }
454
+ /** What a script plugin found in a script, at the script's location unless the plugin gave a finer one. */
455
+ pushScript(found, location) {
456
+ for (const f of found ?? []) {
457
+ this.push({ ...f, action: f.action === 'reject' ? 'strip' : f.action, location: f.location ?? location, data: f.data && { ...f.data } });
458
+ }
459
+ }
443
460
  /**
444
461
  * Adds the findings a plugin reported from inside a contained file, labeled with the file's name and place.
445
462
  * A reject inside the file means the file goes, not the upload, so it is recorded here as strip.
@@ -1037,9 +1054,10 @@ class Walker {
1037
1054
  d.types.decoded = undefined;
1038
1055
  await (await decoded?.catch(() => undefined))?.close?.();
1039
1056
  }
1040
- for (const list of this.shadowScripts.values())
1041
- for (const f of list)
1042
- this.push(f);
1057
+ for (const [key, list] of this.shadowScripts)
1058
+ if (!this.answeredScripts.has(key))
1059
+ for (const f of list)
1060
+ this.push(f);
1043
1061
  if (this.dropped.size || this.refusedSlots.size)
1044
1062
  this.removedAt ??= 'document';
1045
1063
  if (this.coPresent && this.calcRemoved && this.keptCalcFields.size === 0)
@@ -1192,7 +1210,7 @@ class Walker {
1192
1210
  return undefined;
1193
1211
  // A stream decided as a contained file stays one, whether or not it says /Type /EmbeddedFile, unless it is a page's
1194
1212
  // 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));
1213
+ 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
1214
  if (kind === 'efStream' && this.contentRole.has(num))
1197
1215
  kind = 'generic';
1198
1216
  // Nothing reads a stream encrypted with a key the password does not give, and pdf.js draws a page without it.
@@ -1370,14 +1388,13 @@ class Walker {
1370
1388
  continue;
1371
1389
  }
1372
1390
  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
- }
1391
+ const x = await this.handleXfa(v, child, owner, p);
1392
+ if (x !== undefined)
1393
+ out.set(k, x);
1394
+ continue;
1378
1395
  }
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')
1396
+ // XFA rendering goes with the XFA form. A file passed through unchanged keeps it, so it is no removal.
1397
+ if (k === 'NeedsRendering' && this.phase === 'B' && !this.xfaKept)
1381
1398
  continue;
1382
1399
  if (k === 'Metadata') {
1383
1400
  // XMP, on the catalog, a page, an image or anything else.
@@ -1712,6 +1729,320 @@ class Walker {
1712
1729
  await this.alsoAction(d, ctx, owner, path, out, taken);
1713
1730
  return this.handleGeneric(d, { location: 'document names', shadow: ctx.shadow }, owner, path, taken, out);
1714
1731
  }
1732
+ // ---------- XFA ----------
1733
+ /**
1734
+ * An XFA form: its packets are read, every script goes to the script plugins, and what reaches outside the form
1735
+ * goes. A form that cannot be read exactly goes whole, and so does every form when FORM/XFA is set to strip.
1736
+ */
1737
+ async handleXfa(v, ctx, owner, path) {
1738
+ const key = owner + path;
1739
+ if (this.phase === 'B') {
1740
+ const done = this.xfaDone.get(key);
1741
+ return done ? this.tv(done, ctx, owner, path) : undefined;
1742
+ }
1743
+ if (ctx.shadow) {
1744
+ await this.reach(v);
1745
+ return undefined;
1746
+ }
1747
+ const cached = this.xfaDone.get(key);
1748
+ if (cached !== undefined)
1749
+ return cached === null ? undefined : this.tv(cached, ctx, owner, path);
1750
+ let out = null;
1751
+ if (this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.Xfa, 'form').action !== 'strip') {
1752
+ try {
1753
+ out = await this.cleanXfa(v);
1754
+ }
1755
+ catch (e) {
1756
+ if (e instanceof filters_1.DecompressionLimitError || e instanceof document_1.TimeLimitError || e instanceof document_1.ObjectLimitError)
1757
+ throw e;
1758
+ this.passOn(e);
1759
+ const reason = e instanceof xfa_1.XfaReadError ? e.message : `it could not be decoded (${String(e?.message ?? e).slice(0, 80)})`;
1760
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.XfaUnreadable, 'form', { reason });
1761
+ }
1762
+ }
1763
+ this.xfaDone.set(key, out);
1764
+ if (out === null) {
1765
+ await this.reach(v);
1766
+ return undefined;
1767
+ }
1768
+ this.xfaKept = true;
1769
+ return this.tv(out, ctx, owner, path);
1770
+ }
1771
+ /** Returns the XFA entry to write. Cleaned packet text goes into xfaReplace, keyed by the stream that holds it. */
1772
+ async cleanXfa(v) {
1773
+ const streamRef = async (r) => {
1774
+ if (!(r instanceof objects_1.PdfRef))
1775
+ throw new xfa_1.XfaReadError('a packet that is not a stream');
1776
+ let ref = r;
1777
+ let o = await this.doc.getObject(ref);
1778
+ for (let i = 1; i < 32 && o instanceof objects_1.PdfRef; i++) {
1779
+ ref = o;
1780
+ o = await this.doc.getObject(ref);
1781
+ }
1782
+ if (!(o instanceof objects_1.PdfStream))
1783
+ throw new xfa_1.XfaReadError('a packet that is not a stream');
1784
+ if (!this.doc.canDecrypt(o, ref.num))
1785
+ throw new xfa_1.XfaReadError('a packet encrypted with a key the password does not give');
1786
+ return { ref, stream: o };
1787
+ };
1788
+ const value = await this.doc.resolve(v);
1789
+ const packets = [];
1790
+ if (value instanceof objects_1.PdfStream)
1791
+ packets.push(await streamRef(v));
1792
+ else if (Array.isArray(value)) {
1793
+ if (value.length % 2)
1794
+ throw new xfa_1.XfaReadError('a packet array of odd length');
1795
+ for (let i = 0; i < value.length; i += 2) {
1796
+ if (!(value[i] instanceof objects_1.PdfString))
1797
+ throw new xfa_1.XfaReadError('a packet without a name');
1798
+ packets.push({ name: value[i], ...(await streamRef(value[i + 1])) });
1799
+ }
1800
+ }
1801
+ else
1802
+ throw new xfa_1.XfaReadError('an XFA entry that is neither a stream nor a packet array');
1803
+ if (new Set(packets.map(p => p.ref.num)).size !== packets.length)
1804
+ throw new xfa_1.XfaReadError('a packet stream listed twice');
1805
+ for (const p of packets)
1806
+ this.xfaRole.add(p.ref.num);
1807
+ const bodies = [];
1808
+ for (const p of packets) {
1809
+ const bytes = await this.doc.decode(p.stream, p.ref.num, this.options.limits?.decompressedBytes);
1810
+ bodies.push(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString('latin1'));
1811
+ }
1812
+ const whole = bodies.join('');
1813
+ try {
1814
+ new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(whole, 'latin1'));
1815
+ }
1816
+ catch {
1817
+ throw new xfa_1.XfaReadError('bytes that are not UTF-8');
1818
+ }
1819
+ const plan = (0, xfa_1.planXfa)(whole, { stripMetadata: this.options.stripMetadata });
1820
+ const edits = [];
1821
+ const cut = [];
1822
+ const inCut = (a, b) => cut.some(([x, y]) => a >= x && b <= y);
1823
+ const where = (packet, som) => `XFA ${packet}${som ? `, ${som}` : ''}`;
1824
+ for (const r of plan.removals) {
1825
+ if (inCut(r.start, r.end))
1826
+ continue;
1827
+ const loc = where(r.packet, r.som);
1828
+ const what = short(r.what, 120);
1829
+ const f = r.kind === 'packet'
1830
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaPacket, loc, { packet: what })
1831
+ : r.kind === 'submit'
1832
+ ? this.emit(types_1.PdfCategory.Action, types_1.PdfDetail.SubmitForm, loc, { target: what })
1833
+ : r.kind === 'execute'
1834
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExecute, loc, { connection: what })
1835
+ : r.kind === 'connection'
1836
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaConnection, loc, { connection: what })
1837
+ : r.kind === 'exObject'
1838
+ ? this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalObject, loc, { object: what })
1839
+ : this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalReference, loc, { reference: what });
1840
+ if (f.action !== 'strip')
1841
+ continue;
1842
+ edits.push({ start: r.start, end: r.end, insert: '' });
1843
+ cut.push([r.start, r.end]);
1844
+ }
1845
+ for (const sc of plan.scripts) {
1846
+ if (inCut(sc.start, sc.end))
1847
+ continue;
1848
+ const d = await this.decideXfaScript(sc);
1849
+ if (!d.keep)
1850
+ edits.push({ start: sc.start, end: sc.end, insert: '' });
1851
+ else if (d.text !== undefined)
1852
+ edits.push({ start: sc.start, end: sc.end, insert: Buffer.from((0, xfa_1.escapeXmlText)(d.text), 'utf8').toString('latin1') });
1853
+ }
1854
+ for (const l of plan.links) {
1855
+ if (inCut(l.start, l.end))
1856
+ continue;
1857
+ if (!this.decideXfaLink(l.uri, where(l.packet, l.som)))
1858
+ edits.push({ start: l.start, end: l.end, insert: '' });
1859
+ }
1860
+ for (const img of plan.images) {
1861
+ if (inCut(img.start, img.end))
1862
+ continue;
1863
+ const d = await this.decideXfaImage(img, `${where(img.packet, img.som)}, image`);
1864
+ if (!d.keep)
1865
+ edits.push({ start: img.start, end: img.end, insert: '' });
1866
+ else if (d.data !== undefined)
1867
+ edits.push({ start: img.start, end: img.end, insert: d.data });
1868
+ }
1869
+ if (!edits.length)
1870
+ return v;
1871
+ const bounds = [0];
1872
+ for (const b of bodies)
1873
+ bounds.push(bounds[bounds.length - 1] + b.length);
1874
+ const per = (0, xfa_1.splitEdits)(bounds, edits);
1875
+ const kept = [];
1876
+ for (let k = 0; k < packets.length; k++) {
1877
+ const p = packets[k];
1878
+ if (per[k].length) {
1879
+ const body = (0, xfa_1.applyEdits)(bodies[k], per[k]);
1880
+ // A packet emptied whole goes from the array, name and all.
1881
+ if (packets.length > 1 && !body.trim()) {
1882
+ await this.reach(p.ref);
1883
+ continue;
1884
+ }
1885
+ this.xfaReplace.set(p.ref.num, Buffer.from(body, 'latin1'));
1886
+ }
1887
+ if (p.name)
1888
+ kept.push(p.name, p.ref);
1889
+ }
1890
+ if (value instanceof objects_1.PdfStream)
1891
+ return v;
1892
+ // The array is written in place, so an array object of its own is no longer referenced.
1893
+ if (v instanceof objects_1.PdfRef && !this.visited.has(v.num))
1894
+ this.reached.add(v.num);
1895
+ return kept;
1896
+ }
1897
+ /** Runs the script plugins over an XFA script. Returns whether it stays, and its new text when a plugin rewrote it. */
1898
+ async decideXfaScript(sc) {
1899
+ const location = `XFA ${sc.packet}${sc.som ? `, ${sc.som}` : ''}, ${sc.activity}`;
1900
+ const trigger = `xfa-${sc.activity}`;
1901
+ const verify = sc.language ? this.cfg.verify?.scripts.get(sc.text) : undefined;
1902
+ if (verify) {
1903
+ this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, location, { plugin: verify.plugin });
1904
+ this.pushScript(verify.findings, location);
1905
+ return { keep: true };
1906
+ }
1907
+ const language = sc.language === 'formcalc' ? 'FormCalc' : sc.language === 'javascript' ? 'JavaScript' : short(sc.contentType || 'an unreadable form', 40);
1908
+ const f = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Xfa, location, { trigger, length: sc.text.length, language });
1909
+ let decision = { keep: f.action !== 'strip' && sc.language !== undefined };
1910
+ let answer;
1911
+ let found;
1912
+ let answered = false;
1913
+ if (f.action === 'strip' && sc.language) {
1914
+ for (const plugin of this.options.scriptPlugins ?? []) {
1915
+ const script = {
1916
+ text: sc.text,
1917
+ trigger,
1918
+ location,
1919
+ fieldName: sc.som || undefined,
1920
+ keptDocumentScripts: [...this.keptDocScripts],
1921
+ language: sc.language,
1922
+ form: 'xfa',
1923
+ };
1924
+ const data = { plugin: plugin.name };
1925
+ try {
1926
+ if (!(await plugin.accepts(script)))
1927
+ continue;
1928
+ const r = await plugin.process(script);
1929
+ if (Array.isArray(r.findings))
1930
+ found = r.findings;
1931
+ answered = r.result === 'passed' || r.result === 'removed' || (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length > 0);
1932
+ if (r.result === 'passed') {
1933
+ decision = { keep: true };
1934
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, location, data);
1935
+ this.kept.scripts.set(sc.text, { result: 'passed', plugin: plugin.name, findings: found });
1936
+ }
1937
+ else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
1938
+ decision = { keep: true, text: r.text };
1939
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, location, data);
1940
+ // Written with its carriage returns as references, so the output check reads back this exact text.
1941
+ this.kept.scripts.set(r.text, { result: 'scrubbed', plugin: plugin.name });
1942
+ }
1943
+ else
1944
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, location, data);
1945
+ }
1946
+ catch (e) {
1947
+ this.passOn(e);
1948
+ answered = false;
1949
+ answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, location, data);
1950
+ }
1951
+ break;
1952
+ }
1953
+ }
1954
+ // A script in a language no plugin reads goes, whatever the override says.
1955
+ if (!sc.language && f.action === 'info')
1956
+ f.action = 'strip';
1957
+ // A plugin's answer is final: the report holds the answer and what the plugin found, at the plugin's weights, and
1958
+ // nothing of this package's own. Its own finding stands when no plugin answered.
1959
+ if (!answered)
1960
+ this.push(f);
1961
+ if (answer)
1962
+ this.push(answer);
1963
+ if (answered)
1964
+ this.pushScript(found, location);
1965
+ return decision;
1966
+ }
1967
+ /**
1968
+ * An image an XFA form carries inline goes to the file plugins, as an attached file does: a viewer decodes it with
1969
+ * decoders of its own. Returns whether it stays, and its new base64 when a plugin rewrote it.
1970
+ */
1971
+ async decideXfaImage(img, loc) {
1972
+ const type = short(img.contentType, 40);
1973
+ const b64 = img.data.replace(/\s+/g, '');
1974
+ // A last lone character holds six bits, no whole byte, so every decoder reads the same bytes without it.
1975
+ if (img.transferEncoding !== 'base64' || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) {
1976
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, loc, { type, reason: 'Its data is not base64' });
1977
+ return { keep: false };
1978
+ }
1979
+ const bytes = Buffer.from(b64, 'base64');
1980
+ const hash = (0, node_crypto_1.createHash)('md5').update(bytes).digest('hex');
1981
+ const expected = this.cfg.verify?.files.get(hash);
1982
+ if (expected) {
1983
+ this.emit(types_1.PdfCategory.Form, expected.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, loc, { plugin: expected.plugin });
1984
+ this.pushContained(expected.findings, loc, 'XFA image');
1985
+ return { keep: true };
1986
+ }
1987
+ const sniffed = (0, sniff_1.sniffType)(bytes.subarray(0, 1024));
1988
+ const file = { declaredType: img.contentType || undefined, sniffedType: sniffed, size: bytes.length, location: loc, depth: this.cfg.depth + 1, source: (0, io_1.bufferSource)(bytes) };
1989
+ const context = { depth: file.depth, deadline: this.cfg.deadline, [exports.RUN_OPTIONS]: this.options };
1990
+ for (const plugin of this.options.filePlugins ?? []) {
1991
+ try {
1992
+ if (!(await plugin.accepts(file)))
1993
+ continue;
1994
+ }
1995
+ catch (e) {
1996
+ this.passOn(e);
1997
+ continue;
1998
+ }
1999
+ const outSink = new io_1.SpillSink(this.cfg.temp, Number.POSITIVE_INFINITY);
2000
+ try {
2001
+ const out = await plugin.process(file, outSink, context);
2002
+ const r = typeof out === 'string' ? { result: out } : (out ?? {});
2003
+ await outSink.close();
2004
+ const data = { plugin: plugin.name };
2005
+ const written = outSink.length > 0 ? Buffer.from(await outSink.source().read(0, outSink.length)) : undefined;
2006
+ this.pushContained(r.findings, loc, 'XFA image');
2007
+ if (r.result === 'passed' || (r.result === 'scrubbed' && written)) {
2008
+ this.emit(types_1.PdfCategory.Form, r.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, loc, data);
2009
+ // Bytes the plugin wrote are the ones kept.
2010
+ this.kept.files.set(written ? outSink.digest() : hash, { result: r.result, plugin: plugin.name, findings: r.outputFindings });
2011
+ return { keep: true, data: written && !written.equals(bytes) ? written.toString('base64') : undefined };
2012
+ }
2013
+ this.emitRemoval(types_1.PdfCategory.Form, r.result === 'removed' ? types_1.PdfDetail.PluginRemoved : types_1.PdfDetail.PluginFailed, loc, data);
2014
+ return { keep: false };
2015
+ }
2016
+ catch (e) {
2017
+ this.passOn(e);
2018
+ this.emitRemoval(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, loc, { plugin: plugin.name });
2019
+ return { keep: false };
2020
+ }
2021
+ finally {
2022
+ await outSink.dispose();
2023
+ }
2024
+ }
2025
+ const f = this.emit(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, loc, { type: type || sniffed || '' });
2026
+ return { keep: f.action !== 'strip' };
2027
+ }
2028
+ /** A link in an XFA form's rich text, checked as a link action's address is. Returns whether it stays. */
2029
+ decideXfaLink(uri, loc) {
2030
+ const verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2031
+ if (verdict.detail === types_1.PdfDetail.Safe) {
2032
+ const f = this.aggregate(types_1.PdfCategory.Link, types_1.PdfDetail.Safe, data => {
2033
+ const hosts = String(data.hosts ?? '')
2034
+ .split(', ')
2035
+ .filter(Boolean);
2036
+ if (verdict.host && hosts.length < 20 && !hosts.includes(verdict.host))
2037
+ hosts.push(verdict.host);
2038
+ data.hosts = hosts.join(', ');
2039
+ });
2040
+ return f.action !== 'strip';
2041
+ }
2042
+ const target = { uri: short(uri, 120), ...(verdict.reason ? { reason: verdict.reason } : {}) };
2043
+ 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);
2044
+ return f.action !== 'strip';
2045
+ }
1715
2046
  async handleAcroForm(d, ctx, owner, path) {
1716
2047
  const out = new objects_1.PdfDict();
1717
2048
  const taken = new Set();
@@ -2248,6 +2579,7 @@ class Walker {
2248
2579
  const verify = src !== undefined ? this.cfg.verify?.scripts.get(src) : undefined;
2249
2580
  if (verify) {
2250
2581
  this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, ctx.location, { plugin: verify.plugin });
2582
+ this.pushScript(verify.findings, ctx.location);
2251
2583
  if (first)
2252
2584
  return first;
2253
2585
  const d = { keep: true };
@@ -2258,19 +2590,32 @@ class Walker {
2258
2590
  const f = this.cfg.factory.make(types_1.PdfCategory.JavaScript, ctx.jsDetail ?? types_1.PdfDetail.Annotation, ctx.location, { trigger: ctx.trigger ?? 'unknown', length });
2259
2591
  let decision = { keep: f.action !== 'strip' };
2260
2592
  let answer;
2593
+ let found;
2594
+ let answered = false;
2261
2595
  if (f.action === 'strip' && !ctx.shadow && src !== undefined) {
2262
2596
  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] };
2597
+ const script = {
2598
+ text: src,
2599
+ trigger: ctx.trigger ?? 'unknown',
2600
+ location: ctx.location,
2601
+ fieldName: ctx.fieldName,
2602
+ keptDocumentScripts: [...this.keptDocScripts],
2603
+ language: 'javascript',
2604
+ form: 'acroform',
2605
+ };
2264
2606
  const data = { plugin: plugin.name };
2265
2607
  try {
2266
2608
  if (!(await plugin.accepts(script)))
2267
2609
  continue;
2268
2610
  const r = await plugin.process(script);
2611
+ if (Array.isArray(r.findings))
2612
+ found = r.findings;
2613
+ answered = r.result === 'passed' || r.result === 'removed' || (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length > 0);
2269
2614
  if (r.result === 'passed') {
2270
2615
  decision = { keep: true };
2271
2616
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, ctx.location, data);
2272
2617
  if (!first)
2273
- this.kept.scripts.set(src, { result: 'passed', plugin: plugin.name });
2618
+ this.kept.scripts.set(src, { result: 'passed', plugin: plugin.name, findings: found });
2274
2619
  }
2275
2620
  else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
2276
2621
  decision = { keep: true, text: r.text };
@@ -2286,12 +2631,11 @@ class Walker {
2286
2631
  }
2287
2632
  catch (e) {
2288
2633
  this.passOn(e);
2634
+ answered = false;
2289
2635
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, ctx.location, data);
2290
2636
  }
2291
2637
  break;
2292
2638
  }
2293
- if (decision.keep)
2294
- f.action = 'info';
2295
2639
  }
2296
2640
  if (ctx.shadow) {
2297
2641
  // Recorded when the walk ends, after any real visit that keeps the script has corrected it.
@@ -2302,9 +2646,16 @@ class Walker {
2302
2646
  this.shadowScripts.set(key, [f]);
2303
2647
  return { keep: false };
2304
2648
  }
2305
- this.push(f);
2649
+ // A plugin's answer is final: the report holds the answer and what the plugin found, at the plugin's weights, and
2650
+ // nothing of this package's own. Its own finding stands when no plugin answered.
2651
+ if (answered)
2652
+ this.answeredScripts.add(key);
2653
+ else
2654
+ this.push(f);
2306
2655
  if (answer)
2307
2656
  this.push(answer);
2657
+ if (answered)
2658
+ this.pushScript(found, ctx.location);
2308
2659
  if (first) {
2309
2660
  // One object holds one text, so a different rewrite is a refusal too, and only the slot can report it.
2310
2661
  if (first.keep && (!decision.keep || decision.text !== first.text)) {
@@ -2353,13 +2704,16 @@ class Walker {
2353
2704
  // Decided again for another slot: only a script can be decided differently, and the rest was settled before.
2354
2705
  }
2355
2706
  else if (s === 'URI') {
2356
- // Every reading a reader may take has to pass.
2707
+ // Every reading a reader may take has to pass. A relative reading stays, so it does not end the check.
2357
2708
  const readings = uriReadings(await this.doc.resolve(d.get('URI')));
2358
2709
  let uri = readings[0];
2359
2710
  let verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2360
- for (let i = 1; i < readings.length && verdict.detail === types_1.PdfDetail.Safe; i++) {
2711
+ for (let i = 1; i < readings.length && (verdict.detail === types_1.PdfDetail.Safe || verdict.detail === types_1.PdfDetail.Relative); i++) {
2712
+ const next = (0, uri_1.checkUri)(readings[i], this.uriBase);
2713
+ if (next.detail === types_1.PdfDetail.Safe && verdict.detail === types_1.PdfDetail.Relative)
2714
+ continue;
2361
2715
  uri = readings[i];
2362
- verdict = (0, uri_1.checkUri)(uri, this.uriBase);
2716
+ verdict = next;
2363
2717
  }
2364
2718
  if (verdict.detail === types_1.PdfDetail.Safe) {
2365
2719
  // A link being removed for another reason is not "kept", so it is not counted as safe.
@@ -2376,7 +2730,8 @@ class Walker {
2376
2730
  keep = (f?.action ?? this.cfg.factory.actionFor(types_1.PdfCategory.Link, types_1.PdfDetail.Safe)) !== 'strip';
2377
2731
  }
2378
2732
  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) });
2733
+ const target = { uri: short(uri, 120), ...(verdict.reason ? { reason: verdict.reason } : {}) };
2734
+ 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
2735
  keep = f.action !== 'strip';
2381
2736
  }
2382
2737
  }
@@ -2831,6 +3186,7 @@ class Walker {
2831
3186
  async writeStream(out, nn, s, num, mapRef) {
2832
3187
  const dict = s.dict.clone();
2833
3188
  const replaceJs = this.jsReplace.get(num);
3189
+ const replaceXfa = this.xfaReplace.get(num);
2834
3190
  const fileDec = this.fileDecisions.get(num);
2835
3191
  let body;
2836
3192
  let compressed;
@@ -2841,6 +3197,14 @@ class Walker {
2841
3197
  dict.set('Length', replaceJs.length);
2842
3198
  body = [replaceJs];
2843
3199
  }
3200
+ else if (replaceXfa) {
3201
+ const z = zlib.deflateSync(replaceXfa);
3202
+ dict.set('Filter', new objects_1.PdfName('FlateDecode'));
3203
+ dict.delete('DecodeParms');
3204
+ dict.delete('DL');
3205
+ dict.set('Length', z.length);
3206
+ body = [z];
3207
+ }
2844
3208
  else if (fileDec?.replacement) {
2845
3209
  const sink = new io_1.SpillSink(this.cfg.temp, this.options.memoryThreshold ?? 8 * 1024 * 1024);
2846
3210
  compressed = sink;
@@ -2916,6 +3280,9 @@ class Walker {
2916
3280
  this.queue = [];
2917
3281
  this.scriptDecisions.clear();
2918
3282
  this.jsReplace.clear();
3283
+ this.xfaReplace.clear();
3284
+ this.xfaDone.clear();
3285
+ this.xfaRole.clear();
2919
3286
  this.annotPage.clear();
2920
3287
  this.altText.clear();
2921
3288
  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[][];