gamba 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50c5a0eb0a2606ff06311c8ce8ea69acd633ef34846f86cc61990744658a2746
4
- data.tar.gz: a0f8a019e088c71f118d751e2160712e242aa1666ab6cd269b898730f0ad4ab1
3
+ metadata.gz: 6909a10a173cb8d39f9f93432f7b47495e2bf18ca5d56559b263ac8292235ba7
4
+ data.tar.gz: 1a54fa9a6878a7eab8ed06e4d9b57fde344ad3c3edd850c93de2fb5b2bd099fd
5
5
  SHA512:
6
- metadata.gz: 84ff4a9295d3078e0864f681e65dcf72f9e624d4a5542bc3cc4fd2233fc2db15c33f0e936e539b6ae29ce7dd5114debceafec21c0d93780044fcd1f2ea189650
7
- data.tar.gz: 5c3a6d22c9d427386ba0a4fb4ff259c78c05d4ca9b72b73fda006c1a5180f9a3bb554914a9d49b9e5cd7c5da504f03ca86cb68ca7742d9561bd49c44151483c1
6
+ metadata.gz: f8eb03f65d8d6e26264a0fbea81cb38bf68968b171bb84b41aeafd3b11360024c2ad6cfa2ce171a321861a6bfc2756ec8cd445168d20d345724358eb10604b3c
7
+ data.tar.gz: bf668afa09665f836ed62ff0b8d2a593baabaa062353c1b46b9297439bdc6c9cd3e0c977b937a397fa3a3e03dd53dd4cf8478133d49b0aadd7602b20c2cfbc18
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.0.2] — 2026-11-07
6
+
7
+ SVGs that users send can no longer crash or stall a server through `<use>`, `<image>` or `<tref>`.
8
+
9
+ **Fixed**
10
+ - An SVG loaded through `<use>` or `<image>` is checked like the document, attributes included: a `data:` URL nested
11
+ thousands of levels deep overflowed the stack, and entities in an attribute raised `RuntimeError`.
12
+ - `<tref>` draws through the `<use>` path: a tref inside its own text no longer overflows the stack, one to a missing
13
+ id no longer raises `NoMethodError`, and trefs doubling at each level stop at the 20,000-element budget.
14
+ - Embedded SVG images share the budget of the SVG that embeds them and stop at 16 levels, and `data:` URLs are
15
+ decoded once per document.
16
+ - An `@font-face` that is not a font becomes a warning, and a table with no cells draws nothing instead of raising
17
+ `NoMethodError`.
18
+ - addressable 2.9.1 or later is required: before it, an `@import` path took quadratic time to resolve
19
+ (GHSA-qq38-m9mg-xjj5).
20
+
5
21
  ## [2.0.1] — 2026-11-06
6
22
 
7
23
  Fonts, images, SVGs and PDFs that users send can no longer hang a server, exhaust its memory or crash it.
@@ -88,7 +104,8 @@ speed. Same API and same pages as Prawn, pixel for pixel, in lighter files.
88
104
  place of combine_pdf.
89
105
  - `Gamba::VERSION` is the gem's; `Prawn::VERSION` stays 2.5.0, the API reproduced.
90
106
 
91
- [Unreleased]: https://github.com/levups/gamba/compare/v2.0.1...HEAD
107
+ [Unreleased]: https://github.com/levups/gamba/compare/v2.0.2...HEAD
108
+ [2.0.2]: https://github.com/levups/gamba/compare/v2.0.1...v2.0.2
92
109
  [2.0.1]: https://github.com/levups/gamba/compare/v2.0.0...v2.0.1
93
110
  [2.0.0]: https://github.com/levups/gamba/compare/v1.1.0...v2.0.0
94
111
  [1.1.0]: https://github.com/levups/gamba/compare/v1.0.1...v1.1.0
data/README.md CHANGED
@@ -96,11 +96,18 @@ Any of it can be built to attack the server that renders it.
96
96
  - SVG images fetch nothing from the web unless you pass `enable_web_requests: true`. prawn-svg 0.40 fetched by
97
97
  default, so a submitted SVG could make your server request any URL. prawn-svg 1.0 plans the same default as
98
98
  Gamba.
99
- - An SVG can't make Gamba loop or run out of stack through references (`<use>`, gradients) or deep nesting, and
100
- fonts it declares with `@font-face` disappear once it is drawn. In prawn-svg, an uploaded SVG declaring a
101
- "Helvetica" font changed the font of the application's own text.
99
+ - An SVG reads only what you allow. Base64 `data:` URLs of PNG, JPEG and SVG images always load; `http` and
100
+ `https` URLs need `enable_web_requests: true`; files need `enable_file_requests_with_root`, and only regular
101
+ files inside that directory load, symbolic links in it followed. Any other URL, path or scheme is skipped with a
102
+ warning, and an entity the SVG declares from a file or URL is never read.
103
+ - An SVG can't make Gamba loop or run out of stack through references (`<use>`, `<tref>`, gradients) or deep
104
+ nesting. An SVG it loads with `<use>` or `<image>` is checked the same way, SVG images stop 16 levels inside
105
+ one another, and they draw within the budget of the SVG that embeds them.
106
+ - Fonts an SVG declares with `@font-face` disappear once it is drawn, and one that is not a font is left out. In
107
+ prawn-svg, an uploaded SVG declaring a "Helvetica" font changed the font of the application's own text.
102
108
  - A damaged or malicious PNG, JPEG, TrueType or OpenType file either renders or raises an error Gamba names
103
- (`Prawn::Errors::UnsupportedImageType`, `Prawn::Images::JPG::FormatError`, `Gamba::TrueType::Error`). A PNG
109
+ (`Prawn::Errors::UnsupportedImageType`, `Prawn::Images::JPG::FormatError`, `Gamba::TrueType::Error`, or
110
+ `Prawn::Errors::CannotFit` when a damaged font makes a glyph wider than the page). A PNG
104
111
  inflates no further than the size its header gives. CI corrupts sample files on every run to check this.
105
112
  - `Gamba.combine` stops at 64 levels of nested arrays and dictionaries and at 256 MB of decompressed data, and
106
113
  ignores references that lead back to themselves, so a crafted PDF can't send it into a loop or fill
@@ -110,7 +117,8 @@ Any of it can be built to attack the server that renders it.
110
117
  without them.
111
118
 
112
119
  Inline formatting still turns `<link href="...">` into a clickable link. Escape user text before you put it inside
113
- markup.
120
+ markup. A word with no space to break at, thousands of characters long, takes time that grows with the square of its
121
+ length, as in Prawn: limit the length of what users type.
114
122
 
115
123
  To report a vulnerability, see [SECURITY.md](SECURITY.md).
116
124
 
data/lib/gamba/table.rb CHANGED
@@ -77,6 +77,9 @@ module Prawn
77
77
  alias_method :column, :columns
78
78
 
79
79
  def draw
80
+ # Gamba: rows without a single cell, such as [[]], draw nothing, where prawn-table raised NoMethodError.
81
+ return if @cells.empty?
82
+
80
83
  with_position do
81
84
  reference_bounds = @pdf.reference_bounds
82
85
  started_new_page_at_row = initial_row_on_initial_page
data/lib/gamba/version.rb CHANGED
@@ -6,5 +6,5 @@ module Prawn
6
6
  end
7
7
 
8
8
  module Gamba
9
- VERSION = "2.0.1"
9
+ VERSION = "2.0.2"
10
10
  end
@@ -10,6 +10,8 @@ class Prawn::SVG::Document
10
10
  MAXIMUM_ELEMENTS_THROUGH_USE = 20_000
11
11
  # Elements inside one another: deeper, Ruby's stack runs out, and REXML resolves each namespace by its ancestors.
12
12
  MAXIMUM_DEPTH = 256
13
+ # SVG images inside SVG images: over the web, an SVG can embed itself without end.
14
+ MAXIMUM_EMBEDDING_DEPTH = 16
13
15
 
14
16
  class << self
15
17
  attr_accessor :enable_web_requests_warned
@@ -26,19 +28,20 @@ class Prawn::SVG::Document
26
28
  :elements_by_id, :gradients,
27
29
  :element_styles,
28
30
  :color_mode,
29
- :external_svg_cache
31
+ :external_svg_cache,
32
+ :embedding_depth,
33
+ :raster_dimensions_by_data
30
34
 
31
- def initialize(data, bounds, options, font_registry: nil, css_parser: CssParser::Parser.new, attribute_overrides: {})
35
+ # Gamba: embedder is the document whose <image> embeds this one, for the budgets they share.
36
+ def initialize(data, bounds, options, font_registry: nil, css_parser: CssParser::Parser.new, attribute_overrides: {}, embedder: nil)
32
37
  begin
33
- @root = REXML::Document.new(data).root or raise_parse_error(data)
38
+ @root = self.class.parse(data) or raise_parse_error(data)
34
39
  rescue REXML::ParseException => e
35
40
  raise_parse_error(data, e.message)
36
- rescue RuntimeError => e
37
- # Gamba: REXML's entity expansion limits raise a bare RuntimeError.
38
- raise InvalidSVGData, "The data supplied is not a valid SVG document: #{e.message}"
39
41
  end
40
- check_tree!(@root)
41
42
 
43
+ @embedder = embedder
44
+ @embedding_depth = embedder ? embedder.embedding_depth + 1 : 0
42
45
  @warnings = []
43
46
  @options = options
44
47
  @elements_by_id = {}
@@ -48,6 +51,7 @@ class Prawn::SVG::Document
48
51
  @depth = 0
49
52
  @gradients = Prawn::SVG::Gradients.new(self)
50
53
  @external_svg_cache = {}
54
+ @raster_dimensions_by_data = {}.compare_by_identity
51
55
  @fallback_font_name = options.fetch(:fallback_font_name, DEFAULT_FALLBACK_FONT_NAME)
52
56
  @font_registry = font_registry
53
57
  @color_mode = load_color_mode
@@ -96,26 +100,54 @@ class Prawn::SVG::Document
96
100
  def drawing_through_use(source)
97
101
  raise Prawn::SVG::Elements::Base::SkipElementError, "use tag references an element that contains it" if @references_in_use.any? { _1.equal?(source) }
98
102
 
99
- @elements_through_use += 1
100
- source.each_recursive { @elements_through_use += 1 }
101
- raise Prawn::SVG::Elements::Base::SkipElementError, "use tags draw more than #{MAXIMUM_ELEMENTS_THROUGH_USE} elements" if @elements_through_use > MAXIMUM_ELEMENTS_THROUGH_USE
102
-
103
+ draw_again(self.class.element_count(source))
103
104
  @references_in_use << source
104
105
  yield
105
106
  ensure
106
107
  @references_in_use.pop if @references_in_use.last.equal?(source)
107
108
  end
108
109
 
109
- # Gamba: walked without recursion, before anything else walks the tree with it. Texts expand their entities
110
- # here, so that REXML's limits on expansion raise InvalidSVGData rather than a RuntimeError mid-render.
111
- def check_tree!(root)
110
+ # Gamba: elements drawn once more, counted against one budget for this document and the SVG images it embeds.
111
+ def draw_again(element_count)
112
+ return @embedder.draw_again(element_count) if @embedder
113
+
114
+ @elements_through_use += element_count
115
+ raise Prawn::SVG::Elements::Base::SkipElementError, "use tags and SVG images draw more than #{MAXIMUM_ELEMENTS_THROUGH_USE} elements" if @elements_through_use > MAXIMUM_ELEMENTS_THROUGH_USE
116
+ end
117
+
118
+ def self.element_count(source)
119
+ count = 1
120
+ source.each_recursive { count += 1 }
121
+ count
122
+ end
123
+
124
+ # Gamba: the root of an SVG, checked before anything walks it: the document's own, and every SVG it loads with
125
+ # <use> or <image>. REXML's limits on entity expansion raise a bare RuntimeError.
126
+ def self.parse(data)
127
+ root = REXML::Document.new(data).root
128
+ check_tree!(root) if root
129
+ root
130
+ rescue REXML::ParseException
131
+ raise
132
+ rescue RuntimeError => e
133
+ raise InvalidSVGData, "The data supplied is not a valid SVG document: #{e.message}"
134
+ end
135
+
136
+ # Gamba: walked without recursion. Texts and attributes expand their entities here, so that REXML's limits on
137
+ # expansion raise InvalidSVGData rather than a RuntimeError mid-render.
138
+ def self.check_tree!(root)
112
139
  level = [root]
113
140
  MAXIMUM_DEPTH.times do
114
- level.each { |element| element.texts.each(&:value) }
141
+ level.each do |element|
142
+ element.texts.each(&:value)
143
+ element.attributes.each_attribute(&:value)
144
+ end
115
145
  level = level.flat_map { _1.elements.to_a }
116
146
  return if level.empty?
117
147
  end
118
148
  raise InvalidSVGData, "The SVG document nests elements deeper than #{MAXIMUM_DEPTH} levels"
149
+ rescue REXML::ParseException
150
+ raise
119
151
  rescue RuntimeError => e
120
152
  raise InvalidSVGData, "The data supplied is not a valid SVG document: #{e.message}"
121
153
  end
@@ -199,10 +231,12 @@ class Prawn::SVG::Document
199
231
  tempfile.write(data)
200
232
  tempfile.close
201
233
  @font_face_tempfiles << tempfile
234
+ # Gamba: read now, so that a damaged font is a warning like a missing one, not an error mid-render.
235
+ Gamba::TrueType::File.open(tempfile.path).glyph_by_code
202
236
 
203
237
  font_registry.register_font_face(family, weight, style, stretch, tempfile.path)
204
238
  true
205
- rescue Prawn::SVG::UrlLoader::Error => e
239
+ rescue Prawn::SVG::UrlLoader::Error, Gamba::TrueType::Error => e
206
240
  warnings << "Failed to load @font-face font from #{source[:url]}: #{e.message}"
207
241
  false
208
242
  end
@@ -82,10 +82,11 @@ class Prawn::SVG::Elements::Image < Prawn::SVG::Elements::Base
82
82
 
83
83
  def process_image(data, width, height, preserve_aspect_ratio)
84
84
  if (handler = find_image_handler(data))
85
- image = handler.new(data)
86
- ImageData.new([image.width.to_f, image.height.to_f], nil)
85
+ ImageData.new(raster_dimensions(handler, data), nil)
87
86
 
88
87
  elsif potentially_svg?(data)
88
+ raise SkipElementError, "SVG images nested deeper than #{Prawn::SVG::Document::MAXIMUM_EMBEDDING_DEPTH} levels" if @document.embedding_depth >= Prawn::SVG::Document::MAXIMUM_EMBEDDING_DEPTH
89
+
89
90
  attribute_overrides = {}
90
91
  attribute_overrides["preserveAspectRatio"] = preserve_aspect_ratio if preserve_aspect_ratio
91
92
 
@@ -98,8 +99,10 @@ class Prawn::SVG::Elements::Image < Prawn::SVG::Elements::Base
98
99
  document = Prawn::SVG::Document.new(
99
100
  data, [width, height], {width:, height:, enable_web_requests: has_web_loader},
100
101
  # Gamba: the parent's fonts, without which an embedded SVG holding text failed to render.
101
- attribute_overrides:, font_registry: @document.font_registry
102
+ attribute_overrides:, font_registry: @document.font_registry, embedder: @document
102
103
  )
104
+ # Gamba: drawn again for each <use> of it, and for each image of an image, which a few kilobytes can repeat.
105
+ @document.draw_again(Prawn::SVG::Document.element_count(document.root)) if state.inside_use || @document.embedding_depth.positive?
103
106
 
104
107
  dimensions = [document.sizing.output_width, document.sizing.output_height]
105
108
  ImageData.new(dimensions, document)
@@ -111,6 +114,18 @@ class Prawn::SVG::Elements::Image < Prawn::SVG::Elements::Base
111
114
  raise_invalid_image_type
112
115
  end
113
116
 
117
+ # Gamba: the image of a data: URL, which the loader keeps, is read once however many times <use> draws it.
118
+ def raster_dimensions(handler, data)
119
+ return read_raster_dimensions(handler, data) unless @url[0, 5].casecmp?("data:")
120
+
121
+ @document.raster_dimensions_by_data[data] ||= read_raster_dimensions(handler, data)
122
+ end
123
+
124
+ def read_raster_dimensions(handler, data)
125
+ image = handler.new(data)
126
+ [image.width.to_f, image.height.to_f]
127
+ end
128
+
114
129
  def extract_fragment(url)
115
130
  return [url, nil] if url.start_with?("data:")
116
131
 
@@ -149,7 +164,7 @@ class Prawn::SVG::Elements::Image < Prawn::SVG::Elements::Base
149
164
  end
150
165
 
151
166
  def find_view_element_attributes(data, fragment)
152
- root = REXML::Document.new(data).root
167
+ root = Prawn::SVG::Document.parse(data)
153
168
  return unless root
154
169
 
155
170
  element = Prawn::SVG::Document.element_by_id(root, fragment)
@@ -27,20 +27,12 @@ module Prawn::SVG
27
27
 
28
28
  @font, @fallback_fonts = select_fonts
29
29
 
30
- @children = svg_text_children.flat_map do |child|
31
- if child.node_type == :text
32
- build_text_node(child)
33
- else
34
- case child.name
35
- when "tspan", "tref"
36
- build_child(child)
37
- when "textPath"
38
- build_text_path(child)
39
- else
40
- warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
41
- []
42
- end
43
- end
30
+ @children = if name == "tref"
31
+ # Gamba: a <tref> draws its text again, as a <use> does: not inside itself, and within the same budget.
32
+ reference = find_referenced_element
33
+ reference ? document.drawing_through_use(reference.source) { build_children(reference.source.children) } : []
34
+ else
35
+ build_children(source.children)
44
36
  end
45
37
  end
46
38
 
@@ -158,9 +150,29 @@ module Prawn::SVG
158
150
  Elements::TextNode.new(self, text, leading, trailing)
159
151
  end
160
152
 
153
+ def build_children(nodes)
154
+ svg_text_children(nodes).flat_map do |child|
155
+ if child.node_type == :text
156
+ build_text_node(child)
157
+ else
158
+ case child.name
159
+ when "tspan", "tref"
160
+ build_child(child)
161
+ when "textPath"
162
+ build_text_path(child)
163
+ else
164
+ warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
165
+ []
166
+ end
167
+ end
168
+ end
169
+ end
170
+
161
171
  def build_child(child)
162
172
  component = self.class.new(document, child, [], state.dup, self)
163
173
  component.process
174
+ return [] unless component.children
175
+
164
176
  component
165
177
  end
166
178
 
@@ -172,8 +184,8 @@ module Prawn::SVG
172
184
  component
173
185
  end
174
186
 
175
- def svg_text_children
176
- text_children.select do |child|
187
+ def svg_text_children(nodes)
188
+ nodes.select do |child|
177
189
  child.node_type == :text || (
178
190
  child.node_type == :element &&
179
191
  [SVG_NAMESPACE, ""].include?(child.namespace)
@@ -182,21 +194,12 @@ module Prawn::SVG
182
194
  end
183
195
  end
184
196
 
185
- def text_children
186
- if name == "tref"
187
- reference = find_referenced_element
188
- reference ? reference.source.children : []
189
- else
190
- source.children
191
- end
192
- end
193
-
194
197
  def find_referenced_element
195
198
  href = href_attribute
196
199
 
197
200
  if href && href[0..0] == "#"
198
201
  element = document.elements_by_id[href[1..]] # rubocop:disable Performance/ArraySemiInfiniteRangeSlice -- String
199
- element if element.name == "text"
202
+ element if element&.name == "text"
200
203
  end
201
204
  end
202
205
 
@@ -101,8 +101,8 @@ class Prawn::SVG::Elements::Use < Prawn::SVG::Elements::Base
101
101
  data = @document.url_loader.load(url)
102
102
 
103
103
  root = begin
104
- REXML::Document.new(data).root
105
- rescue REXML::ParseException
104
+ Prawn::SVG::Document.parse(data)
105
+ rescue REXML::ParseException, Prawn::SVG::Document::InvalidSVGData
106
106
  raise SkipElementError, "use tag references external SVG at '#{url}' which could not be parsed"
107
107
  end
108
108
 
@@ -29,7 +29,8 @@ class Prawn::SVG::UrlLoader
29
29
 
30
30
  def perform_and_cache(url)
31
31
  data = perform(url)
32
- add_to_cache(url, data) if enable_cache
32
+ # Gamba: a data: URL decodes to the same bytes every time, and <use> can make a document load it thousands of times.
33
+ add_to_cache(url, data) if enable_cache || url[0, 5].casecmp?("data:")
33
34
  data
34
35
  end
35
36
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gamba
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Level UP Solutions
@@ -9,6 +9,26 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: addressable
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.9.1
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 2.9.1
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
12
32
  - !ruby/object:Gem::Dependency
13
33
  name: css_parser
14
34
  requirement: !ruby/object:Gem::Requirement
@@ -63,9 +83,9 @@ dependencies:
63
83
  - - "<"
64
84
  - !ruby/object:Gem::Version
65
85
  version: '4'
66
- description: 'A drop-in replacement for prawn, prawn-table and prawn-svg: the same
67
- API and the same pages as Prawn 2.5, written faster, with fewer allocations and
68
- lighter files.'
86
+ description: 'Gamba is a Ruby library that writes PDF files. It is a faster rewrite
87
+ of Prawn 2.5, prawn-table and prawn-svg: code written for them runs unchanged and
88
+ draws the same pages, in lighter files.'
69
89
  executables: []
70
90
  extensions: []
71
91
  extra_rdoc_files: []
@@ -259,5 +279,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
259
279
  requirements: []
260
280
  rubygems_version: 4.0.20
261
281
  specification_version: 4
262
- summary: 'Prawn''s API, rewritten for speed: same pages, lighter files'
282
+ summary: A faster rewrite of Prawn, prawn-table and prawn-svg that draws the same
283
+ pages
263
284
  test_files: []