CVE-2026-25755 is a PDF object injection vulnerability in jsPDF's addJS() method, scored CVSS 8.8 (High) under CWE-94 Code Injection. The bug lets attacker-supplied strings break out of the PDF JavaScript object literal and inject arbitrary PDF objects, including Additional Actions that auto-execute AcroJS on document open. Any application that passes caller-controlled input to addJS on jsPDF versions before 4.2.0 is exposed. The one-line mitigation is to upgrade to jspdf 4.2.0 or escape (, ), and \ in every string the caller passes to addJS before the call.
If your app calls doc.addJS() with any input that originates outside your codebase, treat every jsPDF version below 4.2.0 as actively exposed. The exploit requires no special tooling, no second round-trip, and no authentication on the producing application. A single form field that flows into addJS is enough.
What CVE-2026-25755 actually does
CVE-2026-25755 is a PDF object injection caused by missing escape handling in jsPDF's addJS helper. PDF is a structured binary format with hard-defined delimiters. A literal string in PDF is enclosed by ( and ). Per PDF 1.7 section 7.3.4 String Object, the closing ) ends the string unless preceded by a backslash. Anything after that closing parenthesis is parsed as the next PDF object, dictionary entry, or operator.
The vulnerable code lives in src/modules/javascript.js in jsPDF. The relevant line concatenates user input directly into the PDF stream:
this.internal.out("/JS (" + text + ")");text is the argument passed to addJS(text). No escape pass runs over it. If text contains a ) character, the PDF parser sees the string close before the bytes the caller intended. Everything after that closing parenthesis is then parsed as PDF object syntax. The attacker controls everything in text, including the closing parenthesis and whatever follows it.
The minimal exploit payload from the public proof of concept is:
) >> /AA << /O << /S /JavaScript /JS (app.alert('Hacked!')) >> >>Walking through what the PDF parser sees when this is interpolated into the vulnerable code:
/JS () >> /AA << /O << /S /JavaScript /JS (app.alert('Hacked!')) >> >>)The first () is the closed (empty) JS string the caller "intended". The next byte is >> which closes the dictionary jsPDF was building. After that, /AA << /O << /S /JavaScript /JS (...) >> >> reads as an Additional Action entry with an open trigger that runs JavaScript. The final ) is a stray byte the reader ignores because the previous >> already balanced the structure. The reader now opens the document, sees the /AA /O entry, and auto-executes the attacker's AcroJS before the user does anything.
The advisory GHSA-9vjf-qc39-jprp carries the CVSS 8.8 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H. The GitLab record at advisories.gitlab.com/pkg/npm/jspdf/CVE-2026-25755 lists the affected range as < 4.2.0 and the fixed version as 4.2.0.
How the AcroJS bypass works
The AcroJS sandbox is not what gets bypassed here, not directly. The bypass is at the layer below it: getting code INTO the AcroJS interpreter that the developer never put there. The sandbox still runs after the injection lands. What changes is the source of the script the sandbox runs.
AcroJS is the restricted JavaScript dialect that runs inside PDF readers. It has no DOM, no fetch, no filesystem access, no eval in modern readers. It does have app.alert, app.launchURL, this.submitForm, this.getField, and dozens of similar APIs for interacting with the PDF and the reader UI. The sandbox is real and the exploit does not escape it.
What CVE-2026-25755 changes is which AcroJS code the reader runs. The developer who called addJS("legitimate code here") intended to ship a specific script in the PDF, authorized for their own purposes. The CVE lets an attacker controlling any input flowing into that call append additional PDF objects after the intended script. The most useful additional object is a document-level Additional Action of type /O (open), which the reader fires automatically when the document opens, with no user interaction beyond opening the file.
The practical impact list, all subject to the AcroJS sandbox:
| AcroJS API | What it does | Abuse pattern |
|---|---|---|
app.alert | Forced dialog | Phishing prompts, fake error messages |
app.launchURL | Open URL in browser (usually prompted) | Drive users to attacker domain on document open |
this.submitForm | POST form data to URL | Exfiltrate any field the user filled in |
this.getField | Read form field values | Combine with submitForm for credential harvest |
app.execMenuItem | Trigger reader menu actions | Force-save, force-print, depending on reader |
Net.HTTP.request (legacy) | Outbound network in older readers | Tracking pixels, beacon callbacks |
None of these grant remote code execution by themselves. Several have been chained with reader-specific sandbox escape CVEs in the past (Adobe Reader's well-documented history, the recurring Foxit batch CVEs, Chromium's PDF Viewer through V8). The realistic outcome of a generic exploitation campaign is phishing, tracking, and abuse of legitimate reader functionality. The realistic outcome of a targeted attack is using the injection as the first stage in a reader-specific exploit chain.
Who is exposed
The exposure is binary at the call site: any code path that flows untrusted input into addJS is exposed; any path that does not is not. The interesting question is what counts as untrusted input. In practice it is anything that did not come from a hardcoded literal in the source code, because the developer's source is the only data the codebase can trust by construction.
Three realistic vulnerable patterns observed in public jsPDF integrations:
// Pattern 1: form field flowing into a "personalized" script.
// The user can set company_name to anything, including injection bytes.
const doc = new jsPDF();
doc.text("Hello " + req.body.company_name, 10, 10);
doc.addJS(`app.alert('Welcome ${req.body.company_name}');`);
doc.save("welcome.pdf");
// Pattern 2: query-string driven debug behavior left in production.
// Common in admin tooling that gates AcroJS on a URL parameter.
const trigger = new URL(window.location.href).searchParams.get("onOpen");
if (trigger) {
doc.addJS(trigger);
}
// Pattern 3: database row flowing into a per-tenant document.
// Tenant operator controls a per-tenant macro, which becomes the addJS argument.
const macro = await db.tenants.findById(tenantId).select("pdfOpenMacro");
doc.addJS(macro);Pattern 1 is the most common in practice and the easiest to miss in code review. The developer is interpolating a value that "looks safe" (a company name) into a script context. Pattern 2 is what every long-running admin tool has somewhere. Pattern 3 is the worst because the attacker is a tenant administrator who has legitimate access to the field, so the injection bytes look like normal data to logging and monitoring.
Codebases that never call addJS are not exposed to this specific CVE. Codebases that call addJS only with literal strings written by the developer cannot fire the bug. Everything in between needs an audit.
Detect: am I vulnerable
Two steps cover the realistic detection workload.
Step one: check the jsPDF version. Read package.json for the direct dependency. Read package-lock.json or npm ls jspdf for transitive copies; UI libraries and reporting widgets sometimes pin their own jsPDF version. Any version below 4.2.0 is vulnerable if addJS is reachable. The check command:
npm ls jspdf
# Look for any line below 4.2.0Step two: find every addJS call site. A ripgrep one-liner finds them across TypeScript, JavaScript, JSX, and TSX:
rg -n '\.addJS\s*\(' --type ts --type js --type tsx --type jsxFor every hit, walk the lineage of the argument. If the argument is a literal string written by the developer, the site is not exposed. If the argument is a variable, trace where the variable came from. Any path back to HTTP input, URL parameters, database rows, file uploads, third-party API responses, or user-editable settings is a finding.
A second grep catches indirect uses through wrapper functions and dynamic property access patterns that ripgrep's first pass misses:
rg -n -e 'addJS\s*\[' -e 'jspdf.*addJS' --type ts --type js --type tsx --type jsxIf your project uses a wrapper around jsPDF (a PdfBuilder class, an internal helper module), grep for the wrapper's method names as well. The audit is mechanical and finite; for typical codebases the full pass takes under thirty minutes.
Patch and harden
The clean fix is the version bump:
npm install jspdf@^4.2.0
# or
yarn add jspdf@^4.2.0
# or
pnpm add jspdf@^4.2.0Re-run the test suite and any visual regression checks. The 4.x range carries a small breaking-change list documented in the jsPDF changelog, mostly around module imports and font handling. Most consumer integrations move forward with a one-line dependency bump.
If you cannot upgrade immediately (locked-down dependency policy, vendored fork, transitive dependency pinned by a UI framework you do not control), wrap every addJS call site with an escape pass before the call:
// PDF 1.7 §7.3.4 literal-string escape rules.
// Escape backslash FIRST so we do not double-escape what the
// parenthesis pass introduces.
function escapePdfLiteralString(input) {
if (typeof input !== "string") {
throw new TypeError("escapePdfLiteralString expects a string");
}
return input
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)");
}
// Replace every direct call to addJS with this safe wrapper.
function addJsSafe(doc, script) {
doc.addJS(escapePdfLiteralString(script));
}Two non-obvious details about the workaround. First, the escape rules in PDF 1.7 also cover non-printable bytes (\n, \r, \t, \b, \f) and octal escapes for arbitrary bytes. If your caller passes Unicode or control characters, the minimal three-character escape above is enough to defeat object injection but does not produce a PDF-spec-compliant string for every input. Patched jsPDF 4.2.0 handles the full rule set. Second, treat the wrapper as the only allowed entry point. Add a lint rule (no-restricted-syntax with a selector for CallExpression[callee.property.name='addJS']) that bans direct .addJS( calls everywhere except inside the wrapper module.
The defense-in-depth move, even after patching: treat addJS as a sink and never pass untrusted strings to it regardless of which jsPDF version is installed. AcroJS scripts that come from external input are very rarely worth the risk; the same UX outcomes can usually be achieved through PDF form fields with declarative validation rules, which have no dynamic-code execution semantics at all.
Why client-side PDF generation has this whole-class risk
The proximate bug is a missing escape pass. The structural bug is the choice to build PDF bytes through string concatenation in caller code. PDF is a structured binary format with delimited string, name, dictionary, and array constructs. Concatenating untrusted input into any of those constructs without escape is an injection class, in the same way concatenating untrusted input into SQL is SQL injection and into HTML is XSS.
Three earlier issues in the JavaScript PDF ecosystem followed the same shape. None are identical to CVE-2026-25755, but each comes from the same family: caller input flowing into a delimited PDF construct without escape. The recurring fix has been adding the escape pass at the library level, one method at a time, as researchers find each call site. The structural fix would be to never let caller bytes reach the PDF stream as raw bytes at all.
The reason client-side PDF libraries gravitate to this pattern is performance and bundle size. Building PDF objects through a typed AST and serializing the AST with proper escapes is slower and larger than concatenating strings. For a library that ships in browser bundles, those costs matter. The trade-off is that every consumer of the library has to be careful at every call site, and every consumer eventually misses one. The CVE list is the record of the misses.
| Bug class | Proximate cause | Structural cause | Realistic fix |
|---|---|---|---|
| PDF string injection (this CVE) | Missing escape on (, ), \ | String-concat into PDF byte stream | Escape every literal, every time |
| PDF name injection | Missing escape on # in name objects | Same | Escape every name, every time |
| PDF dict-key injection | User input as a name key | Same | Validate keys against an allowlist |
| Server-side rendering (alternative) | n/a | No user code emits PDF bytes | Browser print pipeline owns the bytes |
The architectural alternative: server-side rendering with browser sandbox
For workloads where the PDF content originates from HTML, server-side rendering through a headless browser sidesteps the entire bug class. The pipeline looks like this: caller provides HTML and data, the browser parses the HTML (with HTML's well-defined escape rules applied by the browser's parser), the browser lays out the page, the browser emits PDF bytes through its print path. At no point does user code assemble PDF object literals from concatenated strings.
The properties that this architecture inherits:
- HTML sanitization is the browser's job. Any rendering engine has decades of escape-rule testing baked in. The CVE history of HTML parsers is much smaller than the cumulative CVE history of bespoke PDF byte assemblers.
- The renderer runs sandboxed. Chromium's renderer process is sandboxed at the OS level. A bug that escapes HTML parsing into the renderer still has to escape the renderer sandbox to reach the host. AcroJS only gets you the reader's sandbox; the renderer sandbox is in front of the OS.
- PDF bytes come from the print path, not the application. Skia and the OS print system emit the PDF stream. Application code never holds raw PDF object literals.
PDF4.dev runs this model. The PDF generation pipeline takes HTML plus data, hands it to Chromium running Playwright, and emits the result through the standard print path. There is no place in the request flow where caller-supplied strings are concatenated into PDF object literals. The bug class CVE-2026-25755 belongs to does not apply to that architecture.
Server-side rendering is not a silver bullet. It opens its own classes of risk: SSRF if the HTML can fetch external resources, CSS-based information leaks if the renderer caches across tenants, prompt-injection-style abuses if AI agents drive the renderer. The right defense is layered: HTML sanitization at the input, sandboxed rendering in the middle, signed delivery URLs at the output. For the specific class of "attacker breaks out of a PDF string literal", the architecture forecloses the attack rather than patching it.
The decision between client-side and server-side PDF generation is workload-driven. Client-side libraries are the right answer for offline-capable applications, fully-static PDF outputs with no user data, and bundle-size-sensitive embeddings where the request round-trip is unacceptable. Server-side rendering is the right answer for any workload where untrusted strings are concatenated into the PDF content, which covers most invoice, contract, receipt, and report use cases. The CVE makes the case for the second architecture without overstating it: the first architecture is fine when the inputs are trusted, and trust is the variable that changes between projects.
For developers shipping new PDF pipelines this week: upgrade jsPDF to 4.2.0 if you are using it, wrap addJS behind an escape function if you cannot upgrade today, and treat any code path that flows external input into PDF construction as a sink that needs the same hygiene you apply to SQL and HTML. The CVE history is the receipt for what happens when that hygiene slips.
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



