safe_image 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  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 +43 -41
  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 -281
  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 +250 -48
  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 +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  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 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
@@ -1,452 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "pathname"
5
- require "tempfile"
6
-
7
- module SafeImage
8
- # Compatibility-shaped API for the operations Discourse currently performs in
9
- # OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper. The
10
- # backend is decided once by SafeImage.configure!; these methods only
11
- # dispatch to it.
12
- module DiscourseCompat
13
- module_function
14
-
15
- def resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
16
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
17
- case SafeImage.config.backend
18
- when :vips
19
- vips_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
20
- when :imagemagick
21
- imagemagick_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
22
- end
23
- end
24
-
25
- def vips_resize(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
26
- SafeImage.thumbnail(
27
- input: from,
28
- output: to,
29
- width: width,
30
- height: height,
31
- quality: quality || 85,
32
- optimize: optimize,
33
- max_pixels: max_pixels,
34
- chroma_subsampling: chroma_subsampling
35
- )
36
- end
37
-
38
- def imagemagick_resize(from, to, width, height, quality:, optimize:, max_pixels:)
39
- probe = compat_probe(from, max_pixels: max_pixels)
40
- output = PathSafety.ensure_safe_output_path!(to).to_s
41
- info = ImageMagickBackend.thumbnail(
42
- input: probe.input,
43
- output: output,
44
- width: width,
45
- height: height,
46
- format: File.extname(output).delete_prefix(".").downcase,
47
- quality: quality
48
- )
49
- optimize_output(output, quality) if optimize
50
- result_from_info(probe.input, output, info, "imagemagick")
51
- end
52
-
53
- def crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
54
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
55
- case SafeImage.config.backend
56
- when :vips
57
- vips_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
58
- when :imagemagick
59
- imagemagick_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
60
- end
61
- end
62
-
63
- def vips_crop(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
64
- probe = compat_probe(from, max_pixels: max_pixels)
65
- output = PathSafety.ensure_safe_output_path!(to).to_s
66
- format = File.extname(output).delete_prefix(".").downcase
67
-
68
- info =
69
- if use_jpegli_for_generated_jpeg?(format)
70
- with_temp_png(output) do |tmp_path|
71
- VipsBackend.crop_north(
72
- input: probe.input,
73
- output: tmp_path,
74
- width: width,
75
- height: height,
76
- format: "png",
77
- quality: 100,
78
- max_pixels: max_pixels
79
- )
80
- JpegliBackend.encode(
81
- input: tmp_path,
82
- output: output,
83
- quality: quality || JpegliBackend::DEFAULT_QUALITY,
84
- chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
85
- input_format: probe.input_format
86
- )
87
- end
88
- else
89
- VipsBackend.crop_north(
90
- input: probe.input,
91
- output: output,
92
- width: width,
93
- height: height,
94
- format: format,
95
- quality: quality || 85,
96
- max_pixels: max_pixels
97
- )
98
- end
99
- optimize_output(output, quality) if optimize
100
- result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
101
- end
102
-
103
- def imagemagick_crop(from, to, width, height, quality:, optimize:, max_pixels:)
104
- probe = compat_probe(from, max_pixels: max_pixels)
105
- output = PathSafety.ensure_safe_output_path!(to).to_s
106
- info = ImageMagickBackend.resize_like(
107
- input: probe.input,
108
- output: output,
109
- width: width,
110
- height: height,
111
- format: File.extname(output).delete_prefix(".").downcase,
112
- quality: quality,
113
- crop: :north
114
- )
115
- optimize_output(output, quality) if optimize
116
- result_from_info(probe.input, output, info, "imagemagick")
117
- end
118
-
119
- def downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)
120
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
121
- case SafeImage.config.backend
122
- when :vips
123
- vips_downsize(from, to, dimensions, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
124
- when :imagemagick
125
- imagemagick_downsize(from, to, dimensions, optimize: optimize, max_pixels: max_pixels)
126
- end
127
- end
128
-
129
- def vips_downsize(from, to, dimensions, quality:, optimize:, max_pixels:, chroma_subsampling:)
130
- probe = compat_probe(from, max_pixels: max_pixels)
131
- output = PathSafety.ensure_safe_output_path!(to).to_s
132
- format = File.extname(output).delete_prefix(".").downcase
133
- info =
134
- if use_jpegli_for_generated_jpeg?(format)
135
- with_temp_png(output) do |tmp_path|
136
- VipsBackend.downsize(
137
- input: probe.input,
138
- output: tmp_path,
139
- dimensions: dimensions,
140
- format: "png",
141
- quality: 100,
142
- max_pixels: max_pixels
143
- )
144
- JpegliBackend.encode(
145
- input: tmp_path,
146
- output: output,
147
- quality: quality,
148
- chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(chroma_subsampling, input_format: probe.input_format),
149
- input_format: probe.input_format
150
- )
151
- end
152
- else
153
- VipsBackend.downsize(
154
- input: probe.input,
155
- output: output,
156
- dimensions: dimensions,
157
- format: format,
158
- quality: quality,
159
- max_pixels: max_pixels
160
- )
161
- end
162
- optimize_output(output, nil) if optimize
163
- result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
164
- end
165
-
166
- def imagemagick_downsize(from, to, dimensions, optimize:, max_pixels:)
167
- probe = compat_probe(from, max_pixels: max_pixels)
168
- output = PathSafety.ensure_safe_output_path!(to).to_s
169
- info = ImageMagickBackend.downsize(
170
- input: probe.input,
171
- output: output,
172
- dimensions: dimensions,
173
- format: File.extname(output).delete_prefix(".").downcase
174
- )
175
- optimize_output(output, nil) if optimize
176
- result_from_info(probe.input, output, info, "imagemagick")
177
- end
178
-
179
- # Post-processing applies only to the formats the optimizer tools
180
- # understand; other outputs (gif, jxl, ...) skip the pass.
181
- def optimize_output(output, quality)
182
- format = File.extname(output).delete_prefix(".").downcase
183
- format = "jpg" if format == "jpeg"
184
- return unless Processor::OPTIMIZABLE_OUTPUTS.include?(format)
185
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality)
186
- end
187
-
188
- # JPEG default when the caller passes no quality: matches what ImageMagick
189
- # uses for sources without quality tables, rather than libvips' Q75.
190
- NATIVE_CONVERT_DEFAULT_QUALITY = 92
191
-
192
- def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
193
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
194
- output = PathSafety.ensure_safe_output_path!(to).to_s
195
-
196
- case SafeImage.config.backend
197
- when :vips
198
- native_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
199
- when :imagemagick
200
- imagemagick_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels)
201
- end
202
- end
203
-
204
- def imagemagick_convert(from, output, format:, quality:, optimize:, max_pixels:)
205
- probe = compat_probe(from, max_pixels: max_pixels)
206
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
207
- info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
208
- optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
209
- result_from_info(probe.input, output, info, "imagemagick")
210
- end
211
-
212
- def native_convert(from, output, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
213
- input = PathSafety.ensure_regular_file!(from).to_s
214
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
215
-
216
- if use_jpegli_for_convert?(input, normalized_format)
217
- info = JpegliBackend.convert(
218
- input: input,
219
- output: output,
220
- quality: quality || JpegliBackend::DEFAULT_QUALITY,
221
- chroma_subsampling: chroma_subsampling
222
- )
223
- return result_from_info(input, output, info, "cjpegli")
224
- end
225
-
226
- info = write_through_tempfile(output) do |tmp_path|
227
- Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
228
- end
229
- optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
230
- result_from_info(input, output, info, "libvips-direct")
231
- end
232
-
233
- def use_jpegli_for_convert?(input, normalized_format)
234
- normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
235
- end
236
-
237
- # cjpegli is an output-quality tool, not a configuration choice: installed
238
- # means used for JPEG output on the native path. It encodes only pixels
239
- # this gem already decoded, so it is not part of the untrusted-input
240
- # surface the backend choice controls.
241
- def use_jpegli_for_generated_jpeg?(format)
242
- normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
243
- normalized_format == "jpg" && JpegliBackend.available?
244
- end
245
-
246
- def with_temp_png(output)
247
- output_path = Pathname.new(output)
248
- output_path.dirname.mkpath
249
- Tempfile.create([output_path.basename(".*").to_s, ".safe-image.png"], output_path.dirname.to_s) do |tmp|
250
- tmp_path = Pathname.new(tmp.path)
251
- tmp.close
252
- yield tmp_path
253
- ensure
254
- FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
255
- end
256
- end
257
-
258
- def compat_backend_name(backend, info)
259
- base = backend.to_sym == :vips ? "libvips-direct" : "imagemagick"
260
- info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
261
- end
262
-
263
- def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
264
- convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
265
- end
266
-
267
- # EXIF orientation values mapped onto jpegtran's lossless transforms.
268
- JPEGTRAN_OPERATIONS = {
269
- 2 => ["-flip", "horizontal"],
270
- 3 => ["-rotate", "180"],
271
- 4 => ["-flip", "vertical"],
272
- 5 => ["-transpose"],
273
- 6 => ["-rotate", "90"],
274
- 7 => ["-transverse"],
275
- 8 => ["-rotate", "270"]
276
- }.freeze
277
-
278
- def fix_orientation(from, to = from, max_pixels: nil, quality: nil)
279
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
280
- output = PathSafety.ensure_safe_output_path!(to).to_s
281
-
282
- case SafeImage.config.backend
283
- when :vips
284
- native_fix_orientation(from, output, max_pixels: max_pixels, quality: quality)
285
- when :imagemagick
286
- imagemagick_fix_orientation(from, output, max_pixels: max_pixels)
287
- end
288
- end
289
-
290
- def imagemagick_fix_orientation(from, output, max_pixels:)
291
- probe = compat_probe(from, max_pixels: max_pixels)
292
- info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
293
- result_from_info(probe.input, output, info, "imagemagick")
294
- end
295
-
296
- def native_fix_orientation(from, output, max_pixels:, quality:)
297
- input = PathSafety.ensure_regular_file!(from).to_s
298
- format = File.extname(input).delete_prefix(".").downcase
299
- format = "jpg" if format == "jpeg"
300
- # Validates the format against the native loader allowlist and enforces
301
- # the pixel cap before any pixel decode.
302
- orient = VipsBackend.orientation(input, max_pixels: max_pixels)
303
-
304
- # Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
305
- # there is no generation loss. -perfect refuses when the dimensions are
306
- # not MCU-aligned; fall through to the re-encode tier.
307
- if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
308
- begin
309
- return jpegtran_fix_orientation(input, output, orient)
310
- rescue CommandError
311
- nil
312
- end
313
- end
314
-
315
- quality = quality.nil? ? 95 : Integer(quality)
316
- raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
317
- info = write_through_tempfile(output) do |tmp_path|
318
- Native.resize(input, tmp_path, 1.0, format, quality, max_pixels)
319
- end
320
- result_from_info(input, output, info, "libvips-direct")
321
- end
322
-
323
- def jpegtran_fix_orientation(input, output, orient)
324
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
325
- info = write_through_tempfile(output) do |tmp_path|
326
- Runner.run!(["jpegtran", "-copy", "none", "-perfect", *JPEGTRAN_OPERATIONS.fetch(orient), "-outfile", tmp_path, input])
327
- Native.probe(tmp_path)
328
- end
329
- result_from_info(
330
- input,
331
- output,
332
- {
333
- input_format: "jpg",
334
- output_format: "jpg",
335
- width: info.fetch(:width),
336
- height: info.fetch(:height),
337
- duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
338
- },
339
- "jpegtran"
340
- )
341
- end
342
-
343
- # Writes via a sibling tempfile and renames into place, so in-place calls
344
- # (to == from) never feed an output path that libvips is still reading
345
- # from as input.
346
- def write_through_tempfile(output)
347
- tmp_path = File.join(File.dirname(output), ".safe-image-#{Process.pid}-#{output.object_id}#{File.extname(output)}")
348
- PathSafety.ensure_safe_output_path!(tmp_path)
349
- result = yield tmp_path
350
- FileUtils.mv(tmp_path, output)
351
- result
352
- ensure
353
- FileUtils.rm_f(tmp_path)
354
- end
355
-
356
- def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
357
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
358
- output = PathSafety.ensure_safe_output_path!(to).to_s
359
-
360
- case SafeImage.config.backend
361
- when :vips
362
- # Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
363
- info = Ico.convert_to_png(from, output, max_pixels: max_pixels)
364
- backend_name = "ico-ruby+libvips"
365
- when :imagemagick
366
- info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
367
- backend_name = "imagemagick"
368
- end
369
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
370
- result_from_info(from, output, info, backend_name)
371
- end
372
-
373
- def frame_count(path, max_pixels: nil)
374
- max_pixels = SafeImage.resolved_max_pixels(max_pixels)
375
- # ico directories are counted by the pure-Ruby parser on either backend;
376
- # everything else is a header-only count.
377
- return Ico.frame_count(path, max_pixels: max_pixels) if File.extname(PathSafety.local_path(path)).downcase == ".ico"
378
-
379
- case SafeImage.config.backend
380
- when :vips
381
- VipsBackend.frame_count(path, max_pixels: max_pixels)
382
- when :imagemagick
383
- ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
384
- end
385
- end
386
-
387
- def animated?(path, max_pixels: nil)
388
- frame_count(path, max_pixels: max_pixels).to_i > 1
389
- end
390
-
391
- def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
392
- output = PathSafety.ensure_safe_output_path!(output).to_s
393
- request = { output: output, size: size, background_rgb: background_rgb, letter: letter, pointsize: pointsize, font: font }
394
-
395
- info, backend_name =
396
- case SafeImage.config.backend
397
- when :vips
398
- [VipsBackend.letter_avatar(**request), "libvips-direct"]
399
- when :imagemagick
400
- [ImageMagickBackend.letter_avatar(**request), "imagemagick"]
401
- end
402
-
403
- result_from_info("generated", output, info, backend_name)
404
- end
405
-
406
- def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
407
- Optimizer.optimize(
408
- path,
409
- mode: allow_lossy_png ? :lossy : :lossless,
410
- strip_metadata: strip_metadata,
411
- quality: quality,
412
- strict: strict
413
- )
414
- end
415
-
416
- def compat_probe(path, max_pixels: nil)
417
- path = Pathname.new(path).expand_path.to_s
418
- if SafeImage.config.backend == :vips
419
- SafeImage.probe(path, max_pixels: max_pixels)
420
- else
421
- info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
422
- Result.new(
423
- input: path,
424
- output: nil,
425
- input_format: info.fetch(:input_format),
426
- output_format: nil,
427
- width: info.fetch(:width),
428
- height: info.fetch(:height),
429
- filesize: File.size(path),
430
- backend: "imagemagick",
431
- duration_ms: info.fetch(:duration_ms),
432
- optimizer: nil
433
- )
434
- end
435
- end
436
-
437
- def result_from_info(input, output, info, backend)
438
- Result.new(
439
- input: input.to_s,
440
- output: output.to_s,
441
- input_format: info.fetch(:input_format),
442
- output_format: info.fetch(:output_format),
443
- width: info.fetch(:width),
444
- height: info.fetch(:height),
445
- filesize: File.size(output),
446
- backend: backend,
447
- duration_ms: info.fetch(:duration_ms),
448
- optimizer: nil
449
- )
450
- end
451
- end
452
- end
@@ -1,102 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rexml/document"
4
- require "rexml/formatters/default"
5
- require "pathname"
6
- require "tempfile"
7
-
8
- module SafeImage
9
- module SvgSanitizer
10
- ALLOWED_ELEMENTS = %w[
11
- svg g defs title desc path rect circle ellipse line polyline polygon text tspan
12
- linearGradient radialGradient stop clipPath mask pattern use symbol
13
- ].freeze
14
-
15
- ALLOWED_ATTRIBUTES = %w[
16
- id class x y x1 y1 x2 y2 cx cy r rx ry d points width height viewBox
17
- fill stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit
18
- fill-rule clip-rule opacity fill-opacity stroke-opacity transform
19
- gradientUnits gradientTransform offset stop-color stop-opacity clip-path
20
- mask href xlink:href xmlns xmlns:xlink version preserveAspectRatio
21
- font-family font-size font-weight text-anchor
22
- ].freeze
23
-
24
- module_function
25
-
26
- def sanitize!(path, max_pixels: nil)
27
- path = Pathname.new(SvgMetadata.safe_svg_path(path))
28
- begin
29
- SvgMetadata.dimensions(path.to_s, max_pixels: max_pixels)
30
- rescue InvalidImageError => e
31
- raise unless e.message.include?("dimensions are missing")
32
- end
33
- doc = SvgMetadata.parse(path.to_s)
34
-
35
- clean = REXML::Document.new
36
- clean.add_element(sanitize_element!(doc.root.deep_clone))
37
-
38
- out = +""
39
- formatter = REXML::Formatters::Default.new
40
- formatter.write(clean, out)
41
- atomic_write(path, out)
42
- { format: "svg", sanitized: true, filesize: File.size(path.to_s) }
43
- rescue REXML::ParseException => e
44
- raise InvalidImageError, "invalid SVG: #{e.message}"
45
- end
46
-
47
- def sanitize_element!(element)
48
- element.children.to_a.each do |child|
49
- case child
50
- when REXML::Element
51
- if ALLOWED_ELEMENTS.include?(child.name)
52
- sanitize_element!(child)
53
- else
54
- child.remove
55
- end
56
- when REXML::CData
57
- child.replace_with(REXML::Text.new(child.value.to_s))
58
- when REXML::Text
59
- # Text is serialized escaped by REXML::Formatters::Default.
60
- else
61
- child.remove
62
- end
63
- end
64
-
65
- attributes_to_delete = []
66
- element.attributes.each_attribute do |attr|
67
- name = attr.name.to_s
68
- value = attr.value.to_s
69
- allowed = ALLOWED_ATTRIBUTES.include?(name) || name.start_with?("aria-")
70
- if !allowed || name.downcase.start_with?("on") || dangerous_value?(value)
71
- attributes_to_delete << name
72
- end
73
- end
74
- attributes_to_delete.each { |name| element.delete_attribute(name) }
75
-
76
- %w[href xlink:href].each do |href|
77
- next unless element.attributes[href]
78
- element.delete_attribute(href) unless element.attributes[href].to_s.start_with?("#")
79
- end
80
- element
81
- end
82
-
83
- def dangerous_value?(value)
84
- normalized = value.to_s.gsub(/[\u0000-\u0020\u007f]+/, "")
85
- return true if normalized.match?(/(?:javascript|data):/i)
86
-
87
- normalized.scan(/url\(([^)]*)\)/i).any? do |match|
88
- inner = match.first.to_s.delete(%q{'"})
89
- !inner.match?(/\A#[A-Za-z][\w.-]*\z/)
90
- end
91
- end
92
-
93
- def atomic_write(path, content)
94
- Tempfile.create([path.basename.to_s, ".tmp"], path.dirname.to_s, binmode: false) do |tmp|
95
- tmp.write(content)
96
- tmp.flush
97
- tmp.fsync
98
- File.rename(tmp.path, path.to_s)
99
- end
100
- end
101
- end
102
- end