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