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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module SafeImage
6
+ module Optimizer
7
+ module_function
8
+
9
+ MAX_PNGQUANT_SIZE = 500_000
10
+
11
+ def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true)
12
+ path = PathSafety.ensure_regular_file!(path)
13
+
14
+ ext = path.extname.delete_prefix(".").downcase
15
+ ext = "jpg" if ext == "jpeg"
16
+
17
+ before = File.size(path)
18
+ tools = []
19
+
20
+ case ext
21
+ when "jpg"
22
+ if Runner.available?("jpegoptim")
23
+ argv = ["jpegoptim", "--quiet"]
24
+ argv << (strip_metadata ? "--strip-all" : "--strip-none")
25
+ argv << "--max=#{Integer(quality)}" if quality
26
+ argv << path.to_s
27
+ Runner.run!(argv, timeout: timeout)
28
+ tools << "jpegoptim"
29
+ else
30
+ raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
31
+ end
32
+ when "png"
33
+ if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
34
+ if Runner.available?("pngquant")
35
+ tmp = Tempfile.new([path.basename(".*").to_s, ".pngquant.png"], path.dirname.to_s)
36
+ tmp_path = Pathname.new(tmp.path)
37
+ tmp.close
38
+ begin
39
+ argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
40
+ argv << "--quality=#{quality}" if quality # e.g. "65-90"
41
+ argv << path.to_s
42
+ Runner.run!(argv, timeout: timeout)
43
+ if tmp_path.file? && File.size(tmp_path) < File.size(path)
44
+ FileUtils.mv(tmp_path, path)
45
+ tools << "pngquant"
46
+ end
47
+ ensure
48
+ FileUtils.rm_f(tmp_path)
49
+ end
50
+ elsif strict
51
+ raise Error, "pngquant is required for strict lossy PNG optimisation"
52
+ end
53
+ end
54
+
55
+ if Runner.available?("oxipng")
56
+ argv = ["oxipng", "--quiet", "-o", "3"]
57
+ argv.concat(["--strip", strip_metadata ? "safe" : "none"])
58
+ argv << path.to_s
59
+ Runner.run!(argv, timeout: timeout)
60
+ tools << "oxipng"
61
+ else
62
+ raise Error, "oxipng is required for strict PNG optimisation" if strict
63
+ end
64
+ else
65
+ raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
66
+ end
67
+
68
+ after = File.size(path)
69
+ {
70
+ format: ext,
71
+ before_bytes: before,
72
+ after_bytes: after,
73
+ saved_bytes: before - after,
74
+ tools: tools
75
+ }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SafeImage
6
+ module PathSafety
7
+ SAFE_IMAGEMAGICK_PATH = %r{\A[\w\-\./]+\z}.freeze
8
+
9
+ module_function
10
+
11
+ def local_path(value)
12
+ if value.respond_to?(:path) && value.path
13
+ value.path.to_s
14
+ else
15
+ value.to_s
16
+ end
17
+ end
18
+
19
+ def reject_symlink_components!(path)
20
+ path = Pathname.new(local_path(path)).expand_path
21
+ path.ascend do |component|
22
+ next unless File.exist?(component.to_s)
23
+ raise UnsafePathError, "symlink paths are not allowed: #{component}" if File.lstat(component.to_s).symlink?
24
+ end
25
+ path
26
+ end
27
+
28
+ def ensure_regular_file!(path)
29
+ path = reject_symlink_components!(path)
30
+ raise UnsafePathError, "not a file: #{path}" unless path.file?
31
+ path
32
+ end
33
+
34
+ def ensure_safe_output_path!(path)
35
+ path = Pathname.new(local_path(path)).expand_path
36
+ raise UnsafePathError, "path contains NUL" if path.to_s.include?("\0")
37
+ reject_symlink_components!(path.dirname)
38
+ if File.exist?(path.to_s)
39
+ raise UnsafePathError, "output path is a symlink: #{path}" if File.lstat(path.to_s).symlink?
40
+ end
41
+ path
42
+ end
43
+
44
+ def ensure_imagemagick_safe!(path)
45
+ path = local_path(path)
46
+ raise UnsafePathError, "path contains NUL" if path.include?("\0")
47
+ raise UnsafePathError, "path must be absolute" unless path.start_with?("/")
48
+ unless SAFE_IMAGEMAGICK_PATH.match?(path)
49
+ raise UnsafePathError, "path contains characters unsafe for ImageMagick pseudo-filename parsing"
50
+ end
51
+ path
52
+ end
53
+
54
+ def ensure_imagemagick_input_file!(path)
55
+ # Expand to an absolute path first so callers may pass relative paths
56
+ # (matching the rest of the public API), then apply the absolute-path and
57
+ # safe-character checks to the resolved path.
58
+ expanded = Pathname.new(local_path(path)).expand_path.to_s
59
+ ensure_imagemagick_safe!(expanded)
60
+ ensure_regular_file!(Pathname.new(expanded)).to_s
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module SafeImage
8
+ class Processor
9
+ SUPPORTED_INPUTS = %w[jpg jpeg png webp heic heif avif].freeze
10
+ SUPPORTED_OUTPUTS = %w[jpg jpeg png webp avif].freeze
11
+
12
+ def initialize(max_pixels: nil, backend: :vips, execution: :inline, encoder: :auto, chroma_subsampling: :auto)
13
+ @max_pixels = max_pixels
14
+ @backend = backend.to_sym
15
+ @execution = execution.to_sym
16
+ @encoder = encoder.to_sym
17
+ @chroma_subsampling = chroma_subsampling
18
+ end
19
+
20
+ def probe(path)
21
+ input = safe_existing_file!(path)
22
+ info = Native.probe(input.to_s)
23
+ validate_pixels!(info.fetch(:width), info.fetch(:height))
24
+ Result.new(
25
+ input: input.to_s,
26
+ output: nil,
27
+ input_format: info.fetch(:format),
28
+ output_format: nil,
29
+ width: info.fetch(:width),
30
+ height: info.fetch(:height),
31
+ filesize: File.size(input),
32
+ backend: "libvips-direct",
33
+ duration_ms: info.fetch(:duration_ms),
34
+ optimizer: nil
35
+ )
36
+ end
37
+
38
+ def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, optimize: false, optimize_mode: :lossless)
39
+ input = safe_existing_file!(input)
40
+ output = safe_output_path!(output)
41
+ width = Integer(width)
42
+ height = Integer(height)
43
+ quality = Integer(quality)
44
+ raise ArgumentError, "width and height must be positive" if width <= 0 || height <= 0
45
+ raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
46
+
47
+ out_format = (format || output.extname.delete_prefix(".")).downcase
48
+ out_format = "jpg" if out_format == "jpeg"
49
+ unless SUPPORTED_OUTPUTS.include?(out_format)
50
+ raise UnsupportedFormatError, "unsupported output format: #{out_format.inspect}"
51
+ end
52
+
53
+ if @execution == :sandbox || @execution == :sandbox_if_available
54
+ if @execution == :sandbox && !Sandbox.available?
55
+ raise Error, "sandbox execution requested but Landlock::SafeExec is unavailable"
56
+ end
57
+
58
+ info = Sandbox.thumbnail(
59
+ input: input.to_s,
60
+ output: output.to_s,
61
+ width: width,
62
+ height: height,
63
+ format: out_format,
64
+ quality: quality,
65
+ max_pixels: @max_pixels,
66
+ backend: @backend,
67
+ optimize: optimize,
68
+ optimize_mode: optimize_mode
69
+ )
70
+ if info
71
+ return Result.new(
72
+ input: input.to_s,
73
+ output: output.to_s,
74
+ input_format: info.fetch(:input_format),
75
+ output_format: info.fetch(:output_format),
76
+ width: info.fetch(:width),
77
+ height: info.fetch(:height),
78
+ filesize: File.size(output),
79
+ backend: "sandboxed-#{info.fetch(:backend)}",
80
+ duration_ms: info.fetch(:duration_ms),
81
+ optimizer: info[:optimizer]
82
+ )
83
+ end
84
+ end
85
+
86
+ output.dirname.mkpath
87
+ info =
88
+ if out_format == "jpg" && use_jpegli_for_generated_jpeg?(input)
89
+ jpegli_thumbnail(input: input, output: output, width: width, height: height, quality: quality, source_format: input.extname.delete_prefix(".").downcase)
90
+ else
91
+ case @backend
92
+ when :vips
93
+ Native.thumbnail(input.to_s, output.to_s, width, height, out_format, quality, @max_pixels)
94
+ when :imagemagick, :magick
95
+ probe_info = Native.probe(input.to_s)
96
+ validate_pixels!(probe_info.fetch(:width), probe_info.fetch(:height))
97
+ ImageMagickBackend.thumbnail(
98
+ input: input.to_s,
99
+ output: output.to_s,
100
+ width: width,
101
+ height: height,
102
+ format: out_format,
103
+ quality: quality
104
+ )
105
+ else
106
+ raise ArgumentError, "unknown backend: #{@backend.inspect}"
107
+ end
108
+ end
109
+
110
+ opt_info = nil
111
+ if optimize
112
+ opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil)
113
+ end
114
+
115
+ Result.new(
116
+ input: input.to_s,
117
+ output: output.to_s,
118
+ input_format: info.fetch(:input_format),
119
+ output_format: info.fetch(:output_format),
120
+ width: info.fetch(:width),
121
+ height: info.fetch(:height),
122
+ filesize: File.size(output),
123
+ backend: result_backend(info),
124
+ duration_ms: info.fetch(:duration_ms),
125
+ optimizer: opt_info&.fetch(:tools, nil)
126
+ )
127
+ end
128
+
129
+ private
130
+
131
+ def use_jpegli_for_generated_jpeg?(input)
132
+ case @encoder
133
+ when :auto
134
+ @backend == :vips && JpegliBackend.available?
135
+ when :cjpegli
136
+ true
137
+ when :vips, :imagemagick, :magick
138
+ false
139
+ else
140
+ raise ArgumentError, "unknown encoder: #{@encoder.inspect}"
141
+ end
142
+ end
143
+
144
+ def jpegli_thumbnail(input:, output:, width:, height:, quality:, source_format:)
145
+ raise UnsupportedFormatError, "cjpegli is not installed" unless JpegliBackend.available?
146
+ raise ArgumentError, "encoder: :cjpegli currently requires backend: :vips" unless @backend == :vips
147
+
148
+ output.dirname.mkpath
149
+ Tempfile.create([output.basename(".*").to_s, ".safe-image.png"], output.dirname.to_s) do |tmp|
150
+ tmp_path = Pathname.new(tmp.path)
151
+ tmp.close
152
+ Native.thumbnail(input.to_s, tmp_path.to_s, width, height, "png", 100, @max_pixels)
153
+ JpegliBackend.encode(
154
+ input: tmp_path,
155
+ output: output,
156
+ quality: quality,
157
+ chroma_subsampling: JpegliBackend.validate_chroma_subsampling!(@chroma_subsampling, input_format: normalized_source_format(source_format)),
158
+ input_format: normalized_source_format(source_format)
159
+ )
160
+ ensure
161
+ FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
162
+ end
163
+ end
164
+
165
+ def normalized_source_format(format)
166
+ format = format.to_s.downcase
167
+ format == "jpeg" ? "jpg" : format
168
+ end
169
+
170
+ def result_backend(info)
171
+ if info[:encoder] == "cjpegli"
172
+ "#{@backend == :vips ? "libvips-direct" : "imagemagick"}+cjpegli"
173
+ else
174
+ @backend == :vips ? "libvips-direct" : "imagemagick"
175
+ end
176
+ end
177
+
178
+ def safe_existing_file!(path)
179
+ path = PathSafety.ensure_regular_file!(path)
180
+ ext = path.extname.delete_prefix(".").downcase
181
+ ext = "jpg" if ext == "jpeg"
182
+ raise UnsupportedFormatError, "unsupported input format: #{ext.inspect}" unless SUPPORTED_INPUTS.include?(ext)
183
+ path
184
+ end
185
+
186
+ def safe_output_path!(path)
187
+ PathSafety.ensure_safe_output_path!(path)
188
+ end
189
+
190
+ def validate_pixels!(width, height)
191
+ return unless @max_pixels
192
+ pixels = Integer(width) * Integer(height)
193
+ raise LimitError, "image has #{pixels} pixels, exceeds #{@max_pixels}" if pixels > @max_pixels
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ipaddr"
5
+ require "net/http"
6
+ require "resolv"
7
+ require "tempfile"
8
+ require "time"
9
+ require "tmpdir"
10
+ require "uri"
11
+
12
+ module SafeImage
13
+ module Remote
14
+ module_function
15
+
16
+ DEFAULT_MAX_BYTES = 20 * 1024 * 1024
17
+ DEFAULT_MAX_REDIRECTS = 3
18
+ DEFAULT_OPEN_TIMEOUT = 5
19
+ DEFAULT_READ_TIMEOUT = 10
20
+ DEFAULT_TOTAL_TIMEOUT = 30
21
+ DEFAULT_ALLOWED_PORTS = [80, 443].freeze
22
+ USER_AGENT = "safe_image/#{VERSION}".freeze
23
+
24
+ SAFE_CROSS_ORIGIN_REDIRECT_HEADERS = %w[accept accept-encoding user-agent].freeze
25
+ SAFE_INITIAL_REQUEST_HEADERS = SAFE_CROSS_ORIGIN_REDIRECT_HEADERS
26
+ FORBIDDEN_REQUEST_HEADERS = %w[
27
+ host connection keep-alive proxy-authenticate proxy-authorization
28
+ proxy-connection te trailer transfer-encoding upgrade
29
+ ].freeze
30
+
31
+ CONTENT_TYPE_EXTENSIONS = {
32
+ "image/jpeg" => ".jpg",
33
+ "image/jpg" => ".jpg",
34
+ "image/png" => ".png",
35
+ "image/gif" => ".gif",
36
+ "image/webp" => ".webp",
37
+ "image/heic" => ".heic",
38
+ "image/heif" => ".heif",
39
+ "image/avif" => ".avif",
40
+ "image/x-icon" => ".ico",
41
+ "image/vnd.microsoft.icon" => ".ico",
42
+ "image/svg+xml" => ".svg"
43
+ }.freeze
44
+
45
+ EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .svg].freeze
46
+
47
+ BLOCKED_IP_RANGES = [
48
+ # IPv4 special-use / non-public ranges. Default remote fetching is for
49
+ # public Internet images only; callers probing trusted internal URLs must
50
+ # opt in with allow_private: true.
51
+ "0.0.0.0/8", # current network
52
+ "10.0.0.0/8", # RFC1918 private-use
53
+ "100.64.0.0/10", # RFC6598 carrier-grade NAT
54
+ "127.0.0.0/8", # loopback
55
+ "169.254.0.0/16", # RFC3927 link-local
56
+ "172.16.0.0/12", # RFC1918 private-use
57
+ "192.0.0.0/24", # IETF protocol assignments
58
+ "192.0.2.0/24", # TEST-NET-1
59
+ "192.31.196.0/24", # AS112-v4
60
+ "192.52.193.0/24", # AMT
61
+ "192.168.0.0/16", # RFC1918 private-use
62
+ "192.175.48.0/24", # direct delegation AS112 service
63
+ "198.18.0.0/15", # benchmark testing
64
+ "198.51.100.0/24", # TEST-NET-2
65
+ "203.0.113.0/24", # TEST-NET-3
66
+ "224.0.0.0/4", # multicast
67
+ "240.0.0.0/4", # reserved / future-use
68
+ "255.255.255.255/32", # limited broadcast
69
+
70
+ # IPv6 special-use / non-public ranges.
71
+ "::/128", # unspecified
72
+ "::1/128", # loopback
73
+ "::/96", # deprecated IPv4-compatible IPv6
74
+ "::ffff:0:0/96", # IPv4-mapped IPv6
75
+ "64:ff9b::/96", # well-known NAT64 prefix
76
+ "64:ff9b:1::/48", # local-use NAT64 prefix
77
+ "100::/64", # discard-only prefix
78
+ "2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
79
+ "2001:db8::/32", # documentation
80
+ "2002::/16", # 6to4
81
+ "fc00::/7", # unique local address
82
+ "fe80::/10", # link-local unicast
83
+ "ff00::/8" # multicast
84
+ ].map { |range| IPAddr.new(range) }.freeze
85
+
86
+ def fetch(
87
+ url,
88
+ max_bytes: DEFAULT_MAX_BYTES,
89
+ max_redirects: DEFAULT_MAX_REDIRECTS,
90
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
91
+ read_timeout: DEFAULT_READ_TIMEOUT,
92
+ total_timeout: DEFAULT_TOTAL_TIMEOUT,
93
+ allow_private: false,
94
+ allowed_ports: DEFAULT_ALLOWED_PORTS,
95
+ headers: {}
96
+ )
97
+ uri = parse_uri(url)
98
+ started_at = monotonic_time
99
+
100
+ Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
101
+ response = request(
102
+ uri,
103
+ io: file,
104
+ max_bytes: max_bytes,
105
+ max_redirects: max_redirects,
106
+ open_timeout: open_timeout,
107
+ read_timeout: read_timeout,
108
+ total_timeout: total_timeout,
109
+ started_at: started_at,
110
+ allow_private: allow_private,
111
+ allowed_ports: allowed_ports,
112
+ headers: headers
113
+ )
114
+ file.flush
115
+
116
+ ext = extension_for(response.fetch(:uri), response.fetch(:content_type))
117
+ path = file.path
118
+ if File.extname(path) != ext
119
+ renamed = path.sub(/\.bin\z/, ext)
120
+ FileUtils.mv(path, renamed)
121
+ begin
122
+ validate_downloaded_image!(renamed, ext)
123
+ yield renamed
124
+ ensure
125
+ FileUtils.rm_f(renamed)
126
+ end
127
+ else
128
+ validate_downloaded_image!(path, ext)
129
+ yield path
130
+ end
131
+ end
132
+ end
133
+
134
+ def info(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil, animated: false, orientation: false)
135
+ fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
136
+ SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
137
+ end
138
+ end
139
+
140
+ def size(url, **kwargs)
141
+ info(url, **kwargs).size
142
+ end
143
+
144
+ def type(url, **kwargs)
145
+ info(url, **kwargs).type
146
+ end
147
+
148
+ def animated?(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
149
+ fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path|
150
+ SafeImage.animated?(path, max_pixels: max_pixels)
151
+ end
152
+ end
153
+
154
+ def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {})
155
+ raise ArgumentError, "too many redirects" if max_redirects < 0
156
+ check_deadline!(started_at, total_timeout)
157
+ ipaddr = validate_uri!(uri, allow_private: allow_private, allowed_ports: allowed_ports)
158
+
159
+ http = Net::HTTP.new(uri.host, uri.port, nil)
160
+ http.ipaddr = ipaddr if ipaddr
161
+ http.use_ssl = uri.scheme == "https"
162
+ http.open_timeout = open_timeout
163
+ http.read_timeout = read_timeout
164
+
165
+ request = Net::HTTP::Get.new(uri)
166
+ request["User-Agent"] = USER_AGENT
167
+ request["Accept"] = "image/*,*/*;q=0.1"
168
+ request["Accept-Encoding"] = "identity"
169
+ initial_headers(headers).each { |key, value| request[key.to_s] = value.to_s }
170
+
171
+ bytes = 0
172
+ content_type = nil
173
+
174
+ http.request(request) do |response|
175
+ check_deadline!(started_at, total_timeout)
176
+
177
+ case response
178
+ when Net::HTTPRedirection
179
+ location = response["location"] or raise Error, "redirect without Location"
180
+ redirected = parse_uri(uri.merge(location).to_s)
181
+ if uri.scheme == "https" && redirected.scheme == "http"
182
+ raise UnsafePathError, "refusing HTTPS to HTTP redirect"
183
+ end
184
+ return request(
185
+ redirected,
186
+ io: io,
187
+ max_bytes: max_bytes,
188
+ max_redirects: max_redirects - 1,
189
+ open_timeout: open_timeout,
190
+ read_timeout: read_timeout,
191
+ total_timeout: total_timeout,
192
+ started_at: started_at,
193
+ allow_private: allow_private,
194
+ allowed_ports: allowed_ports,
195
+ headers: redirect_headers(headers, from: uri, to: redirected)
196
+ )
197
+ when Net::HTTPSuccess
198
+ content_length = response["content-length"].to_i
199
+ raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
200
+
201
+ content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
202
+ response.read_body do |chunk|
203
+ check_deadline!(started_at, total_timeout)
204
+ bytes += chunk.bytesize
205
+ raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
206
+ io.write(chunk)
207
+ end
208
+ else
209
+ raise Error, "remote image request failed: HTTP #{response.code}"
210
+ end
211
+ end
212
+
213
+ { uri: uri, content_type: content_type, bytes: bytes }
214
+ end
215
+
216
+ def parse_uri(url)
217
+ uri = URI.parse(url.to_s)
218
+ raise ArgumentError, "remote image URL must be http or https" unless %w[http https].include?(uri.scheme)
219
+ raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
220
+ uri
221
+ rescue URI::InvalidURIError => e
222
+ raise ArgumentError, "invalid remote image URL: #{e.message}"
223
+ end
224
+
225
+ def validate_uri!(uri, allow_private:, allowed_ports: DEFAULT_ALLOWED_PORTS)
226
+ unless allow_private || allowed_ports.nil? || allowed_ports.include?(uri.port)
227
+ raise UnsafePathError, "remote image URL uses a disallowed port"
228
+ end
229
+ return nil if allow_private
230
+
231
+ resolver = Resolv::DNS.new
232
+ resolver.timeouts = [2, 2]
233
+ addresses = resolver.getaddresses(uri.host).map(&:to_s)
234
+ raise UnsafePathError, "remote image host did not resolve" if addresses.empty?
235
+
236
+ addresses.each do |address|
237
+ ip = IPAddr.new(address)
238
+ if blocked_ip?(ip)
239
+ raise UnsafePathError, "remote image host resolves to a non-public address"
240
+ end
241
+ end
242
+
243
+ # Pin the socket to a vetted address so validation and connection cannot
244
+ # observe different DNS answers. Prefer IPv4 first for compatibility with
245
+ # common hosts, but either family is fine because every address above was
246
+ # checked.
247
+ addresses.sort_by { |address| address.include?(":") ? 1 : 0 }.first
248
+ end
249
+
250
+ def blocked_ip?(ip)
251
+ BLOCKED_IP_RANGES.any? { |range| range.include?(ip) }
252
+ end
253
+
254
+ def filtered_headers(headers)
255
+ headers.reject { |key, _| FORBIDDEN_REQUEST_HEADERS.include?(key.to_s.downcase) }
256
+ end
257
+
258
+ def initial_headers(headers)
259
+ filtered_headers(headers).select { |key, _| SAFE_INITIAL_REQUEST_HEADERS.include?(key.to_s.downcase) }
260
+ end
261
+
262
+ def redirect_headers(headers, from:, to:)
263
+ headers = filtered_headers(headers)
264
+ return headers if same_origin?(from, to)
265
+
266
+ headers.select { |key, _| SAFE_CROSS_ORIGIN_REDIRECT_HEADERS.include?(key.to_s.downcase) }
267
+ end
268
+
269
+ def same_origin?(a, b)
270
+ a.scheme.to_s.downcase == b.scheme.to_s.downcase &&
271
+ a.host.to_s.downcase == b.host.to_s.downcase &&
272
+ a.port == b.port
273
+ end
274
+
275
+ def check_deadline!(started_at, total_timeout)
276
+ return unless total_timeout
277
+ raise Error, "remote image request timed out" if monotonic_time - started_at > total_timeout
278
+ end
279
+
280
+ def monotonic_time
281
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
282
+ end
283
+
284
+ def extension_for(uri, content_type)
285
+ content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
286
+ raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}" unless content_ext
287
+
288
+ ext = File.extname(uri.path).downcase
289
+ if EXTENSIONS.include?(ext)
290
+ normalized_ext = ext == ".jpeg" ? ".jpg" : ext
291
+ normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
292
+ unless normalized_ext == normalized_content_ext
293
+ raise UnsupportedFormatError, "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
294
+ end
295
+ return ext
296
+ end
297
+
298
+ content_ext
299
+ end
300
+
301
+ def validate_downloaded_image!(path, ext)
302
+ if ext == ".svg"
303
+ SvgMetadata.probe(path)
304
+ else
305
+ SafeImage.probe(path)
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ Info = Data.define(
5
+ :path,
6
+ :type,
7
+ :width,
8
+ :height,
9
+ :size,
10
+ :animated,
11
+ :orientation
12
+ )
13
+
14
+ Result = Data.define(
15
+ :input,
16
+ :output,
17
+ :input_format,
18
+ :output_format,
19
+ :width,
20
+ :height,
21
+ :filesize,
22
+ :backend,
23
+ :duration_ms,
24
+ :optimizer
25
+ ) do
26
+ def success? = true
27
+ end
28
+ end