Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
of the image).
- `blur` and `sharpen` hung with an infinite radius, they throw a RangeError now; huge finite radii take as long as
small ones (`blur(1e9)` took minutes).
- `Image.renderSVG` against denial of service: elements rendered through `<use>` references multiplying each other
("billion laughs", a billion rectangles from a 1 kB file) are limited to 100,000 (like librsvg), dash patterns of
more than a million dashes stroke solid (like Skia) instead of taking any amount of memory, and arbitrarily deep
nesting no longer overflows the stack.

## 3.2.1

Expand Down
37 changes: 29 additions & 8 deletions src/render/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ interface Context {
viewport: Viewport;
layer: Layer;
depth: number;
/** set within the elements a <use> references */
instanced?: boolean;
}

interface Paint {
Expand Down Expand Up @@ -413,7 +415,13 @@ function validProperty(name: string, value: string): boolean {
// document & rendering
// ---------------------------------------------------------------------------

// the most elements to render through <use> references (like librsvg's limit), and dashes of a path
const MAX_INSTANCES = 100000;
const MAX_DASHES = 1000000;

class Document {
/** elements rendered through <use> references so far */
instances: number;
ids: Map<string, XNode>;
active: Set<XNode>;
root: XNode;
Expand All @@ -422,17 +430,20 @@ class Document {
constructor(root: XNode) {
this.ids = new Map();
this.active = new Set(); // clip paths and masks being rendered, to break reference cycles
this.instances = 0;
const styles: string[] = [];

const walk = (node: XNode): void => {
// in document order, with a stack instead of recursion (documents can nest elements arbitrarily deep)
const stack = [root];
while (stack.length) {
const node = stack.pop() as XNode;
if (node.attrs.id && !this.ids.has(node.attrs.id)) this.ids.set(node.attrs.id, node);
if (node.name === 'style' && node.svg && (!node.attrs.type || node.attrs.type === 'text/css')) styles.push(node.text);
for (const child of node.children) {
child.parent = node;
walk(child);
for (let i = node.children.length - 1; i >= 0; i--) {
node.children[i].parent = node;
stack.push(node.children[i]);
}
};
walk(root);
}

this.root = root;
this.rules = parseStyleSheet(styles.join('\n'));
Expand Down Expand Up @@ -507,6 +518,8 @@ class Document {

renderNode(node: XNode, context: Context, root = false): void {
if (!node.svg || UNSUPPORTED.has(node.name) || context.depth > 32 || !this.passesConditions(node)) return;
// <use> references can multiply each other ("billion laughs"): their elements are counted
if (context.instanced && ++this.instances > MAX_INSTANCES) throw new Error(`SVG: more than ${MAX_INSTANCES} elements rendered through <use> references`);

const style = this.computeStyle(node, context.style);
if (style.display === 'none') return;
Expand Down Expand Up @@ -620,7 +633,7 @@ class Document {
const inner = viewBox ? multiply(outer, viewBoxTransform(viewBox, width, height, target.attrs.preserveAspectRatio)) : outer;

const opacity = clamp01(parseNumber(style.opacity, 1));
const innerContext = { ...ctx, matrix: inner, style, viewport: viewBox ? { width: viewBox[2], height: viewBox[3] } : { width, height } };
const innerContext = { ...ctx, matrix: inner, style, viewport: viewBox ? { width: viewBox[2], height: viewBox[3] } : { width, height }, instanced: true };
const draw = (layer: Layer): void => /^(visible|auto)$/.test(target.attrs.overflow || '')
? this.renderChildren(target, { ...innerContext, layer })
: this.clipped({ ...ctx, matrix: outer, layer }, 0, 0, width, height, inner => this.renderChildren(target, { ...innerContext, layer: inner }));
Expand All @@ -635,7 +648,7 @@ class Document {
// render the referenced element as if it were a child of the use element
const computed = target.computed, computedParent = target.computedParent;
target.computed = null;
this.renderNode(target, { ...ctx, matrix });
this.renderNode(target, { ...ctx, matrix, instanced: true });
target.computed = computed;
target.computedParent = computedParent;
}
Expand Down Expand Up @@ -1570,6 +1583,14 @@ function dash(subpaths: Subpath[], pattern: number[], offset: number): Subpath[]
const total = pattern.reduce((a, b) => a + b, 0);
const result: Subpath[] = [];

// too many dashes stroke the path solid, as Skia does (they'd take any amount of memory)
let length = 0;
for (const { points, closed } of subpaths) {
const pts = closed ? [...points, points[0], points[1]] : points;
for (let i = 0; i + 3 < pts.length; i += 2) length += Math.hypot(pts[i + 2] - pts[i], pts[i + 3] - pts[i + 1]);
}
if (!(length / total * pattern.length <= MAX_DASHES)) return subpaths;

for (const { points, closed } of subpaths) {
const pts = closed ? [...points, points[0], points[1]] : points;

Expand Down
18 changes: 18 additions & 0 deletions tests/regressions.js
Original file line number Diff line number Diff line change
Expand Up @@ -637,4 +637,22 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
try { image.blur(Infinity); } catch { threw = true; }
assert(threw, 'blur(Infinity) should throw');
}

// SVGs against denial of service: <use> references multiplying each other ("billion laughs") throw quickly,
// dash patterns too fine to dash stroke solid, deeply nested elements render
{
const NS = 'xmlns="http://www.w3.org/2000/svg"';
let defs = '<g id="l0"><rect width="1" height="1"/></g>';
for (let i = 1; i <= 9; i++) defs += `<g id="l${i}">${'<use href="#l' + (i - 1) + '"/>'.repeat(10)}</g>`;
let start = Date.now(), error = null;
try { await Image.renderSVG(`<svg ${NS} width="100" height="100"><defs>${defs}</defs><use href="#l9"/></svg>`, {size: 100, mode: Image.SVG_MODE_WIDTH}); } catch (err) { error = err; }
assert(error && /<use> references/.test(error.message) && Date.now() - start < 3000, `billion laughs should throw quickly (${error && error.message}, ${Date.now() - start} ms)`);

start = Date.now();
const dashed = await Image.renderSVG(`<svg ${NS} width="100" height="100"><path d="M0 50 L100000000 50" stroke="black" stroke-width="4" stroke-dasharray="0.0001"/></svg>`, {size: 100, mode: Image.SVG_MODE_WIDTH});
assert(Date.now() - start < 3000 && dashed.getRGBAAt(50, 50)[3] === 255, 'a dash pattern too fine to dash should stroke solid, quickly');

const nested = await Image.renderSVG(`<svg ${NS} width="100" height="100">${'<g>'.repeat(100000)}<rect width="10" height="10"/>${'</g>'.repeat(100000)}</svg>`, {size: 100, mode: Image.SVG_MODE_WIDTH});
assert(nested.width === 100, 'deeply nested elements should render');
}
})();
Loading