safe_image 0.3.0 → 0.4.0

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -12,20 +12,6 @@ module SafeImage
12
12
  MAX_SVG_ATTRIBUTES = 50_000
13
13
  MAX_SVG_DIMENSION = 100_000
14
14
  MAX_SVG_PIXELS = 100_000_000
15
- # Upper bound on the render tree the document instantiates. The caps above
16
- # bound the *source* document, but several allowlisted features replicate
17
- # referenced content at render time, so a small source can cost a consumer
18
- # (browser/rasterizer) orders of magnitude more work:
19
- # * <use href="#id"> deep-copies its target subtree — a chain of doubling
20
- # groups fans a few dozen nodes into billions ("use bomb"), and a cyclic
21
- # reference expands forever.
22
- # * a <marker> is drawn once per vertex of every path/line/polyline/polygon
23
- # that references it, so (vertex count) x (marker subtree size) draws — a
24
- # dense `d` (~200k vertices fit in 1 MB) times a non-trivial marker is a
25
- # linear-but-huge "draw bomb" no node/byte/element cap can see.
26
- # SvgSanitizer charges both against this single budget over the sanitized
27
- # tree (renderer-free static accounting) and rejects when it is exceeded.
28
- MAX_SVG_RENDER_UNITS = 1_000_000
29
15
 
30
16
  LENGTH_PATTERN = /\A\s*([+]?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?\s*\z/i.freeze
31
17
  VIEWBOX_SPLIT = /[\s,]+/.freeze
@@ -38,15 +24,15 @@ module SafeImage
38
24
  NON_UTF8_BOMS = [
39
25
  "\xFF\xFE\x00\x00".b, # UTF-32 LE
40
26
  "\x00\x00\xFE\xFF".b, # UTF-32 BE
41
- "\xFF\xFE".b, # UTF-16 LE
42
- "\xFE\xFF".b # UTF-16 BE
27
+ "\xFF\xFE".b, # UTF-16 LE
28
+ "\xFE\xFF".b # UTF-16 BE
43
29
  ].freeze
44
30
 
45
31
  UTF8_BOM = "\xEF\xBB\xBF".b.freeze
46
32
  # Declared encodings we accept: UTF-8/ASCII plus the single-byte,
47
33
  # ASCII-transparent legacy charsets (ISO-8859-*, Windows-125x). Their bytes
48
34
  # below 0x80 decode to identical ASCII, so the byte scans below see the same
49
- # markup any decoder (REXML or a browser) does; and being single-byte, no
35
+ # markup any XML decoder or browser does; and being single-byte, no
50
36
  # lead byte can swallow a following quote the way Shift-JIS, GBK, or Big5
51
37
  # can. Multi-byte (Shift-JIS, GBK, EUC-*, ISO-2022-*), transforming (UTF-7:
52
38
  # "+ADw-" decodes to "<"), and NUL-interleaved (UTF-16/32) encodings are
@@ -54,9 +40,8 @@ module SafeImage
54
40
  # markup the parser acts on. The shape match alone is not airtight:
55
41
  # "utf8" or "windows-1259" fit the pattern yet name no real encoding, so a
56
42
  # name must also resolve via Encoding.find to pass — lookalikes fail
57
- # closed here instead of leaking REXML's bare ArgumentError to the caller.
58
- SAFE_DECLARED_ENCODING =
59
- /\A(?:utf-?8|us-ascii|ascii|iso-?8859-?\d{1,2}|(?:windows|cp)-?125\d)\z/i.freeze
43
+ # closed here instead of leaking a parser encoding error to the caller.
44
+ SAFE_DECLARED_ENCODING = /\A(?:utf-?8|us-ascii|ascii|iso-?8859-?\d{1,2}|(?:windows|cp)-?125\d)\z/i.freeze
60
45
  # ASCII-only so it matches the binary buffer; the optional BOM is stripped
61
46
  # before matching rather than embedded here (which would make this UTF-8).
62
47
  XML_DECL_ENCODING = /\A\s*<\?xml\b[^>]*?\bencoding\s*=\s*["']([^"']+)["']/i.freeze
@@ -177,10 +162,20 @@ module SafeImage
177
162
 
178
163
  def validate_dimensions!(width, height, max_pixels: nil)
179
164
  raise InvalidImageError, "SVG dimensions are missing or invalid" unless width&.positive? && height&.positive?
180
- raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px" if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
165
+ if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
166
+ raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px"
167
+ end
181
168
 
182
169
  pixels = width * height
183
- limit = max_pixels || MAX_SVG_PIXELS
170
+ limit =
171
+ if max_pixels.nil?
172
+ MAX_SVG_PIXELS
173
+ else
174
+ value = Integer(max_pixels)
175
+ raise ArgumentError, "max_pixels must be positive" if value <= 0
176
+
177
+ value
178
+ end
184
179
  raise LimitError, "SVG has #{pixels.to_i} pixels, exceeds #{limit}" if pixels > limit
185
180
 
186
181
  [width.ceil, height.ceil]
@@ -196,7 +191,7 @@ module SafeImage
196
191
  # SAX does NOT raise on malformed XML even with recovery disabled — it
197
192
  # reports through the error callback and keeps going — so well-formedness is
198
193
  # enforced by recording any reported error and rejecting after the parse.
199
- # This reproduces the old REXML pull-parser's reject set (unclosed/mismatched
194
+ # This preserves the old pull-parser's reject set (unclosed/mismatched
200
195
  # tags, trailing junk) and is strictly stricter on multiple root elements,
201
196
  # which is a safe direction for a gate.
202
197
  def scan_svg!(xml)
@@ -224,8 +219,8 @@ module SafeImage
224
219
  end
225
220
 
226
221
  # Loaded on first SVG use, not at file load: keeping the XML library off the
227
- # hot path of every non-SVG operation (and every sandbox worker boot) where
228
- # it would otherwise be paid for nothing.
222
+ # hot path of every non-SVG operation where it would otherwise be paid for
223
+ # nothing.
229
224
  def require_nokogiri
230
225
  require "nokogiri"
231
226
  end
@@ -238,51 +233,61 @@ module SafeImage
238
233
  # parse aborts promptly rather than scanning to the end (verified: rejection
239
234
  # time grows far slower than input size).
240
235
  def cap_scanner_class
241
- @cap_scanner_class ||= Class.new(Nokogiri::XML::SAX::Document) do
242
- attr_reader :root_name, :root_attributes, :parse_error
243
-
244
- def initialize
245
- super
246
- @depth = -1
247
- @elements = 0
248
- @attributes = 0
249
- @root_name = nil
250
- @root_attributes = nil
251
- @parse_error = nil
252
- end
253
-
254
- # attrs: array of Nokogiri::XML::SAX::Parser::Attribute (localname/value),
255
- # NOT including namespace declarations; `ns` carries the xmlns decls. Both
256
- # count toward the attribute cap so the bound cannot be sidestepped by
257
- # spraying namespace declarations.
258
- def start_element_namespace(name, attrs = [], _prefix = nil, _uri = nil, ns = [])
259
- @depth += 1
260
- raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if @depth > MAX_SVG_DEPTH
261
-
262
- @elements += 1
263
- raise LimitError, "SVG has too many elements" if @elements > MAX_SVG_ELEMENTS
264
-
265
- @attributes += attrs.length + ns.length
266
- raise LimitError, "SVG has too many attributes" if @attributes > MAX_SVG_ATTRIBUTES
267
-
268
- return unless @root_name.nil?
269
-
270
- @root_name = name
271
- @root_attributes = attrs.each_with_object({}) { |attr, hash| hash[attr.localname] = attr.value }
272
- end
273
-
274
- def end_element_namespace(_name, _prefix = nil, _uri = nil)
275
- @depth -= 1
236
+ @cap_scanner_class ||=
237
+ Class.new(Nokogiri::XML::SAX::Document) do
238
+ attr_reader :root_name, :root_attributes, :parse_error
239
+
240
+ def initialize
241
+ super
242
+ @depth = -1
243
+ @elements = 0
244
+ @attributes = 0
245
+ @root_name = nil
246
+ @root_attributes = nil
247
+ @parse_error = nil
248
+ end
249
+
250
+ # attrs: array of Nokogiri::XML::SAX::Parser::Attribute (localname/value),
251
+ # NOT including namespace declarations; `ns` carries the xmlns decls. Both
252
+ # count toward the attribute cap so the bound cannot be sidestepped by
253
+ # spraying namespace declarations.
254
+ def start_element_namespace(name, attrs = [], _prefix = nil, _uri = nil, ns = [])
255
+ @depth += 1
256
+ raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if @depth > MAX_SVG_DEPTH
257
+
258
+ @elements += 1
259
+ raise LimitError, "SVG has too many elements" if @elements > MAX_SVG_ELEMENTS
260
+
261
+ @attributes += attrs.length + ns.length
262
+ raise LimitError, "SVG has too many attributes" if @attributes > MAX_SVG_ATTRIBUTES
263
+
264
+ return unless @root_name.nil?
265
+
266
+ @root_name = name
267
+ @root_attributes =
268
+ attrs.each_with_object({}) do |attr, hash|
269
+ # Dimensions are security-relevant: only the actual no-namespace
270
+ # root attributes a browser will use may feed the pixel cap. A
271
+ # namespaced e:width/e:height must not shadow width/height here.
272
+ next unless attr.prefix.to_s.empty? && attr.uri.to_s.empty?
273
+
274
+ hash[attr.localname] = attr.value
275
+ end
276
+ end
277
+
278
+ def end_element_namespace(_name, _prefix = nil, _uri = nil)
279
+ @depth -= 1
280
+ end
281
+
282
+ # libxml2 reports well-formedness violations here rather than raising;
283
+ # record the first so scan_svg! can reject on it.
284
+ def error(message)
285
+ @parse_error ||= message.to_s.strip
286
+ end
287
+
288
+ def warning(_message)
289
+ end
276
290
  end
277
-
278
- # libxml2 reports well-formedness violations here rather than raising;
279
- # record the first so scan_svg! can reject on it.
280
- def error(message)
281
- @parse_error ||= message.to_s.strip
282
- end
283
-
284
- def warning(_message); end
285
- end
286
291
  end
287
292
  end
288
293
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ # Inline implementations for image-producing operations.
5
+ class TransformOperations < OperationSet
6
+ def thumbnail(
7
+ input:,
8
+ output:,
9
+ width:,
10
+ height:,
11
+ format: nil,
12
+ quality: QualityDefaults::JPEG,
13
+ max_pixels: nil,
14
+ optimize: false,
15
+ optimize_mode: :lossless,
16
+ chroma_subsampling: :auto
17
+ )
18
+ Processor.new(
19
+ max_pixels: resolved_max_pixels(max_pixels),
20
+ chroma_subsampling: chroma_subsampling,
21
+ config: config
22
+ ).thumbnail(
23
+ input: input,
24
+ output: output,
25
+ width: width,
26
+ height: height,
27
+ format: format,
28
+ quality: quality,
29
+ optimize: optimize,
30
+ optimize_mode: optimize_mode
31
+ )
32
+ end
33
+
34
+ def optimize(input:, output:, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
35
+ Optimizer.optimize(
36
+ input: input,
37
+ output: output,
38
+ mode: mode,
39
+ strip_metadata: strip_metadata,
40
+ quality: quality,
41
+ strict: strict
42
+ )
43
+ end
44
+
45
+ def resize(
46
+ input:,
47
+ output:,
48
+ width:,
49
+ height:,
50
+ quality: nil,
51
+ optimize: true,
52
+ max_pixels: nil,
53
+ chroma_subsampling: :auto
54
+ )
55
+ backend.resize(
56
+ input: input,
57
+ output: output,
58
+ width: width,
59
+ height: height,
60
+ quality: quality,
61
+ optimize: optimize,
62
+ max_pixels: max_pixels,
63
+ chroma_subsampling: chroma_subsampling
64
+ )
65
+ end
66
+
67
+ def crop(input:, output:, width:, height:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
68
+ backend.crop(
69
+ input: input,
70
+ output: output,
71
+ width: width,
72
+ height: height,
73
+ quality: quality,
74
+ optimize: optimize,
75
+ max_pixels: max_pixels,
76
+ chroma_subsampling: chroma_subsampling
77
+ )
78
+ end
79
+
80
+ def downsize(
81
+ input:,
82
+ output:,
83
+ dimensions:,
84
+ optimize: true,
85
+ max_pixels: nil,
86
+ quality: QualityDefaults::JPEG,
87
+ chroma_subsampling: :auto
88
+ )
89
+ backend.downsize(
90
+ input: input,
91
+ output: output,
92
+ dimensions: dimensions,
93
+ optimize: optimize,
94
+ max_pixels: max_pixels,
95
+ quality: quality,
96
+ chroma_subsampling: chroma_subsampling
97
+ )
98
+ end
99
+
100
+ def convert(input:, output:, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
101
+ backend.convert(
102
+ input: input,
103
+ output: output,
104
+ format: format,
105
+ quality: quality,
106
+ optimize: optimize,
107
+ max_pixels: max_pixels,
108
+ chroma_subsampling: chroma_subsampling
109
+ )
110
+ end
111
+
112
+ def fix_orientation(input:, output:, max_pixels: nil, quality: nil)
113
+ backend.fix_orientation(input: input, output: output, max_pixels: max_pixels, quality: quality)
114
+ end
115
+
116
+ def convert_favicon_to_png(input:, output:, optimize: true, max_pixels: nil)
117
+ backend.convert_favicon_to_png(input: input, output: output, optimize: optimize, max_pixels: max_pixels)
118
+ end
119
+
120
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
121
+ backend.letter_avatar(
122
+ output: output,
123
+ size: size,
124
+ background_rgb: background_rgb,
125
+ letter: letter,
126
+ pointsize: pointsize,
127
+ font: font
128
+ )
129
+ end
130
+
131
+ private
132
+
133
+ def backend
134
+ OperationBackends.for(config)
135
+ end
136
+ end
137
+
138
+ private_constant :TransformOperations
139
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeImage
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -6,18 +6,26 @@ module SafeImage
6
6
 
7
7
  DIMENSIONS_RE = /\A(?:(?<percent>\d+(?:\.\d+)?)%|(?<w>\d*)x(?<h>\d*)(?<only_down>>)?|(?<pixels>\d+)@)\z/
8
8
 
9
- def crop_north(input:, output:, width:, height:, format:, quality: 85, max_pixels: nil)
10
- Native.crop_north(input.to_s, output.to_s, Integer(width), Integer(height), format.to_s, Integer(quality), max_pixels)
9
+ def crop_north(input:, output:, width:, height:, format:, quality: QualityDefaults::JPEG, max_pixels: nil)
10
+ Native.crop_north(
11
+ input.to_s,
12
+ output.to_s,
13
+ Integer(width),
14
+ Integer(height),
15
+ format.to_s,
16
+ Integer(quality),
17
+ max_pixels
18
+ )
11
19
  end
12
20
 
13
- def downsize(input:, output:, dimensions:, format:, quality: 85, max_pixels: nil)
21
+ def downsize(input:, output:, dimensions:, format:, quality: QualityDefaults::JPEG, max_pixels: nil)
14
22
  probe = SafeImage.probe(input, max_pixels: max_pixels)
15
23
  scale = scale_for(probe.width, probe.height, dimensions)
16
24
  # Never upscale, but always re-encode through the native saver — even on a
17
25
  # no-op scale of 1.0 — so the output is metadata-stripped rather than a
18
26
  # verbatim copy of the untrusted input bytes.
19
27
  scale = [scale, 1.0].min
20
- Native.resize(input.to_s, output.to_s, scale, normalized_format(format), Integer(quality), max_pixels)
28
+ Native.resize(input.to_s, output.to_s, scale, Formats.normalize(format), Integer(quality), max_pixels)
21
29
  end
22
30
 
23
31
  def dominant_color(input, max_pixels: nil)
@@ -77,18 +85,11 @@ module SafeImage
77
85
  }
78
86
  end
79
87
 
80
- def normalized_format(format)
81
- format = format.to_s.downcase
82
- format == "jpeg" ? "jpg" : format
83
- end
84
-
85
88
  def scale_for(width, height, dimensions)
86
89
  dimensions = dimensions.to_s
87
90
  match = DIMENSIONS_RE.match(dimensions) or raise ArgumentError, "unsupported dimensions: #{dimensions.inspect}"
88
91
 
89
- if match[:percent]
90
- return Float(match[:percent]) / 100.0
91
- end
92
+ return Float(match[:percent]) / 100.0 if match[:percent]
92
93
 
93
94
  if match[:pixels]
94
95
  target_pixels = Float(match[:pixels])