Skip to content

Commit a688c8f

Browse files
authored
restrict file system access in node build (#3931)
- add jsPDF.allowFsRead property as fs read whitelist - read files only if node --permission flag or allowFsRead are enabled
1 parent a504e97 commit a688c8f

File tree

6 files changed

+224
-9
lines changed

6 files changed

+224
-9
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,36 @@ doc.save("a4.pdf");
119119

120120
</details>
121121

122+
## Security
123+
124+
We strongly advise you to sanitize user input before passing it to jsPDF!
125+
126+
For reporting security vulnerabilities, please see [SECURITY.md](https://github.com/parallax/jsPDF/blob/master/SECURITY.md).
127+
128+
### Reading files from the local file system on node
129+
130+
When running under Node.js, jsPDF will restrict reading files from the local file system by default.
131+
132+
Strongly recommended: use Node's permission flags so the runtime enforces access:
133+
134+
```sh
135+
node --permission --allow-fs-read=... ./scripts/generate.js
136+
```
137+
138+
See [Node's documentation](https://nodejs.org/api/permissions.html) for details. Note that you need to include
139+
all imported JavaScript files (including all dependencies) in the `--allow-fs-read` flag.
140+
141+
Fallback (not recommended): you can allow jsPDF to read specific files by setting `jsPDF.allowFsRead` in your script.
142+
143+
```js
144+
import { jsPDF } from "jspdf";
145+
146+
const doc = new jsPDF();
147+
doc.allowFsRead = ["./fonts/*", "./images/logo.png"]; // allow everything under ./fonts and a single file
148+
```
149+
150+
Warning: We strongly recommend the Node flags over `jsPDF.allowFsRead`, as the flags are enforced by the runtime and offer stronger security.
151+
122152
### Optional dependencies
123153

124154
Some functions of jsPDF require optional dependencies. E.g. the `html` method, which depends on `html2canvas` and,

src/modules/fileloading.js

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import { jsPDF } from "../jspdf.js";
1414
* @module
1515
*/
1616
(function(jsPDFAPI) {
17-
"use strict";
18-
1917
/**
2018
* @name loadFile
2119
* @function
@@ -31,10 +29,44 @@ import { jsPDF } from "../jspdf.js";
3129

3230
// @if MODULE_FORMAT='cjs'
3331
// eslint-disable-next-line no-unreachable
34-
return nodeReadFile(url, sync, callback);
32+
return nodeReadFile.call(this, url, sync, callback);
3533
// @endif
3634
};
3735

36+
/**
37+
* @name allowFsRead
38+
* @property
39+
* @type {string[]|undefined}
40+
*
41+
* Controls which local files may be read by jsPDF when running under Node.js.
42+
*
43+
* Security recommendation:
44+
* - We strongly recommend using Node's permission flags (`node --permission --allow-fs-read=...`) instead of this property,
45+
* especially in production. The Node flags are enforced by the runtime and provide stronger guarantees.
46+
*
47+
* Behavior:
48+
* - When present, jsPDF will allow reading only if the requested, resolved absolute path matches any entry in this array.
49+
* - Each entry can be either:
50+
* - An absolute or relative file path for an exact match, or
51+
* - A prefix ending with a single wildcard `*` to allow all paths starting with that prefix.
52+
* - Examples of allowed patterns:
53+
* - `"./fonts/MyFont.ttf"` (exact match by resolved path)
54+
* - `"/abs/path/to/file.txt"` (exact absolute path)
55+
* - `"./assets/*"` (any file whose resolved path starts with the resolved `./assets/` directory)
56+
*
57+
* Notes:
58+
* - If Node's permission API is available (`process.permission`), it is checked first. If it denies access, reading will fail regardless of `allowFsRead`.
59+
* - If neither `process.permission` nor `allowFsRead` is set, reading from the local file system is disabled and an error is thrown.
60+
*
61+
* Example:
62+
* ```js
63+
* const doc = jsPDF();
64+
* doc.allowFsRead = ["./fonts/*", "./images/logo.png"]; // allow everything under ./fonts and a single file
65+
* const ttf = doc.loadFile("./fonts/MyFont.ttf", true);
66+
* ```
67+
*/
68+
jsPDFAPI.allowFsRead = undefined;
69+
3870
/**
3971
* @name loadImageFile
4072
* @function
@@ -98,10 +130,51 @@ import { jsPDF } from "../jspdf.js";
98130
var fs = require("fs");
99131
var path = require("path");
100132

101-
url = path.resolve(url);
133+
if (!process.permission && !this.allowFsRead) {
134+
throw new Error(
135+
"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."
136+
);
137+
}
138+
139+
try {
140+
url = fs.realpathSync(path.resolve(url));
141+
} catch (e) {
142+
if (sync) {
143+
return undefined;
144+
} else {
145+
callback(undefined);
146+
return;
147+
}
148+
}
149+
150+
if (process.permission && !process.permission.has("fs.read", url)) {
151+
throw new Error(`Cannot read file '${url}'. Permission denied.`);
152+
}
153+
154+
if (this.allowFsRead) {
155+
const allowRead = this.allowFsRead.some(allowedUrl => {
156+
const starIndex = allowedUrl.indexOf("*");
157+
if (starIndex >= 0) {
158+
const fixedPart = allowedUrl.substring(0, starIndex);
159+
let resolved = path.resolve(fixedPart);
160+
if (fixedPart.endsWith(path.sep) && !resolved.endsWith(path.sep)) {
161+
resolved += path.sep;
162+
}
163+
return url.startsWith(resolved);
164+
} else {
165+
return url === path.resolve(allowedUrl);
166+
}
167+
});
168+
if (!allowRead) {
169+
throw new Error(`Cannot read file '${url}'. Permission denied.`);
170+
}
171+
}
172+
102173
if (sync) {
103174
try {
104-
result = fs.readFileSync(url, { encoding: "latin1" });
175+
result = fs.readFileSync(url, {
176+
encoding: "latin1"
177+
});
105178
} catch (e) {
106179
return undefined;
107180
}

test/specs/fileloading.spec.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,27 @@ describe("Module: FileLoad", () => {
88
: "/base/test/reference/success.txt";
99
it("should load a file (sync)", () => {
1010
const doc = jsPDF();
11+
if (typeof isNode !== "undefined" && isNode) {
12+
doc.allowFsRead = [successURL];
13+
}
1114
var file = doc.loadFile(successURL, undefined, undefined);
1215
expect(file).toEqual("success");
1316
});
1417

1518
it("should fail to load a file (sync)", () => {
1619
const doc = jsPDF();
20+
if (typeof isNode !== "undefined" && isNode) {
21+
doc.allowFsRead = ["fail.txt"];
22+
}
1723
var file = doc.loadFile("fail.txt", undefined, undefined);
1824
expect(file).toEqual(undefined);
1925
});
2026

2127
it("should load a file (async)", done => {
2228
const doc = jsPDF();
29+
if (typeof isNode !== "undefined" && isNode) {
30+
doc.allowFsRead = [successURL];
31+
}
2332
doc.loadFile(successURL, false, function(data) {
2433
expect(data).toEqual("success");
2534
done();
@@ -28,9 +37,103 @@ describe("Module: FileLoad", () => {
2837

2938
it("should fail to load a file (async)", done => {
3039
const doc = jsPDF();
40+
if (typeof isNode !== "undefined" && isNode) {
41+
doc.allowFsRead = ["fail.txt"];
42+
}
3143
doc.loadFile("fail.txt", false, function(data) {
3244
expect(data).toEqual(undefined);
3345
done();
3446
});
3547
});
3648
});
49+
50+
if (typeof isNode !== "undefined" && isNode) {
51+
const path = require("path");
52+
53+
describe("Module: FileLoad (Node permissions)", () => {
54+
const absSuccess = path.resolve("./test/reference/success.txt");
55+
let originalPermission;
56+
57+
beforeEach(() => {
58+
originalPermission = process.permission;
59+
});
60+
61+
afterEach(() => {
62+
process.permission = originalPermission;
63+
});
64+
65+
it("should throw if neither process.permission nor jsPDF.allowFsRead is set", () => {
66+
const doc = jsPDF();
67+
doc.allowFsRead = undefined;
68+
process.permission = undefined;
69+
70+
expect(() => {
71+
doc.loadFile(absSuccess, true);
72+
}).toThrowError(/Trying to read a file from local file system/);
73+
});
74+
75+
it("should allow reading via process.permission for exact absolute path", () => {
76+
const doc = jsPDF();
77+
doc.allowFsRead = undefined;
78+
process.permission = {
79+
has: (perm, url) => perm === "fs.read" && url === absSuccess
80+
};
81+
82+
const data = doc.loadFile(absSuccess, true);
83+
expect(data).toEqual("success");
84+
});
85+
86+
it("should deny reading via process.permission when has() returns false", () => {
87+
const doc = jsPDF();
88+
doc.allowFsRead = undefined;
89+
process.permission = {
90+
has: () => false
91+
};
92+
93+
expect(() => {
94+
doc.loadFile(absSuccess, true);
95+
}).toThrowError(/Permission denied/);
96+
});
97+
98+
it("should allow reading via process.permission with wildcard-like directory prefix", () => {
99+
const doc = jsPDF();
100+
doc.allowFsRead = undefined;
101+
const allowedDir = path.resolve("./test/reference/");
102+
process.permission = {
103+
has: (perm, url) => perm === "fs.read" && url.startsWith(allowedDir)
104+
};
105+
106+
const data = doc.loadFile(absSuccess, true);
107+
expect(data).toEqual("success");
108+
});
109+
110+
it("should allow reading via jsPDF.allowFsRead using absolute path (no wildcard)", () => {
111+
const doc = jsPDF();
112+
doc.allowFsRead = [absSuccess];
113+
const data = doc.loadFile(absSuccess, true);
114+
expect(data).toEqual("success");
115+
});
116+
117+
it("should allow reading via jsPDF.allowFsRead using relative path (no wildcard)", () => {
118+
const doc = jsPDF();
119+
doc.allowFsRead = ["./test/reference/success.txt"];
120+
const data = doc.loadFile("./test/reference/success.txt", true);
121+
expect(data).toEqual("success");
122+
});
123+
124+
it("should allow reading via jsPDF.allowFsRead using wildcard prefix", () => {
125+
const doc = jsPDF();
126+
doc.allowFsRead = ["./test/reference/*"];
127+
const data = doc.loadFile("./test/reference/success.txt", true);
128+
expect(data).toEqual("success");
129+
});
130+
131+
it("should deny reading when jsPDF.allowFsRead pattern does not match", () => {
132+
const doc = jsPDF();
133+
doc.allowFsRead = ["./other/dir/*", "./test/reference/deny.txt"];
134+
expect(() => {
135+
doc.loadFile("./test/reference/success.txt", true);
136+
}).toThrowError(/Permission denied/);
137+
});
138+
});
139+
}

test/specs/text.spec.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ break`
179179
const doc = jsPDF({ floatPrecision: 2 });
180180
var PTSans;
181181
if (typeof global === "object" && global.isNode === true) {
182+
doc.allowFsRead = ["./test/reference/PTSans.ttf"];
182183
PTSans = doc.loadFile("./test/reference/PTSans.ttf");
183184
} else {
184185
PTSans = doc.loadFile("base/test/reference/PTSans.ttf");
@@ -187,10 +188,15 @@ break`
187188
doc.addFont("PTSans.ttf", "PTSans", "normal");
188189
doc.setFont("PTSans");
189190
doc.setFontSize(10);
190-
doc.text("А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! ", 10, 10, {
191-
align: "justify",
192-
maxWidth: 100,
193-
});
191+
doc.text(
192+
"А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! А ну чики брики и в дамки! ",
193+
10,
194+
10,
195+
{
196+
align: "justify",
197+
maxWidth: 100
198+
}
199+
);
194200
comparePdf(doc.output(), "justify-custom-font.pdf", "text");
195201
});
196202

test/specs/ttfsupport.spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("TTFSupport", () => {
1616
});
1717
var PTSans;
1818
if (typeof global === "object" && global.isNode === true) {
19+
doc.allowFsRead = ["./test/reference/PTSans.ttf"];
1920
PTSans = doc.loadFile("./test/reference/PTSans.ttf");
2021
} else {
2122
PTSans = doc.loadFile("base/test/reference/PTSans.ttf");
@@ -37,6 +38,7 @@ describe("TTFSupport", () => {
3738
});
3839

3940
if (typeof global === "object" && global.isNode === true) {
41+
doc.allowFsRead = ["./test/reference/PTSans.ttf"];
4042
doc.addFont("./test/reference/PTSans.ttf", "PTSans", "normal");
4143
} else {
4244
doc.addFont("base/test/reference/PTSans.ttf", "PTSans", "normal");

types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,7 @@ declare module "jspdf" {
10991099
sync: false,
11001100
callback: (data: string) => string
11011101
): void;
1102+
allowFsRead: string[] | undefined;
11021103

11031104
// jsPDF plugin: html
11041105
html(src: string | HTMLElement, options?: HTMLOptions): HTMLWorker;

0 commit comments

Comments
 (0)