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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -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
data/lib/safe_image/svg_css.rb
DELETED
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "strscan"
|
|
4
|
-
|
|
5
|
-
module SafeImage
|
|
6
|
-
# Allowlist sanitizer for the small CSS subset SVG files legitimately use
|
|
7
|
-
# (Inkscape writes style="" attributes, Illustrator writes class rules in
|
|
8
|
-
# <style> elements). Output is constructed from validated tokens, never
|
|
9
|
-
# echoed from the input, so nothing outside the vocabulary below can appear
|
|
10
|
-
# in it. Anything the grammar does not recognise — escapes, quotes,
|
|
11
|
-
# at-rules, comments, unknown properties or functions, non-fragment url() —
|
|
12
|
-
# drops the declaration rather than being decoded.
|
|
13
|
-
module SvgCss
|
|
14
|
-
NO_FUNCTIONS = [].freeze
|
|
15
|
-
URL_FUNCTIONS = %w[url].freeze
|
|
16
|
-
COLOR_FUNCTIONS = %w[rgb rgba hsl hsla].freeze
|
|
17
|
-
PAINT_FUNCTIONS = (COLOR_FUNCTIONS + URL_FUNCTIONS).freeze
|
|
18
|
-
# Lowercase: function names are matched and emitted case-insensitively.
|
|
19
|
-
TRANSFORM_FUNCTIONS = %w[matrix translate translatex translatey scale rotate skewx skewy].freeze
|
|
20
|
-
|
|
21
|
-
# A CSS property is allowed exactly when its presentation-attribute twin is
|
|
22
|
-
# in SvgSanitizer::ALLOWED_ATTRIBUTES (a test asserts this); the value is the
|
|
23
|
-
# list of functions that property's values may call. The only functions that
|
|
24
|
-
# reach a resource are url(...) — and those are constrained to same-document
|
|
25
|
-
# #fragment references — so the URL surface is exactly the url()-bearing
|
|
26
|
-
# rows below (paint servers, clip/mask, and markers), all fragment-only.
|
|
27
|
-
ALLOWED_PROPERTIES = {
|
|
28
|
-
# Paint and color. url() = paint-server reference (gradient/pattern).
|
|
29
|
-
"fill" => PAINT_FUNCTIONS,
|
|
30
|
-
"stroke" => PAINT_FUNCTIONS,
|
|
31
|
-
"stop-color" => COLOR_FUNCTIONS,
|
|
32
|
-
"color" => COLOR_FUNCTIONS, # currentColor resolution; Inkscape/Illustrator
|
|
33
|
-
"opacity" => NO_FUNCTIONS,
|
|
34
|
-
"fill-opacity" => NO_FUNCTIONS,
|
|
35
|
-
"stroke-opacity" => NO_FUNCTIONS,
|
|
36
|
-
"stop-opacity" => NO_FUNCTIONS,
|
|
37
|
-
"fill-rule" => NO_FUNCTIONS,
|
|
38
|
-
"clip-rule" => NO_FUNCTIONS,
|
|
39
|
-
# Stroke geometry. stroke-dasharray/dashoffset are how every dashed line
|
|
40
|
-
# is expressed; vector-effect:non-scaling-stroke is an Inkscape default.
|
|
41
|
-
"stroke-width" => NO_FUNCTIONS,
|
|
42
|
-
"stroke-linecap" => NO_FUNCTIONS,
|
|
43
|
-
"stroke-linejoin" => NO_FUNCTIONS,
|
|
44
|
-
"stroke-miterlimit" => NO_FUNCTIONS,
|
|
45
|
-
"stroke-dasharray" => NO_FUNCTIONS,
|
|
46
|
-
"stroke-dashoffset" => NO_FUNCTIONS,
|
|
47
|
-
"vector-effect" => NO_FUNCTIONS,
|
|
48
|
-
# Geometry references. url() = clipPath/mask/marker element by #id.
|
|
49
|
-
"clip-path" => URL_FUNCTIONS,
|
|
50
|
-
"mask" => URL_FUNCTIONS,
|
|
51
|
-
"marker" => URL_FUNCTIONS,
|
|
52
|
-
"marker-start" => URL_FUNCTIONS,
|
|
53
|
-
"marker-mid" => URL_FUNCTIONS,
|
|
54
|
-
"marker-end" => URL_FUNCTIONS,
|
|
55
|
-
"transform" => TRANSFORM_FUNCTIONS,
|
|
56
|
-
# Visibility and rendering hints. Keywords/numbers only.
|
|
57
|
-
"display" => NO_FUNCTIONS,
|
|
58
|
-
"visibility" => NO_FUNCTIONS,
|
|
59
|
-
"overflow" => NO_FUNCTIONS,
|
|
60
|
-
"paint-order" => NO_FUNCTIONS,
|
|
61
|
-
"mix-blend-mode" => NO_FUNCTIONS,
|
|
62
|
-
"isolation" => NO_FUNCTIONS,
|
|
63
|
-
"shape-rendering" => NO_FUNCTIONS,
|
|
64
|
-
"image-rendering" => NO_FUNCTIONS,
|
|
65
|
-
"color-interpolation" => NO_FUNCTIONS,
|
|
66
|
-
# Text. Keywords/lengths only; no url() anywhere in text styling.
|
|
67
|
-
"font-family" => NO_FUNCTIONS,
|
|
68
|
-
"font-size" => NO_FUNCTIONS,
|
|
69
|
-
"font-weight" => NO_FUNCTIONS,
|
|
70
|
-
"font-style" => NO_FUNCTIONS,
|
|
71
|
-
"font-variant" => NO_FUNCTIONS,
|
|
72
|
-
"font-stretch" => NO_FUNCTIONS,
|
|
73
|
-
"text-anchor" => NO_FUNCTIONS,
|
|
74
|
-
"text-decoration" => NO_FUNCTIONS,
|
|
75
|
-
"letter-spacing" => NO_FUNCTIONS,
|
|
76
|
-
"word-spacing" => NO_FUNCTIONS,
|
|
77
|
-
"dominant-baseline" => NO_FUNCTIONS,
|
|
78
|
-
"baseline-shift" => NO_FUNCTIONS,
|
|
79
|
-
"writing-mode" => NO_FUNCTIONS,
|
|
80
|
-
"direction" => NO_FUNCTIONS
|
|
81
|
-
}.freeze
|
|
82
|
-
|
|
83
|
-
# Every character a declaration may contain. The exclusions do the work:
|
|
84
|
-
# no backslash (CSS escapes re-form tokens after any pattern check), no
|
|
85
|
-
# quotes (no strings, so no string-URL functions), no "@" (no at-rules),
|
|
86
|
-
# no "*" — which keeps CSS comments (/* */) structurally impossible even
|
|
87
|
-
# though "/" is admitted for modern color alpha (rgb(R G B / A)). A "/"
|
|
88
|
-
# survives to output only via the color-function parser below; anywhere
|
|
89
|
-
# else it fails tokenisation and the declaration drops.
|
|
90
|
-
DECLARATION_CHARSET = %r{\A[a-zA-Z0-9 #%+.,()/:-]*\z}.freeze
|
|
91
|
-
|
|
92
|
-
# CSS priority flag. Parsed out of the value structurally and re-emitted
|
|
93
|
-
# canonically, so "!" never enters the value tokeniser.
|
|
94
|
-
IMPORTANT = /\s*!\s*important\s*\z/i.freeze
|
|
95
|
-
|
|
96
|
-
# url() may only reference the current document; same fragment shape
|
|
97
|
-
# SvgSanitizer.dangerous_value? accepts.
|
|
98
|
-
FRAGMENT = /#[A-Za-z][\w.-]*/.freeze
|
|
99
|
-
|
|
100
|
-
HEX_COLOR = /#\h{3,8}/.freeze
|
|
101
|
-
NUMBER = /[+-]?(?:\d+\.\d+|\.\d+|\d+)(?:%|px|pt|pc|em|rem|ex|ch|cm|mm|in|deg|rad|grad|turn)?/.freeze
|
|
102
|
-
IDENT = /[a-zA-Z][a-zA-Z0-9-]*/.freeze
|
|
103
|
-
FUNCTION_NAME = /[a-zA-Z][a-zA-Z0-9-]*(?=\()/.freeze
|
|
104
|
-
SEPARATOR = /\s*,\s*|\s+/.freeze
|
|
105
|
-
|
|
106
|
-
# Selectors: type/.class/#id compounds joined by descendant or child
|
|
107
|
-
# combinators, in comma lists. The charset shuts out pseudo-classes (:),
|
|
108
|
-
# attribute selectors ([), and everything the declaration charset already
|
|
109
|
-
# excludes.
|
|
110
|
-
SELECTOR_CHARSET = /\A[a-zA-Z0-9_ #.,*>-]*\z/.freeze
|
|
111
|
-
SELECTOR_TYPE = /\*|[a-zA-Z][a-zA-Z0-9-]*/.freeze
|
|
112
|
-
SELECTOR_QUALIFIER = /[.#][A-Za-z_][\w-]*/.freeze
|
|
113
|
-
COMBINATOR = /\s*>\s*|\s+/.freeze
|
|
114
|
-
|
|
115
|
-
module_function
|
|
116
|
-
|
|
117
|
-
# Prefixes a bare id/fragment name with the document namespace, unless it is
|
|
118
|
-
# already prefixed (so re-sanitising is a fixed point). A nil namespace is a
|
|
119
|
-
# no-op, preserving the document-scoped (non-inline) behaviour.
|
|
120
|
-
def apply_namespace(namespace, name)
|
|
121
|
-
return name if namespace.nil? || name.start_with?("#{namespace}-")
|
|
122
|
-
|
|
123
|
-
"#{namespace}-#{name}"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Sanitizes a style="" declaration list. When a namespace is given, url(#id)
|
|
127
|
-
# references are rewritten to url(#namespace-id) so they keep pointing at the
|
|
128
|
-
# namespaced ids in the same document. Returns the constructed declaration
|
|
129
|
-
# list, or nil when no declaration survives.
|
|
130
|
-
def sanitize_declarations(css, namespace: nil)
|
|
131
|
-
declarations = normalize(css).split(";").filter_map { |declaration| sanitize_declaration(declaration, namespace) }
|
|
132
|
-
declarations.empty? ? nil : declarations.join(";")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Sanitizes a <style> element's stylesheet. The structure scan accepts
|
|
136
|
-
# only a flat list of "selectors { declarations }" rules — at-rules,
|
|
137
|
-
# nested blocks, and unbalanced braces fail the whole sheet closed rather
|
|
138
|
-
# than surviving in degraded form. Within a well-formed sheet, individual
|
|
139
|
-
# selectors and declarations drop independently. Returns the constructed
|
|
140
|
-
# stylesheet, or nil when no rule survives.
|
|
141
|
-
def sanitize_stylesheet(css, namespace: nil)
|
|
142
|
-
css = normalize(css)
|
|
143
|
-
# At-rules (@import, @media, @font-face, @keyframes, ...) have no place in
|
|
144
|
-
# the allowed subset, and "@" appears nowhere else in it. Rejecting it up
|
|
145
|
-
# front fails the whole element closed — the rule-by-rule scan below would
|
|
146
|
-
# otherwise drop only the at-rule and keep later rules, which contradicts
|
|
147
|
-
# the documented guarantee and risks parser edge cases at the boundary.
|
|
148
|
-
return nil if css.include?("@")
|
|
149
|
-
|
|
150
|
-
scanner = StringScanner.new(css)
|
|
151
|
-
rules = []
|
|
152
|
-
until scanner.eos?
|
|
153
|
-
scanner.skip(/\s+/)
|
|
154
|
-
break if scanner.eos?
|
|
155
|
-
|
|
156
|
-
selectors_src = scanner.scan(/[^{}]+/)
|
|
157
|
-
return nil unless selectors_src && scanner.skip(/\{/)
|
|
158
|
-
|
|
159
|
-
body = scanner.scan(/[^{}]*/)
|
|
160
|
-
return nil unless scanner.skip(/\}/)
|
|
161
|
-
|
|
162
|
-
selectors = sanitize_selectors(selectors_src, namespace)
|
|
163
|
-
declarations = sanitize_declarations(body, namespace: namespace)
|
|
164
|
-
rules << "#{selectors}{#{declarations}}" if selectors && declarations
|
|
165
|
-
end
|
|
166
|
-
rules.empty? ? nil : rules.join
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def sanitize_selectors(src, namespace = nil)
|
|
170
|
-
selectors = src.split(",").filter_map { |selector| sanitize_selector(selector.strip, namespace) }
|
|
171
|
-
selectors.empty? ? nil : selectors.join(",")
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def sanitize_selector(selector, namespace = nil)
|
|
175
|
-
return nil if selector.empty? || !selector.match?(SELECTOR_CHARSET)
|
|
176
|
-
|
|
177
|
-
scanner = StringScanner.new(selector)
|
|
178
|
-
out = +""
|
|
179
|
-
loop do
|
|
180
|
-
compound = scan_compound(scanner, namespace)
|
|
181
|
-
return nil unless compound
|
|
182
|
-
|
|
183
|
-
out << compound
|
|
184
|
-
break if scanner.eos?
|
|
185
|
-
|
|
186
|
-
combinator = scanner.scan(COMBINATOR)
|
|
187
|
-
return nil if combinator.nil? || scanner.eos?
|
|
188
|
-
|
|
189
|
-
out << (combinator.include?(">") ? ">" : " ")
|
|
190
|
-
end
|
|
191
|
-
scope_selector(namespace, out)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# Confines a selector to the namespaced document by anchoring it under the
|
|
195
|
-
# root's scope class, so a preserved <style> cannot reach a host page if the
|
|
196
|
-
# SVG is inlined. Universal/type/class selectors that would otherwise match
|
|
197
|
-
# host elements only match descendants of this document's root. Idempotent:
|
|
198
|
-
# an already-scoped selector is returned unchanged.
|
|
199
|
-
def scope_selector(namespace, selector)
|
|
200
|
-
return selector if namespace.nil?
|
|
201
|
-
|
|
202
|
-
scope = ".#{namespace}-scope"
|
|
203
|
-
selector.start_with?("#{scope} ") ? selector : "#{scope} #{selector}"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def scan_compound(scanner, namespace = nil)
|
|
207
|
-
out = +""
|
|
208
|
-
if (type = scanner.scan(SELECTOR_TYPE))
|
|
209
|
-
out << type
|
|
210
|
-
end
|
|
211
|
-
while (qualifier = scanner.scan(SELECTOR_QUALIFIER))
|
|
212
|
-
out << namespace_qualifier(namespace, qualifier)
|
|
213
|
-
end
|
|
214
|
-
out.empty? ? nil : out
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# Prefix an id (#x) or class (.x) selector's name with the namespace so it
|
|
218
|
-
# matches only this document's namespaced ids/classes, never a host element's.
|
|
219
|
-
# Type and universal selectors are left alone (they are confined by the root
|
|
220
|
-
# scope class instead). Idempotent via apply_namespace.
|
|
221
|
-
def namespace_qualifier(namespace, qualifier)
|
|
222
|
-
return qualifier if namespace.nil?
|
|
223
|
-
|
|
224
|
-
"#{qualifier[0]}#{apply_namespace(namespace, qualifier[1..])}"
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def normalize(css)
|
|
228
|
-
css.to_s.tr("\t\r\n\f\v", " ")
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def sanitize_declaration(declaration, namespace = nil)
|
|
232
|
-
important = ""
|
|
233
|
-
if declaration.match?(IMPORTANT)
|
|
234
|
-
declaration = declaration.sub(IMPORTANT, "")
|
|
235
|
-
important = "!important"
|
|
236
|
-
end
|
|
237
|
-
return nil unless declaration.match?(DECLARATION_CHARSET)
|
|
238
|
-
|
|
239
|
-
property, value = declaration.split(":", 2)
|
|
240
|
-
return nil if value.nil?
|
|
241
|
-
|
|
242
|
-
property = property.strip.downcase
|
|
243
|
-
functions = ALLOWED_PROPERTIES[property]
|
|
244
|
-
return nil unless functions
|
|
245
|
-
|
|
246
|
-
value = sanitize_value(value.strip, functions, namespace)
|
|
247
|
-
value && "#{property}:#{value}#{important}"
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# A value is a comma- or space-separated list of tokens: keywords, numbers
|
|
251
|
-
# with an allowlisted unit, hex colors, and allowlisted functions. The
|
|
252
|
-
# output is reassembled from the matched tokens.
|
|
253
|
-
def sanitize_value(value, functions, namespace = nil)
|
|
254
|
-
scanner = StringScanner.new(value)
|
|
255
|
-
out = +""
|
|
256
|
-
loop do
|
|
257
|
-
token = scan_token(scanner, functions, namespace)
|
|
258
|
-
return nil unless token
|
|
259
|
-
|
|
260
|
-
out << token
|
|
261
|
-
break if scanner.eos?
|
|
262
|
-
|
|
263
|
-
separator = scanner.scan(SEPARATOR)
|
|
264
|
-
return nil if separator.nil? || scanner.eos?
|
|
265
|
-
|
|
266
|
-
out << (separator.include?(",") ? "," : " ")
|
|
267
|
-
end
|
|
268
|
-
out
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def scan_token(scanner, functions, namespace = nil)
|
|
272
|
-
if (name = scanner.scan(FUNCTION_NAME))
|
|
273
|
-
scan_function(scanner, name.downcase, functions, namespace)
|
|
274
|
-
else
|
|
275
|
-
scanner.scan(HEX_COLOR) || scanner.scan(NUMBER) || scanner.scan(IDENT)
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# The scanner is positioned at the "(". url() takes exactly one
|
|
280
|
-
# same-document fragment; every other allowed function takes numbers.
|
|
281
|
-
def scan_function(scanner, name, functions, namespace = nil)
|
|
282
|
-
return nil unless functions.include?(name)
|
|
283
|
-
|
|
284
|
-
scanner.skip(/\(\s*/)
|
|
285
|
-
if name == "url"
|
|
286
|
-
fragment = scanner.scan(FRAGMENT)
|
|
287
|
-
return nil unless fragment && scanner.skip(/\s*\)/)
|
|
288
|
-
|
|
289
|
-
"url(##{apply_namespace(namespace, fragment[1..])})"
|
|
290
|
-
else
|
|
291
|
-
args = []
|
|
292
|
-
loop do
|
|
293
|
-
arg = scanner.scan(NUMBER)
|
|
294
|
-
return nil unless arg
|
|
295
|
-
|
|
296
|
-
args << arg
|
|
297
|
-
break if scanner.skip(/\s*\)/)
|
|
298
|
-
# Modern color syntax: rgb(R G B / A). The slash separates the alpha,
|
|
299
|
-
# and is accepted only here, only for color functions — the single
|
|
300
|
-
# path by which "/" can reach output. Re-emitted in the space form
|
|
301
|
-
# (mixing commas with "/" is invalid CSS), so the result is valid.
|
|
302
|
-
if COLOR_FUNCTIONS.include?(name) && scanner.skip(%r{\s*/\s*})
|
|
303
|
-
alpha = scanner.scan(NUMBER)
|
|
304
|
-
return nil unless alpha && scanner.skip(/\s*\)/)
|
|
305
|
-
|
|
306
|
-
return "#{name}(#{args.join(" ")} / #{alpha})"
|
|
307
|
-
end
|
|
308
|
-
return nil unless scanner.skip(SEPARATOR)
|
|
309
|
-
end
|
|
310
|
-
"#{name}(#{args.join(",")})"
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
end
|