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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +703 -0
- data/SECURITY.md +48 -0
- data/ext/safe_image_native/extconf.rb +8 -0
- data/ext/safe_image_native/safe_image_native.c +392 -0
- data/lib/safe_image/RT_sRGB.icm +0 -0
- data/lib/safe_image/discourse_compat.rb +283 -0
- data/lib/safe_image/image_magick_backend.rb +263 -0
- data/lib/safe_image/imagemagick_policy/policy.xml +22 -0
- data/lib/safe_image/jpegli_backend.rb +109 -0
- data/lib/safe_image/native.rb +3 -0
- data/lib/safe_image/optimizer.rb +78 -0
- data/lib/safe_image/path_safety.rb +63 -0
- data/lib/safe_image/processor.rb +196 -0
- data/lib/safe_image/remote.rb +309 -0
- data/lib/safe_image/result.rb +28 -0
- data/lib/safe_image/runner.rb +174 -0
- data/lib/safe_image/sandbox.rb +236 -0
- data/lib/safe_image/svg_metadata.rb +132 -0
- data/lib/safe_image/svg_sanitizer.rb +102 -0
- data/lib/safe_image/version.rb +5 -0
- data/lib/safe_image/vips_backend.rb +50 -0
- data/lib/safe_image.rb +272 -0
- metadata +140 -0
|
@@ -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,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
|