Skip to content

Fix segfault, use-after-free and per-parse leak in the SVG paths - #2613

Draft
iurisilvio wants to merge 4 commits into
Automattic:masterfrom
iurisilvio:svg-crash-and-leak-tests
Draft

Fix segfault, use-after-free and per-parse leak in the SVG paths#2613
iurisilvio wants to merge 4 commits into
Automattic:masterfrom
iurisilvio:svg-crash-and-leak-tests

Conversation

@iurisilvio

Copy link
Copy Markdown
Contributor

Three failures in the lunasvg SVG paths, each with a test that fails on master and passes here. Reproduced first against the published canvas@4.0.0-rc2, then against a source build of master.

The failures

An SVG with an embedded image in a format we cannot decode segfaults. The decoder callback in loadSVGFromBuffer() ignored the status of loadFromBuffer() and returned whatever surface the failed decode left behind, which for an unsupported format is null. lunasvg passed that straight to cairo_surface_set_user_data(). Loading a document with <image href="data:image/webp;base64,..."> — or with corrupt image data — takes the process down.

A failed SVG re-render leaves a dangling surface. renderSVGToSurface() destroyed _surface without nulling it before its error returns, so the next render and clearData() both destroy it again:

const img = new Image()
img.src = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16"/></svg>')
ctx.drawImage(img, 0, 0)
img.width = img.height = 40000   // > the largest image surface cairo will make
ctx.drawImage(img, 0, 0)         // throws, as it should
ctx.drawImage(img, 0, 0)         // aborts
Assertion failed: (CAIRO_REFERENCE_COUNT_HAS_REFERENCE (&surface->ref_count)),
function cairo_surface_destroy, file cairo-surface.c, line 963

Only an assertion-enabled cairo turns it into an abort, so it shows up on the debug build zig build produces and is a silent use-after-free in a ReleaseFast one.

Every parse of an SVG with an embedded JPEG, GIF or BMP leaks one decoded frame. Those decoders back their surface with a new uint8_t[] buffer through cairo_image_surface_create_for_data(), which cairo does not own. transferSurface() hands the surface to lunasvg and drops _data without freeing it or attaching a destroy callback, so nothing frees the pixels — ever. An embedded PNG is unaffected, since cairo owns the pixels on that path, which makes it a clean control:

1200x1200 raster, RSS slope over 100 parses after a 50-parse warmup
master     embedded PNG      1 KiB/parse     embedded JPEG  6238 KiB/parse
this PR    embedded PNG      2 KiB/parse     embedded JPEG     5 KiB/parse
                                             (one decoded frame is 5625 KiB)

Changes

  • transferSurface() attaches _data to the surface with a destroy callback, so the buffer dies with the surface that points at it. If cairo cannot store it, the surface goes too rather than leaving a buffer nothing can free.
  • renderSVGToSurface() nulls _surface after destroying it, and treats a null Bitmap — what renderToBitmap() returns for a document with no intrinsic size, or when the allocation fails — as an error instead of reading a status through a null pointer.
  • Context2d::DrawImage() stops when surface() returns null. It always could, once a re-render fails; nothing checked because the abort came first.
  • The decoder callback returns null on a failed decode instead of a half-built surface.
  • loadSVGFromBuffer() releases the parsed document on both of its error paths, where it is only reachable again through an Image that never completed loading.
  • pkg/lunasvg picks up Handle a null surface from the image decoder callback chearon/lunasvg#1, which null-checks the decoder result. This currently points at my fork so the tests pass here — happy to re-point it at chearon/lunasvg once that lands, or drop the bump and let it come in separately.

Tests

test/svg.test.js. Both crashes take the process down, so each case runs in a child process and the test reports the signal. The leak test measures an RSS slope and calibrates it against the same image embedded as a PNG in the same process, rather than against a fixed threshold — debug and release builds differ by two orders of magnitude in allocator noise.

On master: 3 failing. On this branch: the full suite passes, 308 tests, on both zig build and zig build -Doptimize=ReleaseFast.

  • Have you updated CHANGELOG.md?

@iurisilvio
iurisilvio force-pushed the svg-crash-and-leak-tests branch 2 times, most recently from d92f484 to c935e8b Compare August 20, 2026 07:15
iurisilvio and others added 3 commits August 20, 2026 09:22
Covers three failures reproduced against canvas@4.0.0-rc2:

- An SVG whose embedded `<image>` is a format node-canvas cannot decode
  segfaults. The decoder callback in `loadSVGFromBuffer` ignores the status of
  `loadFromBuffer`, so `transferSurface()` returns a null surface and lunasvg
  passes it straight to `cairo_surface_set_user_data`.

- A failed SVG re-render aborts the process on the next render or on GC.
  `renderSVGToSurface` destroys `_surface` without nulling it before its error
  return, so the pointer is destroyed a second time.

- Every parse of an SVG with an embedded JPEG (also GIF and BMP) leaks the
  decoded frame: those decoders back the surface with
  `cairo_image_surface_create_for_data`, and `transferSurface()` drops `_data`
  without freeing it or attaching a destroy callback. Measured 1.4 MiB per
  parse of a 600x600 JPEG, linear and surviving a full finalizer drain. The
  same test over an embedded PNG passes and acts as the control, since cairo
  owns the pixels on that path.

The two crashes take the process down, so each case runs in a child process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`transferSurface()` hands the decoded surface to lunasvg and drops every
pointer we hold. The JPEG, GIF and BMP decoders back their surface with a
`new uint8_t[]` buffer through cairo_image_surface_create_for_data, which
cairo does not own, so that buffer was leaked on every parse of an SVG with an
embedded raster in one of those formats -- a whole decoded frame each time,
1.4 MiB for a 600x600 JPEG, unbounded. Attach it to the surface with a destroy
callback so it dies with the surface it belongs to.

`renderSVGToSurface()` destroyed `_surface` without nulling it before its
error returns, so a re-render that fails at a size cairo rejects left a
dangling pointer for the next render and for clearData() to destroy again.
Null it, and treat a null Bitmap -- what renderToBitmap() returns when the
document has no intrinsic size or the allocation fails -- as an error instead
of reading a status through a null pointer. DrawImage now also stops when
surface() returns null, which it does once the re-render throws.

The decoder callback ignored the status of loadFromBuffer() and returned
whatever surface the failed decode left behind. Return null there, and take
lunasvg's fix for the null it has always been able to get back: it fed that
straight to cairo_surface_set_user_data, so an SVG carrying an image in a
format node-canvas cannot decode segfaulted.

`loadSVGFromBuffer()` also kept the parsed document alive on both of its error
paths, where it can only be reached again through an Image that never
completed loading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch it pinned was rebased to add the regression test and the cairo
lookup its build needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant