Get your API key
CVE-2026-25755: jsPDF object injection turns addJS into an AcroJS bypass

CVE-2026-25755: jsPDF object injection turns addJS into an AcroJS bypass

CVE-2026-25755 in jsPDF lets attacker strings break out of addJS() and inject PDF objects, bypassing the AcroJS sandbox. CVSS 8.8, fixed in jspdf 4.2.0.

13 min read

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 APIWhat it doesAbuse pattern
app.alertForced dialogPhishing prompts, fake error messages
app.launchURLOpen URL in browser (usually prompted)Drive users to attacker domain on document open
this.submitFormPOST form data to URLExfiltrate any field the user filled in
this.getFieldRead form field valuesCombine with submitForm for credential harvest
app.execMenuItemTrigger reader menu actionsForce-save, force-print, depending on reader
Net.HTTP.request (legacy)Outbound network in older readersTracking 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.0

Step 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 jsx

For 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 jsx

If 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.0

Re-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 classProximate causeStructural causeRealistic fix
PDF string injection (this CVE)Missing escape on (, ), \String-concat into PDF byte streamEscape every literal, every time
PDF name injectionMissing escape on # in name objectsSameEscape every name, every time
PDF dict-key injectionUser input as a name keySameValidate keys against an allowlist
Server-side rendering (alternative)n/aNo user code emits PDF bytesBrowser 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.

Frequently asked questions

Is my jsPDF app vulnerable to CVE-2026-25755?
Any jsPDF version below 4.2.0 that calls `doc.addJS(...)` with a string that ever touches data from outside the codebase is vulnerable. "Outside the codebase" covers HTTP form fields, URL query parameters, database rows, file uploads, third-party API responses, and user-editable document metadata. If `addJS` is never called, the codebase is not exposed to this specific CVE. If it is called only with hardcoded literals that the developer wrote in source, the bug cannot fire. Anything in between needs an audit.
How do I escape parentheses for jsPDF addJS?
Inside a PDF literal string, the three characters that need escaping are `(`, `)`, and `\`. Each must be prefixed with a backslash before being inserted into the string body. A minimal JavaScript escaper looks like `input.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')`. The order matters: escape the backslash first, otherwise the backslashes added by the parenthesis-escaping pass get doubled. Patched jsPDF 4.2.0 does this internally. If you cannot upgrade today, wrap every call site to `addJS` with the same escaper and treat the wrapper as the only allowed entry point.
Does this affect pdf-lib or pdfkit too?
Not under this specific CVE identifier. CVE-2026-25755 is scoped to jsPDF's `addJS` implementation. However, the bug class (string concatenation of untrusted input into a delimited binary format) is generic to PDF libraries. pdf-lib and pdfkit have had their own historical issues around metadata and annotation fields where caller input is interpolated into PDF strings or names. Audit every library you use the same way: find every code path that builds a PDF object containing caller-supplied bytes, and confirm the library escapes the relevant delimiters before writing them out.
Can a malicious PDF take over my user's machine via this CVE?
Direct machine takeover is not the realistic outcome. The injected payload runs as AcroJS inside the reader's PDF JavaScript engine, which is sandboxed by every modern reader. AcroJS cannot reach the local filesystem, cannot run shell commands, and cannot access browser DOM or cookies. The realistic outcomes are nuisance behavior (forced alerts, redirects via `app.launchURL`), social engineering setups (fake password prompts rendered through PDF form fields), tracking pixels via outbound network requests where the reader allows them, and exploitation of reader-specific AcroJS bugs that have separate CVE identifiers. The CVE matters because it gets attacker code into a context the developer never authorized.
Is server-side PDF rendering really safer than client-side?
For the bug class CVE-2026-25755 represents (string-concat object injection in a library that emits PDF bytes directly), yes. Server-side rendering through a browser produces PDFs from HTML that the browser's HTML parser sanitized before layout. The PDF bytes are emitted by the browser's print pipeline, not by string concatenation in user code. The attack surface is different: server-side rendering opens its own classes of risk like SSRF and CSS-based XSLeak. But the specific class of "attacker string breaks out of a PDF object literal" does not apply because no user code is assembling PDF objects from strings. The right defense depends on the threat model, and combining the two layers (HTML sanitization + library-level escapes) is the most defensible posture.
How do I find all addJS calls in my codebase?
Ripgrep is the fastest path. Run `rg -n '\.addJS\s*\(' --type ts --type js --type tsx --type jsx` from the repo root. The output gives you every call site with file and line. For each call site, walk the data lineage of the argument: is it a literal, a constant, or a value that came from outside the codebase? Anything that came from outside is a potential injection sink. Add the wrapped escaper or upgrade to jsPDF 4.2.0, then add a lint rule that bans direct calls to `addJS` and forces callers through your wrapper.
Does the AcroJS sandbox prevent any harm even after injection?
The AcroJS sandbox limits but does not eliminate harm. AcroJS has APIs to display alerts (`app.alert`), open URLs (`app.launchURL`, typically prompted), submit form data (`this.submitForm`), and call back into the host application through extension APIs. None of those are filesystem or shell access, so they fall short of remote code execution, but several are useful for phishing, tracking, and abuse of legitimate reader functionality. Sandboxes also have a history of their own escape CVEs across Adobe Reader, Foxit, and Chromium's PDF Viewer. The defense in depth conclusion is the same as for browser JavaScript: untrusted code inside a sandbox is safer than untrusted code outside one, but you still do not want it there.
Should I switch from jsPDF to pdf-lib?
Not on the strength of one CVE. jsPDF shipped a fix in 4.2.0 within a reasonable disclosure window, and the bug class is generic to PDF libraries that interpolate caller input into binary output. The cases for migration from jsPDF to pdf-lib are stronger when (1) you need richer form, encryption, or signature support, (2) you want a maintained API with TypeScript-first ergonomics, (3) your bundle size budget cannot absorb jsPDF's footprint. The cases against are jsPDF's larger plugin ecosystem (autotable, html-to-canvas adapters) and the fact that pdf-lib has its own historical issues. Pick the library that fits the workload, then audit every call site that touches untrusted input regardless of which library you chose.

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.