diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7f45b1..edf12a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fix `date` text field formatting emitting invalid JavaScript, so the format was never applied. Fixes #1546 - Fix `indentAllLines` applying the indent again on every paragraph and every page break, and keep it applied across continued text. Fixes #1606 - Fix a hole in a sparse array being skipped entirely, which shifted every later entry down one +- Encrypt strings inside name trees. Fixes #1513 ### [v0.19.1] - 2026-06-10 diff --git a/lib/object.js b/lib/object.js index 7bccec45..7e7a61ad 100644 --- a/lib/object.js +++ b/lib/object.js @@ -101,9 +101,12 @@ class PDFObject { // Byte arrays are converted to PDF hex strings } else if (object instanceof Uint8Array) { return `<${bytesToHex(object)}>`; + } else if (object instanceof PDFTree) { + // Name trees hold PDF strings as keys, which have to be encrypted like + // any other string in the document + return object.toString(encryptFn); } else if ( object instanceof PDFAbstractReference || - object instanceof PDFTree || object instanceof SpotColor ) { return object.toString(); diff --git a/lib/tree.js b/lib/tree.js index 6634d8de..14dfa319 100644 --- a/lib/tree.js +++ b/lib/tree.js @@ -19,7 +19,7 @@ class PDFTree { return this._items[key]; } - toString() { + toString(encryptFn = null) { // Needs to be sorted by key const sortedKeys = Object.keys(this._items).sort((a, b) => this._compareKeys(a, b), @@ -30,14 +30,15 @@ class PDFTree { const first = sortedKeys[0], last = sortedKeys[sortedKeys.length - 1]; out.push( - ` /Limits ${PDFObject.convert([this._dataForKey(first), this._dataForKey(last)])}`, + ` /Limits ${PDFObject.convert([this._dataForKey(first), this._dataForKey(last)], encryptFn)}`, ); } out.push(` /${this._keysName()} [`); for (let key of sortedKeys) { out.push( - ` ${PDFObject.convert(this._dataForKey(key))} ${PDFObject.convert( + ` ${PDFObject.convert(this._dataForKey(key), encryptFn)} ${PDFObject.convert( this._items[key], + encryptFn, )}`, ); } diff --git a/tests/unit/tree.spec.js b/tests/unit/tree.spec.js new file mode 100644 index 00000000..84b94842 --- /dev/null +++ b/tests/unit/tree.spec.js @@ -0,0 +1,94 @@ +import PDFDocument from '../../lib/document'; +import PDFNameTree from '../../lib/name_tree'; +import PDFNumberTree from '../../lib/number_tree'; +import PDFObject from '../../lib/object'; +import { fromBinaryString, toBinaryString } from '../../lib/binary'; +import { collectPdf } from './helpers'; + +// Stand-in for a real cipher: uppercasing keeps the expected output readable +// while still showing that every string went through it individually +const encryptFn = (bytes) => + fromBinaryString(toBinaryString(bytes).toUpperCase()); + +describe.each([ + [ + 'PDFNameTree', + { + Tree: PDFNameTree, + keys: ['a', 'b'], + plain: `<< + /Limits [(a) (b)] + /Names [ + (a) (one) + (b) (two) +] +>>`, + // the keys are PDF strings, so they get encrypted just like the values + encrypted: `<< + /Limits [(A) (B)] + /Names [ + (A) (ONE) + (B) (TWO) +] +>>`, + }, + ], + [ + 'PDFNumberTree', + { + Tree: PDFNumberTree, + keys: [1, 2], + plain: `<< + /Limits [1 2] + /Nums [ + 1 (one) + 2 (two) +] +>>`, + // the keys are numbers rather than strings, so they stay as they are + encrypted: `<< + /Limits [1 2] + /Nums [ + 1 (ONE) + 2 (TWO) +] +>>`, + }, + ], +])('%s', (_treeName, { Tree, keys, plain, encrypted }) => { + let tree; + + beforeEach(() => { + tree = new Tree(); + tree.add(keys[0], new String('one')); + tree.add(keys[1], new String('two')); + }); + + test('is written in plain text without an encryption function', () => { + expect(PDFObject.convert(tree)).toEqual(plain); + }); + + test('is encrypted with an encryption function', () => { + expect(PDFObject.convert(tree, encryptFn)).toEqual(encrypted); + }); +}); + +describe('name trees in a document', () => { + const names = ['(heading)', '(data.txt)', 'app.alert']; + + const writeDocument = (options = {}) => { + const document = new PDFDocument(options); + document.text('link', { destination: 'heading' }); + document.file(Buffer.from('example text'), { name: 'data.txt' }); + document.addNamedJavaScript('hello', 'app.alert("hi")'); + return collectPdf(document); + }; + + test.each(names)('writes %s in plain text when not encrypted', (name) => { + expect(writeDocument()).toContain(name); + }); + + test.each(names)('never writes %s when encrypted', (name) => { + expect(writeDocument({ userPassword: 'secret' })).not.toContain(name); + }); +});