Skip to content

Stop crafted SVGs and empty tables from crashing or stalling - #61

Merged
czj merged 1 commit into
mainfrom
claude/upbeat-carson-1rwsu6
Nov 6, 2026
Merged

czj merged 1 commit into
mainfrom
claude/upbeat-carson-1rwsu6

Conversation

@czj

@czj czj commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

An SVG a user uploads could still crash or stall the server that renders it, in the default configuration. Gamba checked the document's own tree for depth and entity expansion, but not an SVG loaded through <use> or <image>, so a data: URL nested 4,000 levels deep overflowed the stack, and entities in an attribute raised a bare RuntimeError. <tref> had none of the guards <use> has: a tref inside its own text overflowed the stack, one to a missing id raised NoMethodError, and sixteen levels of two took 38 seconds. Each embedded SVG image also got a fresh <use> budget, so 64 copies of one heavy image took 82 seconds, and with enable_web_requests: true an SVG embedding itself fetched itself 415 times before the stack ran out. table([[]]) raised NoMethodError too.

Loaded SVGs now go through the same parse and check as the document, attributes included. <tref> draws through the <use> path, so it gets the ancestor check and the 20,000-element budget. Embedded SVG images share the budget of the SVG that embeds them and stop at 16 levels. data: URLs are decoded once per document, and an @font-face that is not a font becomes a warning. A table with no cells draws nothing.

  • Same pages otherwise: the oracle still passes, and every new limit sits far above what a valid document reaches. prawn-svg 0.40.4 and prawn-table 0.2.2 crash on every one of these inputs (checked against the oracle bundle), so each change is a declared divergence: the table one in test/divergences.yml with an oracle example, the SVG ones in the Files from users section of the compatibility page.
  • Addressable floor raised: css_parser joins every @import path with addressable, and before 2.9.1 (GHSA-qq38-m9mg-xjj5) that join took quadratic time on dot segments. A 20 KB import path took 2.1 s on 2.9.0 and 0.2 s on 2.9.2. The gemspec now asks for >= 2.9.1, and the README and guide list addressable as a dependency.
  • Flaky tests fixed: svg_security_test.rb raised NameError when an InvalidSVGData test ran first, because Prawn::SVG was not loaded yet; it now requires prawn-svg. The input fuzzer now accepts Prawn::Errors::CannotFit, which a corrupted advance width can raise just as in Prawn (seed 1234, case 943).
  • Docs: the README says which references an SVG may load (data URLs, web only on request, files only under the root) and notes that a very long unbroken word costs time quadratic in its length, as in Prawn. The gemspec summary and description now read like the README instead of a tagline.
Proof

Every new test fails on main and passes here: test_an_svg_loaded_by_use_is_checked_like_the_document (SystemStackError), test_entities_expanding_too_far_in_an_attribute_are_invalid_svg, test_svg_images_drawn_through_use_share_the_budget_of_their_document, test_svg_images_inside_svg_images_stop_at_a_depth, test_a_data_url_image_drawn_through_use_is_decoded_once (timeout), test_a_tref_inside_the_text_it_refers_to_is_skipped (SystemStackError), test_a_tref_to_a_missing_or_later_text_draws_nothing (NoMethodError), test_trefs_doubling_at_each_level_stop_at_a_budget (timeout), test_a_font_face_that_is_not_a_font_is_left_out (Gamba::TrueType::Error), and the oracle example table_without_cells (NoMethodError). test_an_import_climbing_back_up_its_path_resolves_quickly guards the addressable floor.

Reproductions, before then after, Ruby 3.3.6:

  • 64 <use> copies of an SVG image drawing 20,000 elements: 82 s, then 1.3 s
  • 16,384 <use> copies of a 480 KB PNG data URL: 11.4 s, then 2.6 s
  • self-embedding SVG over the web: 415 fetches and SystemStackError, then 17 fetches and a warning
  • <tref> doubling, 16 levels: 37.7 s, then 1.3 s with a warning

Benchmarks against origin/main on Ruby 3.3.6 (a shared 4-core VM, so a few percent is noise). bench/run.rb, median of 5 alternating processes of 20 renders, main then branch: invoice 17.1 / 17.4 ms without JIT and 16.7 / 16.2 ms with YJIT, report 825 / 826 and 842 / 804 ms, catalog 1,507 / 1,495 and 1,482 / 1,529 ms. All 120 prawn-svg sample SVGs, best of 10 renders in each of 4 alternating processes: 971 / 1,008 ms without JIT and 986 / 1,005 ms with YJIT, with allocations within 0.1% of main. Published numbers are unchanged.

bundle exec rake test (corpus fetched, mupdf, poppler and qpdf installed): 1,224 runs, 0 failures, 3 skips (veraPDF and Mustang are not installed here); ORACLE_CANDIDATE=prawn FUZZ_CASES=0 gives 1,224 runs, 0 failures. FUZZ_SEED=4242 FUZZ_CASES=500 and INPUT_FUZZ_CASES=3000 with seeds 777 and 1234 (the seed that failed on main) pass, RuboCop is clean, and rake docs:highlight:check passes.

  • Tests cover the change (a bug fix comes with a test that fails without it)
  • Output unchanged, or every difference is intended and listed above

An SVG loaded by <use> or <image> is parsed and checked like the document,
attributes included, so deep nesting and entity bombs in it are refused
instead of overflowing the stack or raising a bare RuntimeError. <tref>
draws through the <use> path: no tref inside its own text, no crash on a
missing reference, and the same 20,000-element budget. Embedded SVG
images share the budget of the SVG that embeds them and stop at 16
levels, which ends an SVG embedding itself over the web. data: URLs are
decoded once per document, an @font-face that is not a font becomes a
warning, and a table without cells draws nothing.

addressable 2.9.1 is now required: css_parser joins @import paths with
it, which took quadratic time on dot segments before that version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLwjZyBMrd7ZWPbSW6dDXp
@czj
czj enabled auto-merge (squash) November 6, 2026 12:33
@czj
czj merged commit adb2101 into main Nov 6, 2026
8 checks passed
@czj
czj deleted the claude/upbeat-carson-1rwsu6 branch November 6, 2026 12:38
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.

2 participants