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
@@ -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