From 21277a7539cbd4847c1dd3a4555f3cc34ce660e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Holl=C3=A4nder?= Date: Tue, 16 Dec 2025 13:51:27 +0100 Subject: [PATCH 1/2] restrict file system access in node build - add jsPDF.allowFsRead property as fs read whitelist - read files only if node --permission flag or allowFsRead are enabled --- README.md | 30 ++++++++++ src/modules/fileloading.js | 74 +++++++++++++++++++++-- test/specs/fileloading.spec.js | 103 +++++++++++++++++++++++++++++++++ test/specs/text.spec.js | 14 +++-- test/specs/ttfsupport.spec.js | 2 + types/index.d.ts | 1 + 6 files changed, 215 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c838777fa..99e36f1a5 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,36 @@ doc.save("a4.pdf"); +## Security + +We strongly advise you to sanitize user input before passing it to jsPDF! + +For reporting security vulnerabilities, please see [SECURITY.md](https://github.com/parallax/jsPDF/blob/master/SECURITY.md). + +### Reading files from the local file system on node + +When running under Node.js, jsPDF will restrict reading files from the local file system by default. + +Strongly recommended: use Node's permission flags so the runtime enforces access: + +```sh +node --permission --allow-fs-read=... ./scripts/generate.js +``` + +See [Node's documentation](https://nodejs.org/api/permissions.html) for details. Note that you need to include +all imported JavaScript files (including all dependencies) in the `--allow-fs-read` flag. + +Fallback (not recommended): you can allow jsPDF to read specific files by setting `jsPDF.allowFsRead` in your script. + +```js +import { jsPDF } from "jspdf"; + +const doc = new jsPDF(); +doc.allowFsRead = ["./fonts/*", "./images/logo.png"]; // allow everything under ./fonts and a single file +``` + +Warning: We strongly recommend the Node flags over `jsPDF.allowFsRead`, as the flags are enforced by the runtime and offer stronger security. + ### Optional dependencies Some functions of jsPDF require optional dependencies. E.g. the `html` method, which depends on `html2canvas` and, diff --git a/src/modules/fileloading.js b/src/modules/fileloading.js index 6f94587f6..2444c233a 100644 --- a/src/modules/fileloading.js +++ b/src/modules/fileloading.js @@ -14,8 +14,6 @@ import { jsPDF } from "../jspdf.js"; * @module */ (function(jsPDFAPI) { - "use strict"; - /** * @name loadFile * @function @@ -31,10 +29,44 @@ import { jsPDF } from "../jspdf.js"; // @if MODULE_FORMAT='cjs' // eslint-disable-next-line no-unreachable - return nodeReadFile(url, sync, callback); + return nodeReadFile.call(this, url, sync, callback); // @endif }; + /** + * @name allowFsRead + * @property + * @type {string[]|undefined} + * + * Controls which local files may be read by jsPDF when running under Node.js. + * + * Security recommendation: + * - We strongly recommend using Node's permission flags (`node --permission --allow-fs-read=...`) instead of this property, + * especially in production. The Node flags are enforced by the runtime and provide stronger guarantees. + * + * Behavior: + * - When present, jsPDF will allow reading only if the requested, resolved absolute path matches any entry in this array. + * - Each entry can be either: + * - An absolute or relative file path for an exact match, or + * - A prefix ending with a single wildcard `*` to allow all paths starting with that prefix. + * - Examples of allowed patterns: + * - `"./fonts/MyFont.ttf"` (exact match by resolved path) + * - `"/abs/path/to/file.txt"` (exact absolute path) + * - `"./assets/*"` (any file whose resolved path starts with the resolved `./assets/` directory) + * + * Notes: + * - If Node's permission API is available (`process.permission`), it is checked first. If it denies access, reading will fail regardless of `allowFsRead`. + * - If neither `process.permission` nor `allowFsRead` is set, reading from the local file system is disabled and an error is thrown. + * + * Example: + * ```js + * const doc = jsPDF(); + * doc.allowFsRead = ["./fonts/*", "./images/logo.png"]; // allow everything under ./fonts and a single file + * const ttf = doc.loadFile("./fonts/MyFont.ttf", true); + * ``` + */ + jsPDFAPI.allowFsRead = undefined; + /** * @name loadImageFile * @function @@ -98,10 +130,42 @@ import { jsPDF } from "../jspdf.js"; var fs = require("fs"); var path = require("path"); - url = path.resolve(url); + if (!process.permission && !this.allowFsRead) { + throw new Error( + "Trying to read a file from local file system. To enable this feature either run node with the --permission and --allow-fs-read flags or set the jsPDF.allowFsRead property." + ); + } + + url = fs.realpathSync(path.resolve(url)); + + if (process.permission && !process.permission.has("fs.read", url)) { + throw new Error(`Cannot read file '${url}'. Permission denied.`); + } + + if (this.allowFsRead) { + const allowRead = this.allowFsRead.some(allowedUrl => { + const starIndex = allowedUrl.indexOf("*"); + if (starIndex >= 0) { + const fixedPart = allowedUrl.substring(0, starIndex); + let resolved = path.resolve(fixedPart); + if (fixedPart.endsWith(path.sep) && !resolved.endsWith(path.sep)) { + resolved += path.sep; + } + return url.startsWith(resolved); + } else { + return url === path.resolve(allowedUrl); + } + }); + if (!allowRead) { + throw new Error(`Cannot read file '${url}'. Permission denied.`); + } + } + if (sync) { try { - result = fs.readFileSync(url, { encoding: "latin1" }); + result = fs.readFileSync(url, { + encoding: "latin1" + }); } catch (e) { return undefined; } diff --git a/test/specs/fileloading.spec.js b/test/specs/fileloading.spec.js index 1ea772f30..7ef40fab7 100644 --- a/test/specs/fileloading.spec.js +++ b/test/specs/fileloading.spec.js @@ -8,18 +8,27 @@ describe("Module: FileLoad", () => { : "/base/test/reference/success.txt"; it("should load a file (sync)", () => { const doc = jsPDF(); + if (typeof isNode !== "undefined" && isNode) { + doc.allowFsRead = [successURL]; + } var file = doc.loadFile(successURL, undefined, undefined); expect(file).toEqual("success"); }); it("should fail to load a file (sync)", () => { const doc = jsPDF(); + if (typeof isNode !== "undefined" && isNode) { + doc.allowFsRead = ["fail.txt"]; + } var file = doc.loadFile("fail.txt", undefined, undefined); expect(file).toEqual(undefined); }); it("should load a file (async)", done => { const doc = jsPDF(); + if (typeof isNode !== "undefined" && isNode) { + doc.allowFsRead = [successURL]; + } doc.loadFile(successURL, false, function(data) { expect(data).toEqual("success"); done(); @@ -28,9 +37,103 @@ describe("Module: FileLoad", () => { it("should fail to load a file (async)", done => { const doc = jsPDF(); + if (typeof isNode !== "undefined" && isNode) { + doc.allowFsRead = ["fail.txt"]; + } doc.loadFile("fail.txt", false, function(data) { expect(data).toEqual(undefined); done(); }); }); }); + +if (typeof isNode !== "undefined" && isNode) { + const path = require("path"); + + describe("Module: FileLoad (Node permissions)", () => { + const absSuccess = path.resolve("./test/reference/success.txt"); + let originalPermission; + + beforeEach(() => { + originalPermission = process.permission; + }); + + afterEach(() => { + process.permission = originalPermission; + }); + + it("should throw if neither process.permission nor jsPDF.allowFsRead is set", () => { + const doc = jsPDF(); + doc.allowFsRead = undefined; + process.permission = undefined; + + expect(() => { + doc.loadFile(absSuccess, true); + }).toThrowError(/Trying to read a file from local file system/); + }); + + it("should allow reading via process.permission for exact absolute path", () => { + const doc = jsPDF(); + doc.allowFsRead = undefined; + process.permission = { + has: (perm, url) => perm === "fs.read" && url === absSuccess + }; + + const data = doc.loadFile(absSuccess, true); + expect(data).toEqual("success"); + }); + + it("should deny reading via process.permission when has() returns false", () => { + const doc = jsPDF(); + doc.allowFsRead = undefined; + process.permission = { + has: () => false + }; + + expect(() => { + doc.loadFile(absSuccess, true); + }).toThrowError(/Permission denied/); + }); + + it("should allow reading via process.permission with wildcard-like directory prefix", () => { + const doc = jsPDF(); + doc.allowFsRead = undefined; + const allowedDir = path.resolve("./test/reference/"); + process.permission = { + has: (perm, url) => perm === "fs.read" && url.startsWith(allowedDir) + }; + + const data = doc.loadFile(absSuccess, true); + expect(data).toEqual("success"); + }); + + it("should allow reading via jsPDF.allowFsRead using absolute path (no wildcard)", () => { + const doc = jsPDF(); + doc.allowFsRead = [absSuccess]; + const data = doc.loadFile(absSuccess, true); + expect(data).toEqual("success"); + }); + + it("should allow reading via jsPDF.allowFsRead using relative path (no wildcard)", () => { + const doc = jsPDF(); + doc.allowFsRead = ["./test/reference/success.txt"]; + const data = doc.loadFile("./test/reference/success.txt", true); + expect(data).toEqual("success"); + }); + + it("should allow reading via jsPDF.allowFsRead using wildcard prefix", () => { + const doc = jsPDF(); + doc.allowFsRead = ["./test/reference/*"]; + const data = doc.loadFile("./test/reference/success.txt", true); + expect(data).toEqual("success"); + }); + + it("should deny reading when jsPDF.allowFsRead pattern does not match", () => { + const doc = jsPDF(); + doc.allowFsRead = ["./other/dir/*", "./test/reference/deny.txt"]; + expect(() => { + doc.loadFile("./test/reference/success.txt", true); + }).toThrowError(/Permission denied/); + }); + }); +} diff --git a/test/specs/text.spec.js b/test/specs/text.spec.js index 0a48280dc..a824a8cb7 100644 --- a/test/specs/text.spec.js +++ b/test/specs/text.spec.js @@ -179,6 +179,7 @@ break` const doc = jsPDF({ floatPrecision: 2 }); var PTSans; if (typeof global === "object" && global.isNode === true) { + doc.allowFsRead = ["./test/reference/PTSans.ttf"]; PTSans = doc.loadFile("./test/reference/PTSans.ttf"); } else { PTSans = doc.loadFile("base/test/reference/PTSans.ttf"); @@ -187,10 +188,15 @@ break` doc.addFont("PTSans.ttf", "PTSans", "normal"); doc.setFont("PTSans"); doc.setFontSize(10); - doc.text("А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! ", 10, 10, { - align: "justify", - maxWidth: 100, - }); + doc.text( + "А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! ", + 10, + 10, + { + align: "justify", + maxWidth: 100 + } + ); comparePdf(doc.output(), "justify-custom-font.pdf", "text"); }); diff --git a/test/specs/ttfsupport.spec.js b/test/specs/ttfsupport.spec.js index 07eeac5c1..c44c3a9a5 100644 --- a/test/specs/ttfsupport.spec.js +++ b/test/specs/ttfsupport.spec.js @@ -16,6 +16,7 @@ describe("TTFSupport", () => { }); var PTSans; if (typeof global === "object" && global.isNode === true) { + doc.allowFsRead = ["./test/reference/PTSans.ttf"]; PTSans = doc.loadFile("./test/reference/PTSans.ttf"); } else { PTSans = doc.loadFile("base/test/reference/PTSans.ttf"); @@ -37,6 +38,7 @@ describe("TTFSupport", () => { }); if (typeof global === "object" && global.isNode === true) { + doc.allowFsRead = ["./test/reference/PTSans.ttf"]; doc.addFont("./test/reference/PTSans.ttf", "PTSans", "normal"); } else { doc.addFont("base/test/reference/PTSans.ttf", "PTSans", "normal"); diff --git a/types/index.d.ts b/types/index.d.ts index 70f6c24cf..cbbe844a0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1099,6 +1099,7 @@ declare module "jspdf" { sync: false, callback: (data: string) => string ): void; + allowFsRead: string[] | undefined; // jsPDF plugin: html html(src: string | HTMLElement, options?: HTMLOptions): HTMLWorker; From 9029c14fa50171a54da15cfb6ea05b75f2cda7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Holl=C3=A4nder?= Date: Thu, 18 Dec 2025 11:04:23 +0100 Subject: [PATCH 2/2] catch errors when resolving symlinks --- src/modules/fileloading.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/modules/fileloading.js b/src/modules/fileloading.js index 2444c233a..27eb6b0b1 100644 --- a/src/modules/fileloading.js +++ b/src/modules/fileloading.js @@ -136,7 +136,16 @@ import { jsPDF } from "../jspdf.js"; ); } - url = fs.realpathSync(path.resolve(url)); + try { + url = fs.realpathSync(path.resolve(url)); + } catch (e) { + if (sync) { + return undefined; + } else { + callback(undefined); + return; + } + } if (process.permission && !process.permission.has("fs.read", url)) { throw new Error(`Cannot read file '${url}'. Permission denied.`);