From 5bea6a4339fe3b97cd61df81df69d7b5e9d03ce3 Mon Sep 17 00:00:00 2001 From: Igor Klimer Date: Sat, 22 Aug 2026 21:25:24 +0000 Subject: [PATCH] Encrypt strings inside name trees Fixes https://github.com/foliojs/pdfkit/issues/1513 In an encrypted document, the strings inside name trees were being written unencrypted. PDF readers would then try to decrypt them like every other string, get garbage, and the lookup would fail. --- CHANGELOG.md | 1 + lib/object.js | 5 ++- lib/tree.js | 7 +-- tests/unit/tree.spec.js | 94 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/unit/tree.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7f45b13..edf12a154 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 7bccec458..7e7a61ad1 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 6634d8dee..14dfa3196 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 000000000..84b948425 --- /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); + }); +});