Module: SafeImage::SvgMetadata

Defined in:
lib/safe_image/svg_metadata.rb

Constant Summary collapse

MAX_SVG_BYTES =
1 * 1024 * 1024
MAX_SVG_DEPTH =
64
MAX_SVG_ELEMENTS =
10_000
MAX_SVG_ATTRIBUTES =
50_000
MAX_SVG_DIMENSION =
100_000
MAX_SVG_PIXELS =
100_000_000
LENGTH_PATTERN =
/\A\s*([+]?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?\s*\z/i.freeze
VIEWBOX_SPLIT =
/[\s,]+/.freeze

Class Method Summary collapse

Class Method Details

.dimensions(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/safe_image/svg_metadata.rb', line 33

def dimensions(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
  path = safe_svg_path(path)
  doc = parse(path, max_bytes: max_bytes)
  root = doc.root
  width = parse_length(root.attributes["width"])
  height = parse_length(root.attributes["height"])

  unless width && height
    view_box = parse_view_box(root.attributes["viewBox"])
    width ||= view_box&.fetch(2)
    height ||= view_box&.fetch(3)
  end

  validate_dimensions!(width, height, max_pixels: max_pixels)
end

.parse(path, max_bytes: MAX_SVG_BYTES) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/safe_image/svg_metadata.rb', line 49

def parse(path, max_bytes: MAX_SVG_BYTES)
  path = safe_svg_path(path)
  size = File.size(path)
  raise LimitError, "SVG exceeds #{max_bytes} bytes" if size > max_bytes

  xml = File.binread(path, max_bytes + 1)
  raise LimitError, "SVG exceeds #{max_bytes} bytes" if xml.bytesize > max_bytes
  reject_unsafe_xml!(xml)
  doc = REXML::Document.new(xml)
  raise InvalidImageError, "SVG root required" unless doc.root&.name == "svg"

  validate_tree!(doc.root)
  doc
rescue REXML::ParseException => e
  raise InvalidImageError, "invalid SVG: #{e.message}"
end

.parse_length(value) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/safe_image/svg_metadata.rb', line 77

def parse_length(value)
  value = value.to_s
  match = LENGTH_PATTERN.match(value)
  return nil unless match

  number = Float(match[1])
  return nil unless number.finite? && number.positive?

  number
rescue ArgumentError
  nil
end

.parse_view_box(value) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
# File 'lib/safe_image/svg_metadata.rb', line 90

def parse_view_box(value)
  parts = value.to_s.strip.split(VIEWBOX_SPLIT)
  return nil unless parts.length == 4

  numbers = parts.map { |part| Float(part) }
  return nil unless numbers.all?(&:finite?) && numbers[2].positive? && numbers[3].positive?

  numbers
rescue ArgumentError
  nil
end

.probe(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/safe_image/svg_metadata.rb', line 20

def probe(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  path = safe_svg_path(path)
  width, height = dimensions(path, max_pixels: max_pixels, max_bytes: max_bytes)
  {
    input_format: "svg",
    width: width,
    height: height,
    frames: 1,
    duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
  }
end

.reject_unsafe_xml!(xml) ⇒ Object

Raises:



72
73
74
75
# File 'lib/safe_image/svg_metadata.rb', line 72

def reject_unsafe_xml!(xml)
  raise InvalidImageError, "doctype is not allowed in SVG" if xml.match?(/<!DOCTYPE/i)
  raise InvalidImageError, "XML processing instructions are not allowed in SVG" if xml.match?(/<\?(?!xml\s)/i)
end

.safe_svg_path(path) ⇒ Object



66
67
68
69
70
# File 'lib/safe_image/svg_metadata.rb', line 66

def safe_svg_path(path)
  path = PathSafety.ensure_regular_file!(path)
  raise UnsupportedFormatError, "not an SVG file: #{path}" unless File.extname(path.to_s).downcase == ".svg"
  path.to_s
end

.validate_dimensions!(width, height, max_pixels: nil) ⇒ Object

Raises:



102
103
104
105
106
107
108
109
110
111
# File 'lib/safe_image/svg_metadata.rb', line 102

def validate_dimensions!(width, height, max_pixels: nil)
  raise InvalidImageError, "SVG dimensions are missing or invalid" unless width&.positive? && height&.positive?
  raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px" if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION

  pixels = width * height
  limit = max_pixels || MAX_SVG_PIXELS
  raise LimitError, "SVG has #{pixels.to_i} pixels, exceeds #{limit}" if pixels > limit

  [width.ceil, height.ceil]
end

.validate_element!(element, depth:, counters:) ⇒ Object

Raises:



118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/safe_image/svg_metadata.rb', line 118

def validate_element!(element, depth:, counters:)
  raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if depth > MAX_SVG_DEPTH

  counters[:elements] += 1
  raise LimitError, "SVG has too many elements" if counters[:elements] > MAX_SVG_ELEMENTS

  counters[:attributes] += element.attributes.length
  raise LimitError, "SVG has too many attributes" if counters[:attributes] > MAX_SVG_ATTRIBUTES

  element.elements.each do |child|
    validate_element!(child, depth: depth + 1, counters: counters)
  end
end

.validate_tree!(root) ⇒ Object



113
114
115
116
# File 'lib/safe_image/svg_metadata.rb', line 113

def validate_tree!(root)
  counters = { elements: 0, attributes: 0 }
  validate_element!(root, depth: 0, counters: counters)
end