Fix segfault, use-after-free and per-parse leak in the SVG paths - #2613
Draft
iurisilvio wants to merge 4 commits into
Draft
Fix segfault, use-after-free and per-parse leak in the SVG paths#2613iurisilvio wants to merge 4 commits into
iurisilvio wants to merge 4 commits into
Conversation
iurisilvio
force-pushed
the
svg-crash-and-leak-tests
branch
2 times, most recently
from
August 20, 2026 07:15
d92f484 to
c935e8b
Compare
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>
iurisilvio
force-pushed
the
svg-crash-and-leak-tests
branch
from
August 20, 2026 07:22
c935e8b to
4a4e423
Compare
iurisilvio
marked this pull request as draft
August 20, 2026 11:14
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofloadFromBuffer()and returned whatever surface the failed decode left behind, which for an unsupported format is null. lunasvg passed that straight tocairo_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_surfacewithout nulling it before its error returns, so the next render andclearData()both destroy it again:Only an assertion-enabled cairo turns it into an abort, so it shows up on the debug build
zig buildproduces and is a silent use-after-free in aReleaseFastone.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 throughcairo_image_surface_create_for_data(), which cairo does not own.transferSurface()hands the surface to lunasvg and drops_datawithout 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:Changes
transferSurface()attaches_datato 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_surfaceafter destroying it, and treats a nullBitmap— whatrenderToBitmap()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 whensurface()returns null. It always could, once a re-render fails; nothing checked because the abort came first.loadSVGFromBuffer()releases the parsed document on both of its error paths, where it is only reachable again through anImagethat never completed loading.pkg/lunasvgpicks 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 buildandzig build -Doptimize=ReleaseFast.