safe_image 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -12
- data/README.md +140 -287
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +42 -40
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -290
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +225 -98
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- 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,
|
|
42
|
-
"\xFE\xFF".b
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
228
|
-
#
|
|
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 ||=
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
data/lib/safe_image/version.rb
CHANGED
|
@@ -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:
|
|
10
|
-
Native.crop_north(
|
|
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:
|
|
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,
|
|
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])
|