@patrickdk77/pdf-defuse 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/dist/findings.js +15 -2
- package/dist/types.d.ts +16 -1
- package/dist/types.js +8 -0
- package/dist/uri.d.ts +12 -0
- package/dist/uri.js +51 -1
- package/dist/walker.d.ts +26 -0
- package/dist/walker.js +363 -13
- package/dist/xfa.d.ts +113 -0
- package/dist/xfa.js +407 -0
- package/package.json +1 -1
package/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
|
|
1374
|
-
if (
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
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 = {
|
|
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 =
|
|
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
|
|
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[][];
|