safe_image 0.1.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "timeout"
6
+
7
+ module SafeImage
8
+ class CommandError < Error
9
+ attr_reader :command, :status, :stdout, :stderr
10
+
11
+ def initialize(message, command:, status: nil, stdout: "", stderr: "")
12
+ super(message)
13
+ @command = command
14
+ @status = status
15
+ @stdout = stdout
16
+ @stderr = stderr
17
+ end
18
+ end
19
+
20
+ module Runner
21
+ module_function
22
+
23
+ DEFAULT_TIMEOUT = 20
24
+ MAX_OUTPUT_BYTES = 512 * 1024
25
+ TRUSTED_PATH = "/usr/bin:/bin:/usr/local/bin".freeze
26
+ ALLOWED_ENV_KEYS = %w[LANG LC_ALL LC_CTYPE TZ].freeze
27
+ IMAGEMAGICK_POLICY_PATH = File.expand_path("imagemagick_policy", __dir__)
28
+ IMAGEMAGICK_POLICY_FILE = File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
29
+ BASE_ENV = {
30
+ "PATH" => TRUSTED_PATH,
31
+ "VIPS_BLOCK_UNTRUSTED" => "1"
32
+ }.freeze
33
+
34
+ def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
35
+ raise ArgumentError, "empty command" if argv.nil? || argv.empty?
36
+ argv = argv.map(&:to_s)
37
+ argv[0] = resolve_executable!(argv[0])
38
+ ensure_imagemagick_policy! if imagemagick_command?(File.basename(argv[0]))
39
+
40
+ Dir.mktmpdir("safe-image-command-") do |tmpdir|
41
+ child_env = command_env(tmpdir, env)
42
+
43
+ if sandbox || SafeImage.sandbox_enabled?
44
+ return Sandbox.capture_command!(argv, read: read, write: [*write, tmpdir], timeout: timeout, env: child_env)
45
+ end
46
+
47
+ return run_process!(argv, child_env, timeout: timeout)
48
+ end
49
+ end
50
+
51
+ def run_process!(argv, child_env, timeout:)
52
+ stdout = +"".b
53
+ stderr = +"".b
54
+ status = nil
55
+
56
+ Open3.popen3(child_env, *argv, unsetenv_others: true, pgroup: true) do |stdin, out, err, wait_thr|
57
+ stdin.close
58
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
59
+ streams = { out => stdout, err => stderr }
60
+
61
+ until streams.empty?
62
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ if remaining <= 0
64
+ kill_process_group(wait_thr.pid)
65
+ raise CommandError.new("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
66
+ end
67
+
68
+ readable, = IO.select(streams.keys, nil, nil, remaining)
69
+ next unless readable
70
+
71
+ readable.each do |io|
72
+ begin
73
+ chunk = io.read_nonblock(16 * 1024)
74
+ buffer = streams.fetch(io)
75
+ buffer << chunk
76
+ if buffer.bytesize > MAX_OUTPUT_BYTES
77
+ kill_process_group(wait_thr.pid)
78
+ raise CommandError.new("command output exceeded #{MAX_OUTPUT_BYTES} bytes", command: argv, stdout: stdout, stderr: stderr)
79
+ end
80
+ rescue IO::WaitReadable
81
+ next
82
+ rescue EOFError
83
+ streams.delete(io)
84
+ io.close
85
+ end
86
+ end
87
+ end
88
+
89
+ # The read loop above exits as soon as both pipes hit EOF, which can
90
+ # happen while the child is still alive (it closed/redirected its
91
+ # standard streams but keeps running, possibly via a grandchild).
92
+ # Bound the final wait against the same deadline so the timeout is a
93
+ # hard ceiling rather than something a child can close its way out of.
94
+ until status
95
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+ if remaining <= 0
97
+ kill_process_group(wait_thr.pid)
98
+ raise CommandError.new("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
99
+ end
100
+ status = wait_thr.join(remaining)&.value
101
+ end
102
+ rescue CommandError
103
+ raise
104
+ rescue Exception
105
+ kill_process_group(wait_thr.pid) if wait_thr
106
+ raise
107
+ end
108
+
109
+ return [stdout, stderr] if status&.success?
110
+
111
+ raise CommandError.new(
112
+ "command failed: #{argv.first} exited #{status&.exitstatus}",
113
+ command: argv,
114
+ status: status&.exitstatus,
115
+ stdout: stdout,
116
+ stderr: stderr
117
+ )
118
+ end
119
+
120
+ def kill_process_group(pid)
121
+ Process.kill("TERM", -pid)
122
+ rescue Errno::ESRCH, Errno::EPERM
123
+ ensure
124
+ begin
125
+ sleep 0.2
126
+ Process.kill("KILL", -pid)
127
+ rescue Errno::ESRCH, Errno::EPERM
128
+ end
129
+ end
130
+
131
+ def command_env(tmpdir, env = {})
132
+ allowed = env.each_with_object({}) do |(key, value), hash|
133
+ key = key.to_s
134
+ hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
135
+ end
136
+
137
+ BASE_ENV.merge(
138
+ "MAGICK_CONFIGURE_PATH" => IMAGEMAGICK_POLICY_PATH,
139
+ "MAGICK_TEMPORARY_PATH" => tmpdir,
140
+ "HOME" => tmpdir,
141
+ "XDG_CACHE_HOME" => tmpdir,
142
+ "TMPDIR" => tmpdir
143
+ ).merge(allowed)
144
+ end
145
+
146
+ def ensure_imagemagick_policy!
147
+ raise Error, "missing ImageMagick policy: #{IMAGEMAGICK_POLICY_FILE}" unless File.file?(IMAGEMAGICK_POLICY_FILE)
148
+ end
149
+
150
+ def imagemagick_command?(name)
151
+ %w[magick convert identify compare].include?(name.to_s)
152
+ end
153
+
154
+ def available?(name)
155
+ !!resolve_executable(name)
156
+ end
157
+
158
+ def resolve_executable!(name)
159
+ resolve_executable(name) || raise(UnsupportedFormatError, "missing executable: #{name}")
160
+ end
161
+
162
+ def resolve_executable(name)
163
+ name = name.to_s
164
+ return name if name.include?(File::SEPARATOR) && File.file?(name) && File.executable?(name)
165
+
166
+ TRUSTED_PATH.split(File::PATH_SEPARATOR).each do |dir|
167
+ path = File.join(dir, name)
168
+ return path if File.file?(path) && File.executable?(path)
169
+ end
170
+
171
+ nil
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+
7
+ module SafeImage
8
+ module Sandbox
9
+ module_function
10
+
11
+ DEFAULT_RLIMITS = {
12
+ cpu_seconds: 30,
13
+ memory_bytes: 2 * 1024 * 1024 * 1024,
14
+ file_size_bytes: 1024 * 1024 * 1024,
15
+ open_files: 256
16
+ }.freeze
17
+
18
+ OPERATIONS = %w[
19
+ probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
20
+ convert_favicon_to_png frame_count animated? letter_avatar optimize_image!
21
+ sanitize_svg!
22
+ ].freeze
23
+
24
+ def available?
25
+ require "landlock"
26
+ Landlock::SafeExec.supported?
27
+ rescue LoadError
28
+ false
29
+ end
30
+
31
+ def capture_command!(argv, read:, write:, timeout: Runner::DEFAULT_TIMEOUT, env: nil, rlimits: DEFAULT_RLIMITS)
32
+ require "landlock"
33
+ env ||= Runner.command_env(Dir.tmpdir)
34
+
35
+ result = Landlock::SafeExec.capture!(
36
+ *argv.map(&:to_s),
37
+ read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *read]),
38
+ write: existing_paths(write),
39
+ execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
40
+ env: env.merge("SAFE_IMAGE_SANDBOX_CHILD" => "1"),
41
+ inherit_env: false,
42
+ timeout: timeout,
43
+ rlimits: rlimits,
44
+ seccomp_deny_network: true,
45
+ max_output_bytes: 512 * 1024,
46
+ truncate_output: false
47
+ )
48
+ [result.stdout, result.stderr]
49
+ rescue LoadError
50
+ raise Error, "landlock sandbox requested but the landlock gem is unavailable"
51
+ rescue Landlock::SafeExec::CommandError => e
52
+ raise CommandError.new(
53
+ "sandboxed command failed",
54
+ command: argv,
55
+ status: e.status&.exitstatus,
56
+ stdout: e.stdout,
57
+ stderr: e.stderr
58
+ )
59
+ end
60
+
61
+ def public_call!(operation, args:, kwargs:)
62
+ operation = operation.to_s
63
+ raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
64
+ result = run_worker!(operation, { args: args, kwargs: kwargs })
65
+ operation == "type" && result ? result.to_sym : result
66
+ end
67
+
68
+ def thumbnail(request)
69
+ public_call!(
70
+ :thumbnail,
71
+ args: [],
72
+ kwargs: request.merge(execution: :inline)
73
+ )
74
+ end
75
+
76
+ def run_worker!(operation, request)
77
+ operation = operation.to_s
78
+ raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
79
+
80
+ require "landlock"
81
+ payload = JSON.dump({ operation: operation, request: request })
82
+ code = <<~'RUBY'
83
+ require "json"
84
+ require "safe_image"
85
+
86
+ def deep_symbolize(value)
87
+ case value
88
+ when Hash
89
+ value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
90
+ when Array
91
+ value.map { |v| deep_symbolize(v) }
92
+ else
93
+ value
94
+ end
95
+ end
96
+
97
+ payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
98
+ operation = payload.fetch(:operation).to_s
99
+ allowed_operations = %w[
100
+ probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
101
+ convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
102
+ ]
103
+ raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
104
+
105
+ request = payload.fetch(:request)
106
+ args = request[:args] || []
107
+ kwargs = deep_symbolize(request[:kwargs] || {})
108
+
109
+ result = SafeImage.with_sandbox_disabled do
110
+ SafeImage.__send__(operation, *args, **kwargs)
111
+ end
112
+
113
+ if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
114
+ puts JSON.dump({ __type: "Result", data: result.to_h })
115
+ elsif defined?(SafeImage::Info) && result.is_a?(SafeImage::Info)
116
+ puts JSON.dump({ __type: "Info", data: result.to_h })
117
+ else
118
+ puts JSON.dump({ __type: "Value", data: result })
119
+ end
120
+ RUBY
121
+
122
+ paths = sandbox_paths(request, operation)
123
+ Dir.mktmpdir("safe-image-worker-") do |tmpdir|
124
+ worker_env = Runner.command_env(tmpdir).merge(
125
+ "SAFE_IMAGE_SANDBOX_CHILD" => "1",
126
+ "GEM_HOME" => ENV["GEM_HOME"].to_s,
127
+ "GEM_PATH" => ENV["GEM_PATH"].to_s,
128
+ "RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
129
+ )
130
+
131
+ stdout, = Landlock::SafeExec.capture!(
132
+ RbConfig.ruby,
133
+ "-I#{File.expand_path("../../", __dir__)}",
134
+ "-rjson",
135
+ "-e",
136
+ code,
137
+ payload,
138
+ read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *paths.fetch(:read), tmpdir]),
139
+ write: existing_paths([*paths.fetch(:write), tmpdir]),
140
+ execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
141
+ env: worker_env,
142
+ inherit_env: false,
143
+ timeout: Runner::DEFAULT_TIMEOUT,
144
+ rlimits: DEFAULT_RLIMITS,
145
+ seccomp_deny_network: true,
146
+ max_output_bytes: 512 * 1024,
147
+ truncate_output: false
148
+ )
149
+ response = JSON.parse(stdout, symbolize_names: true)
150
+ if response[:__type] == "Result"
151
+ data = response.fetch(:data)
152
+ Result.new(**data)
153
+ elsif response[:__type] == "Info"
154
+ data = response.fetch(:data)
155
+ Info.new(**data)
156
+ else
157
+ response[:data]
158
+ end
159
+ end
160
+ rescue LoadError
161
+ raise Error, "landlock sandbox requested but the landlock gem is unavailable"
162
+ rescue Landlock::SafeExec::CommandError => e
163
+ raise CommandError.new(
164
+ "sandboxed worker failed",
165
+ command: [RbConfig.ruby, "-e", "..."],
166
+ status: e.status&.exitstatus,
167
+ stdout: e.stdout,
168
+ stderr: e.stderr
169
+ )
170
+ end
171
+
172
+ def sandbox_paths(request, operation)
173
+ read = []
174
+ write = []
175
+
176
+ values = []
177
+ values.concat(Array(request[:args]))
178
+ values.concat(Array(request.dig(:kwargs)&.values))
179
+ values.flatten.compact.each do |value|
180
+ next unless value.is_a?(String)
181
+ next if value.empty? || value.include?("\0")
182
+
183
+ expanded = File.expand_path(value) rescue next
184
+ if File.exist?(expanded)
185
+ read << expanded
186
+ elsif looks_like_path?(value)
187
+ write << File.dirname(expanded)
188
+ end
189
+ end
190
+
191
+ # Common keyword names for generated outputs. Include the containing dir
192
+ # even when a stale file already exists, because operations may replace it.
193
+ kwargs = request[:kwargs] || {}
194
+ %i[output to path].each do |key|
195
+ next unless kwargs[key].is_a?(String)
196
+ write << File.dirname(File.expand_path(kwargs[key]))
197
+ end
198
+
199
+ # In-place mutators need write permission for an existing input path too.
200
+ if %w[optimize optimize_image! sanitize_svg! fix_orientation].include?(operation.to_s)
201
+ first = Array(request[:args]).first
202
+ if first.is_a?(String) && File.exist?(first)
203
+ expanded = File.expand_path(first)
204
+ write << expanded
205
+ write << File.dirname(expanded)
206
+ end
207
+ end
208
+
209
+ { read: read.uniq, write: write.uniq }
210
+ end
211
+
212
+ def looks_like_path?(value)
213
+ value.start_with?("/", "./", "../") || File.extname(value) != ""
214
+ end
215
+
216
+ def runtime_read_paths
217
+ paths = []
218
+ paths.concat(Gem.path) if defined?(Gem)
219
+ paths.concat($LOAD_PATH.select { |path| path && path != "." })
220
+ paths << RbConfig::CONFIG["rubylibdir"]
221
+ paths << RbConfig::CONFIG["rubyarchdir"]
222
+ paths << RbConfig::CONFIG["sitearchdir"]
223
+ paths << RbConfig::CONFIG["vendorarchdir"]
224
+ paths << File.dirname(RbConfig.ruby)
225
+ paths
226
+ end
227
+
228
+ def existing_paths(paths)
229
+ paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
230
+ end
231
+
232
+ def symbolize(hash)
233
+ hash.transform_keys(&:to_sym)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rexml/document"
5
+
6
+ module SafeImage
7
+ module SvgMetadata
8
+ module_function
9
+
10
+ MAX_SVG_BYTES = 1 * 1024 * 1024
11
+ MAX_SVG_DEPTH = 64
12
+ MAX_SVG_ELEMENTS = 10_000
13
+ MAX_SVG_ATTRIBUTES = 50_000
14
+ MAX_SVG_DIMENSION = 100_000
15
+ MAX_SVG_PIXELS = 100_000_000
16
+
17
+ LENGTH_PATTERN = /\A\s*([+]?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?\s*\z/i.freeze
18
+ VIEWBOX_SPLIT = /[\s,]+/.freeze
19
+
20
+ def probe(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
21
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ path = safe_svg_path(path)
23
+ width, height = dimensions(path, max_pixels: max_pixels, max_bytes: max_bytes)
24
+ {
25
+ input_format: "svg",
26
+ width: width,
27
+ height: height,
28
+ frames: 1,
29
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
30
+ }
31
+ end
32
+
33
+ def dimensions(path, max_pixels: nil, max_bytes: MAX_SVG_BYTES)
34
+ path = safe_svg_path(path)
35
+ doc = parse(path, max_bytes: max_bytes)
36
+ root = doc.root
37
+ width = parse_length(root.attributes["width"])
38
+ height = parse_length(root.attributes["height"])
39
+
40
+ unless width && height
41
+ view_box = parse_view_box(root.attributes["viewBox"])
42
+ width ||= view_box&.fetch(2)
43
+ height ||= view_box&.fetch(3)
44
+ end
45
+
46
+ validate_dimensions!(width, height, max_pixels: max_pixels)
47
+ end
48
+
49
+ def parse(path, max_bytes: MAX_SVG_BYTES)
50
+ path = safe_svg_path(path)
51
+ size = File.size(path)
52
+ raise LimitError, "SVG exceeds #{max_bytes} bytes" if size > max_bytes
53
+
54
+ xml = File.binread(path, max_bytes + 1)
55
+ raise LimitError, "SVG exceeds #{max_bytes} bytes" if xml.bytesize > max_bytes
56
+ reject_unsafe_xml!(xml)
57
+ doc = REXML::Document.new(xml)
58
+ raise InvalidImageError, "SVG root required" unless doc.root&.name == "svg"
59
+
60
+ validate_tree!(doc.root)
61
+ doc
62
+ rescue REXML::ParseException => e
63
+ raise InvalidImageError, "invalid SVG: #{e.message}"
64
+ end
65
+
66
+ def safe_svg_path(path)
67
+ path = PathSafety.ensure_regular_file!(path)
68
+ raise UnsupportedFormatError, "not an SVG file: #{path}" unless File.extname(path.to_s).downcase == ".svg"
69
+ path.to_s
70
+ end
71
+
72
+ def reject_unsafe_xml!(xml)
73
+ raise InvalidImageError, "doctype is not allowed in SVG" if xml.match?(/<!DOCTYPE/i)
74
+ raise InvalidImageError, "XML processing instructions are not allowed in SVG" if xml.match?(/<\?(?!xml\s)/i)
75
+ end
76
+
77
+ def parse_length(value)
78
+ value = value.to_s
79
+ match = LENGTH_PATTERN.match(value)
80
+ return nil unless match
81
+
82
+ number = Float(match[1])
83
+ return nil unless number.finite? && number.positive?
84
+
85
+ number
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ def parse_view_box(value)
91
+ parts = value.to_s.strip.split(VIEWBOX_SPLIT)
92
+ return nil unless parts.length == 4
93
+
94
+ numbers = parts.map { |part| Float(part) }
95
+ return nil unless numbers.all?(&:finite?) && numbers[2].positive? && numbers[3].positive?
96
+
97
+ numbers
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ def validate_dimensions!(width, height, max_pixels: nil)
103
+ raise InvalidImageError, "SVG dimensions are missing or invalid" unless width&.positive? && height&.positive?
104
+ raise LimitError, "SVG dimensions exceed #{MAX_SVG_DIMENSION}px" if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
105
+
106
+ pixels = width * height
107
+ limit = max_pixels || MAX_SVG_PIXELS
108
+ raise LimitError, "SVG has #{pixels.to_i} pixels, exceeds #{limit}" if pixels > limit
109
+
110
+ [width.ceil, height.ceil]
111
+ end
112
+
113
+ def validate_tree!(root)
114
+ counters = { elements: 0, attributes: 0 }
115
+ validate_element!(root, depth: 0, counters: counters)
116
+ end
117
+
118
+ def validate_element!(element, depth:, counters:)
119
+ raise LimitError, "SVG nesting exceeds #{MAX_SVG_DEPTH}" if depth > MAX_SVG_DEPTH
120
+
121
+ counters[:elements] += 1
122
+ raise LimitError, "SVG has too many elements" if counters[:elements] > MAX_SVG_ELEMENTS
123
+
124
+ counters[:attributes] += element.attributes.length
125
+ raise LimitError, "SVG has too many attributes" if counters[:attributes] > MAX_SVG_ATTRIBUTES
126
+
127
+ element.elements.each do |child|
128
+ validate_element!(child, depth: depth + 1, counters: counters)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ module VipsBackend
5
+ module_function
6
+
7
+ DIMENSIONS_RE = /\A(?:(?<percent>\d+(?:\.\d+)?)%|(?<w>\d*)x(?<h>\d*)(?<only_down>>)?|(?<pixels>\d+)@)\z/
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)
11
+ end
12
+
13
+ def downsize(input:, output:, dimensions:, format:, quality: 85, max_pixels: nil)
14
+ probe = SafeImage.probe(input, max_pixels: max_pixels)
15
+ scale = scale_for(probe.width, probe.height, dimensions)
16
+ # Never upscale, but always re-encode through the native saver — even on a
17
+ # no-op scale of 1.0 — so the output is metadata-stripped rather than a
18
+ # verbatim copy of the untrusted input bytes.
19
+ scale = [scale, 1.0].min
20
+ Native.resize(input.to_s, output.to_s, scale, normalized_format(format), Integer(quality), max_pixels)
21
+ end
22
+
23
+ def normalized_format(format)
24
+ format = format.to_s.downcase
25
+ format == "jpeg" ? "jpg" : format
26
+ end
27
+
28
+ def scale_for(width, height, dimensions)
29
+ dimensions = dimensions.to_s
30
+ match = DIMENSIONS_RE.match(dimensions) or raise ArgumentError, "unsupported dimensions: #{dimensions.inspect}"
31
+
32
+ if match[:percent]
33
+ return Float(match[:percent]) / 100.0
34
+ end
35
+
36
+ if match[:pixels]
37
+ target_pixels = Float(match[:pixels])
38
+ return Math.sqrt(target_pixels / (Integer(width) * Integer(height)))
39
+ end
40
+
41
+ target_w = match[:w].to_s.empty? ? nil : Float(match[:w])
42
+ target_h = match[:h].to_s.empty? ? nil : Float(match[:h])
43
+ scales = []
44
+ scales << target_w / width if target_w
45
+ scales << target_h / height if target_h
46
+ raise ArgumentError, "missing width/height in dimensions: #{dimensions.inspect}" if scales.empty?
47
+ scales.min
48
+ end
49
+ end
50
+ end