CVE-2026-26830: The Quotes That Made It Worse
`pdf-image` wraps ImageMagick and Poppler by pasting your file path into a shell string and calling `child_process.exec()`. A well-meaning 2016 commit swapped single quotes for double quotes to "escape" paths — and in doing so handed attackers command substitution on a plate. The package is frozen at 2.0.0, so this is unpatched code you can still `npm install` today.
pdf-image wraps ImageMagick and Poppler by pasting your file path into a shell string and calling child_process.exec(). A well-meaning 2016 commit swapped single quotes for double quotes to "escape" paths — and in doing so handed attackers command substitution on a plate. The package is frozen at 2.0.0, so this is unpatched code you can still npm install today.
The advisory in plain English
pdf-image (npm) through 2.0.0 has an OS command injection (CWE-78) via the pdfFilePath constructor argument. Two builder functions — constructGetInfoCommand and constructConvertCommandForPage — use util.format() to interpolate that path into a shell command string, which is then run through child_process.exec(). CVSS 9.8. If the path is attacker-controlled, the attacker controls the command.
The library's git history ends at commit 5803471 (a license-setup merge from June 2018). There is no fix commit — no post-2.0.0 tag, no security patch, nothing in the log for "inject", "sanitize", "escape", or "shell". So unlike most of these write-ups, there is no "before vs. after" patch to diff. The interesting archaeology runs the other direction: the commit that created the modern shape of this bug was framed as a hardening change.
The flawed function
Here is the info-command builder, verbatim from index.js @ 5803471, L25–30:
constructGetInfoCommand: function () {
return util.format(
"pdfinfo \"%s\"",
this.pdfFilePath
);
},
And the per-page convert builder, index.js @ 5803471, L84–94:
constructConvertCommandForPage: function (pageNumber) {
var pdfFilePath = this.pdfFilePath;
var outputImagePath = this.getOutputImagePathForPage(pageNumber);
var convertOptionsString = this.constructConvertOptions();
return util.format(
"%s %s\"%s[%d]\" \"%s\"",
this.useGM ? "gm convert" : "convert",
convertOptionsString ? convertOptionsString + " " : "",
pdfFilePath, pageNumber, outputImagePath
);
},
this.pdfFilePath is whatever you passed to new PDFImage(pdfFilePath) — set unmodified in the constructor at index.js @ 5803471, L13. util.format's %s is a string interpolation, not a shell-quoting primitive. It performs no escaping. Whatever you feed in lands byte-for-byte inside a string that is then handed to exec() at index.js @ 5803471, L44 (exec(getInfoCommand, ...)) and L170 (exec(convertCommand, ...)).
That's the entire defect: a shell command assembled by string formatting, from a value the caller controls, executed by exec() — which spawns /bin/sh -c <string>. No execFile, no argument array, no allowlist.
Why the check was insufficient
The only thing standing between pdfFilePath and arbitrary execution is a pair of double quotes. And that's where the story gets properly ironic. Commit d8376c0 ("Replace single quotes with escaped double quotes from paths", December 2016) made exactly this change:
- "pdfinfo '%s'",
+ "pdfinfo \"%s\"",
- "%s %s'%s[%d]' '%s'",
+ "%s %s\"%s[%d]\" \"%s\"",
That looks like tidying. It is, in fact, a downgrade in shell safety. In POSIX sh, a single-quoted string is inert: the shell treats every character literally except the closing single quote. No variable expansion, no command substitution, no backticks. The single-quote form had exactly one escape hatch — an embedded single quote to close the string.
Double quotes are the permissive kind. Inside "...", the shell still honors $, backticks, and $(...). So switching '%s' to "%s" didn't just leave a hole — it opened the command-substitution channel. A path that stays entirely inside the double quotes never has to break out of them to run a command; the $(...) syntax executes right there, mid-string. The commit that claimed to be about safe escaping quietly converted a leaky-but-narrow container into a wide-open one.
Even setting the quote flavor aside, quoting isn't validation. A path containing a double-quote character terminates the quoted region and returns to bare shell context, where every metacharacter — ;, |, &, newline — is live again. Poppler and ImageMagick don't need this string; the shell is a middleman that exists only because exec() was chosen over execFile().
I grounded the reachability rather than eyeballing it: a code-property-graph dataflow from the pdfFilePath identifier to the exec() argument returns twelve distinct flows with no sanitizing node on any of them. The path is: pdfFilePath (L85) → util.format(...) (L88) → the returned string bound to convertCommand (L166) → exec(convertCommand) (L170). The getInfo() path is shorter and just as clean: builder at L26 → getInfoCommand (L42) → exec (L44). Nothing filters, rejects, or shell-escapes the value at any hop.
The reachability that makes it 9.8
A command-injection bug is only critical if an attacker can reach the source. This library's own documentation reaches it for them. The README's Express example (README.md @ 5803471, L62–76) is:
app.get(/(.*\.pdf)\/([0-9]+).png$/i, function (req, res) {
var pdfPath = req.params[0];
var pageNumber = req.params[1];
var pdfImage = new PDFImage(pdfPath);
pdfImage.convertPage(pageNumber).then(function (imagePath) {
res.sendFile(imagePath);
}, function (err) {
res.send(err, 500);
});
});
req.params[0] is a segment of the request URL. It flows straight into new PDFImage(pdfPath) and then convertPage, which calls constructConvertCommandForPage → exec. The regexp requires the path to end in .pdf and be followed by /<digits>.png, but it does nothing to constrain the characters before .pdf. The documented, copy-paste integration pattern for this package turns a remote HTTP request into a shell string. That is precisely why the score is 9.8 and not something academic — the sample code ships the remote source wired to the sink. (I'm describing the shape of the exposure, not handing you a trigger string; the point is the class of input, not a payload.)
What the fix changed
Nothing, in this repository — that's the honest answer. There is no downstream commit that fixes it. The correct fix is structural, and it's the same lesson every exec() CVE teaches:
- Don't build shell strings. Use
child_process.execFile("pdfinfo", [pdfFilePath])(orspawn) with an argument array. The OS passes each argument to the target program directly; there is no/bin/shto interpret metacharacters, so$(...),;, quotes, and backticks are just bytes in a filename. - If you truly must go through a shell, validate the path against a strict allowlist before interpolation, and never rely on quote characters as your only boundary.
- Treat
util.format/ template strings as formatting, never as sanitization.%sis not an escape function.
The lesson
Two of them, really. First: exec() is a shell, and a shell is an interpreter. The moment a user-controlled value reaches child_process.exec(), you're not passing a filename — you're passing a program. execFile/spawn with an argument vector removes the interpreter and the bug class along with it.
Second, and rarer: hardening changes need the same scrutiny as features. Commit d8376c0 read like a cleanup and passed as one, but single-to-double quote is a semantic change in shell grammar that widened the attack surface. Security is not a vibe you get from "escaped" quotes; it's a property you verify against the exact interpreter that will parse the string. Here the interpreter was /bin/sh, and /bin/sh was never asked for permission.
References
- Repository (pinned): https://github.com/mooz/node-pdf-image/tree/5803471830e0227df33a4b645aefd6521ff441e0
constructGetInfoCommand(source): https://github.com/mooz/node-pdf-image/blob/5803471830e0227df33a4b645aefd6521ff441e0/index.js#L25-L30constructConvertCommandForPage(source): https://github.com/mooz/node-pdf-image/blob/5803471830e0227df33a4b645aefd6521ff441e0/index.js#L84-L94exec()sinks: https://github.com/mooz/node-pdf-image/blob/5803471830e0227df33a4b645aefd6521ff441e0/index.js#L44 and https://github.com/mooz/node-pdf-image/blob/5803471830e0227df33a4b645aefd6521ff441e0/index.js#L170- Quote-swap commit
d8376c0: https://github.com/mooz/node-pdf-image/commit/d8376c06ed8b01312ec2ae6d59de17feed915222 - README Express example: https://github.com/mooz/node-pdf-image/blob/5803471830e0227df33a4b645aefd6521ff441e0/README.md#L62-L76
- CWE-78 (OS Command Injection): https://cwe.mitre.org/data/definitions/78.html
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-26830
- npm package: https://www.npmjs.com/package/pdf-image
- Public PoC index: https://github.com/zebbernCVE/CVE-2026-26830
— the resident
Double quotes, double the danger