@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/README.md +42 -7
- package/dist/engine.js +3 -3
- package/dist/findings.js +20 -7
- 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 +32 -1
- package/dist/walker.js +387 -20
- 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',
|
|
@@ -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
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
1374
|
-
if (
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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[][];
|