Skip to content
Merged
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
40 changes: 39 additions & 1 deletion src/skia/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ import { ck } from "./init.ts";
// biome-ignore lint/suspicious/noExplicitAny: CanvasKit Image
type SkImage = any;

/**
* Upper bound on the SVG rasterization surface (per side).
*
* The raster surface costs `width * height * 4` bytes, and an SVG can *declare*
* an arbitrary intrinsic size through its `width`/`height` attributes or its
* `viewBox` — none of which is bounded by the document's byte length. A ~100
* byte document claiming `60000x60000` would ask CanvasKit for ~14 GB, an
* untrusted-input memory-exhaustion DoS. Cap the longest side and scale the
* other axis proportionally so the image still renders correctly, just bounded
* in resolution. 8192 matches common GPU max-texture limits and is far larger
* than any realistic layout target.
*/
const MAX_SVG_SIDE = 8192;

const registry = new Map<number, SkImage>();
let nextId = 1;

Expand Down Expand Up @@ -105,7 +119,7 @@ function rasterizeSvg(bytes: Uint8Array): SkImage {
if (dom == null) throw new Error("Failed to parse SVG document.");

try {
const [width, height] = readContainerSize(dom, markup);
const [width, height] = clampContainerSize(readContainerSize(dom, markup));
const surface = kit.MakeSurface(Math.ceil(width), Math.ceil(height));
if (surface == null) {
throw new Error(`Failed to allocate a ${width}x${height} SVG surface.`);
Expand Down Expand Up @@ -143,4 +157,28 @@ function readContainerSize(
return [300, 150]; // CSS default replaced-element size
}

/**
* Bound the rasterization size so an attacker-declared SVG dimension cannot
* drive an unbounded surface allocation (memory-exhaustion DoS). Non-finite or
* non-positive dimensions (e.g. a malformed `width`/`viewBox`) fall back to the
* CSS default replaced-element size rather than reaching `MakeSurface`.
*/
function clampContainerSize([width, height]: [number, number]): [
number,
number,
] {
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return [300, 150];
}
const longest = Math.max(width, height);
if (longest <= MAX_SVG_SIDE) return [width, height];
const scale = MAX_SVG_SIDE / longest;
return [width * scale, height * scale];
}

export type { SkImage };
37 changes: 37 additions & 0 deletions test/image-svg-dos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "vitest";
import { SoneImage } from "../src/skia/image.ts";
import { ready } from "../src/skia/init.ts";

const MAX_SVG_SIDE = 8192;

function svg(attrs: string): Uint8Array {
return new TextEncoder().encode(
`<svg xmlns="http://www.w3.org/2000/svg" ${attrs}><rect width="10" height="10"/></svg>`,
);
}

test("SVG raster size is clamped so a tiny document cannot force a huge allocation", async () => {
await ready();

// A ~100-byte document declaring 60000x60000 would ask for ~14 GB unclamped.
const huge = SoneImage.decode(svg('width="60000" height="60000"'));
expect(huge.width).toBeLessThanOrEqual(MAX_SVG_SIDE);
expect(huge.height).toBeLessThanOrEqual(MAX_SVG_SIDE);
// Aspect ratio is preserved (square stays square).
expect(huge.width).toBe(huge.height);
huge.delete();

// viewBox is subject to the same bound.
const hugeViewBox = SoneImage.decode(svg('viewBox="0 0 50000 25000"'));
expect(hugeViewBox.width).toBeLessThanOrEqual(MAX_SVG_SIDE);
expect(hugeViewBox.height).toBeLessThanOrEqual(MAX_SVG_SIDE);
hugeViewBox.delete();
});

test("normal-sized SVG is rasterized at its declared size", async () => {
await ready();
const normal = SoneImage.decode(svg('width="200" height="120"'));
expect(normal.width).toBe(200);
expect(normal.height).toBe(120);
normal.delete();
});
Loading