safe_image 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -10
- data/README.md +176 -168
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +43 -41
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -281
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +250 -48
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
data/lib/safe_image/remote.rb
CHANGED
|
@@ -7,10 +7,9 @@ require "time"
|
|
|
7
7
|
require "tmpdir"
|
|
8
8
|
require "uri"
|
|
9
9
|
# net/http and resolv are required lazily inside the methods that fetch, not at
|
|
10
|
-
# load time:
|
|
11
|
-
# /
|
|
12
|
-
#
|
|
13
|
-
# symlink into /run the sandbox denies that read and the worker dies at boot.
|
|
10
|
+
# load time: remote fetches need network access, while sandboxed image-processing
|
|
11
|
+
# child helpers/tools deliberately do not get it. Keep networking libraries off
|
|
12
|
+
# the load path until a caller uses the remote API.
|
|
14
13
|
|
|
15
14
|
module SafeImage
|
|
16
15
|
module Remote
|
|
@@ -27,8 +26,16 @@ module SafeImage
|
|
|
27
26
|
SAFE_CROSS_ORIGIN_REDIRECT_HEADERS = %w[accept accept-encoding user-agent].freeze
|
|
28
27
|
SAFE_INITIAL_REQUEST_HEADERS = SAFE_CROSS_ORIGIN_REDIRECT_HEADERS
|
|
29
28
|
FORBIDDEN_REQUEST_HEADERS = %w[
|
|
30
|
-
host
|
|
31
|
-
|
|
29
|
+
host
|
|
30
|
+
connection
|
|
31
|
+
keep-alive
|
|
32
|
+
proxy-authenticate
|
|
33
|
+
proxy-authorization
|
|
34
|
+
proxy-connection
|
|
35
|
+
te
|
|
36
|
+
trailer
|
|
37
|
+
transfer-encoding
|
|
38
|
+
upgrade
|
|
32
39
|
].freeze
|
|
33
40
|
|
|
34
41
|
CONTENT_TYPE_EXTENSIONS = {
|
|
@@ -48,44 +55,85 @@ module SafeImage
|
|
|
48
55
|
|
|
49
56
|
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
58
|
+
# First-bytes signatures per downloaded extension, checked as soon as the
|
|
59
|
+
# first SIGNATURE_HEAD_BYTES of the body arrive so an obviously mislabeled
|
|
60
|
+
# response is dropped without downloading the rest. Each entry lists
|
|
61
|
+
# alternative candidates; a candidate is a list of [offset, bytes] pairs
|
|
62
|
+
# that must all match. The check rejects only on a definite mismatch of
|
|
63
|
+
# every candidate against fully-available bytes, so it can never reject an
|
|
64
|
+
# image the configured backend could decode — decoders sniff these same
|
|
65
|
+
# magic bytes to pick a loader. SVG has no usable signature and is exempt.
|
|
66
|
+
SIGNATURES = {
|
|
67
|
+
".jpg" => [[[0, "\xFF\xD8\xFF".b]]],
|
|
68
|
+
".jpeg" => [[[0, "\xFF\xD8\xFF".b]]],
|
|
69
|
+
".png" => [[[0, "\x89PNG\r\n\x1A\n".b]]],
|
|
70
|
+
".gif" => [[[0, "GIF8".b]]],
|
|
71
|
+
".webp" => [[[0, "RIFF".b], [8, "WEBP".b]]],
|
|
72
|
+
".ico" => [[[0, "\x00\x00\x01\x00".b]]],
|
|
73
|
+
".heic" => [[[4, "ftyp".b]]],
|
|
74
|
+
".heif" => [[[4, "ftyp".b]]],
|
|
75
|
+
".avif" => [[[4, "ftyp".b]]],
|
|
76
|
+
".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# 12 bytes covers the longest fixed magic this gate uses today (container JXL)
|
|
80
|
+
# while still rejecting mismatches before meaningful body download.
|
|
81
|
+
SIGNATURE_HEAD_BYTES = 12
|
|
82
|
+
|
|
83
|
+
# The metadata helpers probe the partially-downloaded file at these growing
|
|
84
|
+
# byte thresholds: 64KiB catches normal headers, x4 reaches pathological but
|
|
85
|
+
# valid marker chains without too many decoder attempts.
|
|
86
|
+
PREFIX_PROBE_INITIAL_BYTES = 64 * 1024
|
|
87
|
+
PREFIX_PROBE_GROWTH_FACTOR = 4
|
|
88
|
+
|
|
89
|
+
# SVG is excluded from prefix probing: SvgMetadata enforces a total-size
|
|
90
|
+
# cap (MAX_SVG_BYTES) that probing a prefix would bypass, and remote SVGs
|
|
91
|
+
# are small enough that downloading them fully costs little.
|
|
92
|
+
PREFIX_PROBE_EXTENSIONS = (EXTENSIONS - [".svg"]).freeze
|
|
93
|
+
|
|
94
|
+
# Sentinel a metadata_fetch block returns when the prefix parsed but its
|
|
95
|
+
# answer could still change with more data (e.g. "not animated", which a
|
|
96
|
+
# truncated file can report for an animated one).
|
|
97
|
+
CONTINUE_DOWNLOAD = Object.new.freeze
|
|
98
|
+
|
|
99
|
+
BLOCKED_IP_RANGES =
|
|
100
|
+
[
|
|
101
|
+
# IPv4 special-use / non-public ranges. Default remote fetching is for
|
|
102
|
+
# public Internet images only; callers probing trusted internal URLs must
|
|
103
|
+
# opt in with allow_private: true.
|
|
104
|
+
"0.0.0.0/8", # current network
|
|
105
|
+
"10.0.0.0/8", # RFC1918 private-use
|
|
106
|
+
"100.64.0.0/10", # RFC6598 carrier-grade NAT
|
|
107
|
+
"127.0.0.0/8", # loopback
|
|
108
|
+
"169.254.0.0/16", # RFC3927 link-local
|
|
109
|
+
"172.16.0.0/12", # RFC1918 private-use
|
|
110
|
+
"192.0.0.0/24", # IETF protocol assignments
|
|
111
|
+
"192.0.2.0/24", # TEST-NET-1
|
|
112
|
+
"192.31.196.0/24", # AS112-v4
|
|
113
|
+
"192.52.193.0/24", # AMT
|
|
114
|
+
"192.168.0.0/16", # RFC1918 private-use
|
|
115
|
+
"192.175.48.0/24", # direct delegation AS112 service
|
|
116
|
+
"198.18.0.0/15", # benchmark testing
|
|
117
|
+
"198.51.100.0/24", # TEST-NET-2
|
|
118
|
+
"203.0.113.0/24", # TEST-NET-3
|
|
119
|
+
"224.0.0.0/4", # multicast
|
|
120
|
+
"240.0.0.0/4", # reserved / future-use
|
|
121
|
+
"255.255.255.255/32", # limited broadcast
|
|
122
|
+
# IPv6 special-use / non-public ranges.
|
|
123
|
+
"::/128", # unspecified
|
|
124
|
+
"::1/128", # loopback
|
|
125
|
+
"::/96", # deprecated IPv4-compatible IPv6
|
|
126
|
+
"::ffff:0:0/96", # IPv4-mapped IPv6
|
|
127
|
+
"64:ff9b::/96", # well-known NAT64 prefix
|
|
128
|
+
"64:ff9b:1::/48", # local-use NAT64 prefix
|
|
129
|
+
"100::/64", # discard-only prefix
|
|
130
|
+
"2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
|
|
131
|
+
"2001:db8::/32", # documentation
|
|
132
|
+
"2002::/16", # 6to4
|
|
133
|
+
"fc00::/7", # unique local address
|
|
134
|
+
"fe80::/10", # link-local unicast
|
|
135
|
+
"ff00::/8" # multicast
|
|
136
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
89
137
|
|
|
90
138
|
def fetch(
|
|
91
139
|
url,
|
|
@@ -101,23 +149,24 @@ module SafeImage
|
|
|
101
149
|
uri = parse_uri(url)
|
|
102
150
|
started_at = monotonic_time
|
|
103
151
|
|
|
104
|
-
Tempfile.create([
|
|
105
|
-
response =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
|
|
153
|
+
response =
|
|
154
|
+
request(
|
|
155
|
+
uri,
|
|
156
|
+
io: file,
|
|
157
|
+
max_bytes: max_bytes,
|
|
158
|
+
max_redirects: max_redirects,
|
|
159
|
+
open_timeout: open_timeout,
|
|
160
|
+
read_timeout: read_timeout,
|
|
161
|
+
total_timeout: total_timeout,
|
|
162
|
+
started_at: started_at,
|
|
163
|
+
allow_private: allow_private,
|
|
164
|
+
allowed_ports: allowed_ports,
|
|
165
|
+
headers: headers
|
|
166
|
+
)
|
|
118
167
|
file.flush
|
|
119
168
|
|
|
120
|
-
ext =
|
|
169
|
+
ext = response.fetch(:ext)
|
|
121
170
|
path = file.path
|
|
122
171
|
if File.extname(path) != ext
|
|
123
172
|
renamed = path.sub(/\.bin\z/, ext)
|
|
@@ -135,9 +184,42 @@ module SafeImage
|
|
|
135
184
|
end
|
|
136
185
|
end
|
|
137
186
|
|
|
138
|
-
def info(
|
|
139
|
-
|
|
140
|
-
|
|
187
|
+
def info(
|
|
188
|
+
url,
|
|
189
|
+
max_bytes: DEFAULT_MAX_BYTES,
|
|
190
|
+
max_redirects: DEFAULT_MAX_REDIRECTS,
|
|
191
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
192
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
193
|
+
total_timeout: DEFAULT_TOTAL_TIMEOUT,
|
|
194
|
+
allow_private: false,
|
|
195
|
+
allowed_ports: DEFAULT_ALLOWED_PORTS,
|
|
196
|
+
headers: {},
|
|
197
|
+
max_pixels: nil,
|
|
198
|
+
animated: false,
|
|
199
|
+
orientation: false
|
|
200
|
+
)
|
|
201
|
+
metadata_fetch(
|
|
202
|
+
url,
|
|
203
|
+
max_bytes: max_bytes,
|
|
204
|
+
max_redirects: max_redirects,
|
|
205
|
+
open_timeout: open_timeout,
|
|
206
|
+
read_timeout: read_timeout,
|
|
207
|
+
total_timeout: total_timeout,
|
|
208
|
+
allow_private: allow_private,
|
|
209
|
+
allowed_ports: allowed_ports,
|
|
210
|
+
headers: headers
|
|
211
|
+
) do |path, eof|
|
|
212
|
+
result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
|
|
213
|
+
# A truncated file can undercount frames but never overcount, so
|
|
214
|
+
# "animated" is final as soon as it is true; "not animated" is only
|
|
215
|
+
# provable from the complete file. Type, dimensions and orientation
|
|
216
|
+
# come from the header the successful probe just parsed, so they
|
|
217
|
+
# cannot change with more data.
|
|
218
|
+
if animated && result.animated != true && !eof
|
|
219
|
+
CONTINUE_DOWNLOAD
|
|
220
|
+
else
|
|
221
|
+
result
|
|
222
|
+
end
|
|
141
223
|
end
|
|
142
224
|
end
|
|
143
225
|
|
|
@@ -149,19 +231,163 @@ module SafeImage
|
|
|
149
231
|
info(url, **kwargs).type
|
|
150
232
|
end
|
|
151
233
|
|
|
152
|
-
def animated?(
|
|
153
|
-
|
|
154
|
-
|
|
234
|
+
def animated?(
|
|
235
|
+
url,
|
|
236
|
+
max_bytes: DEFAULT_MAX_BYTES,
|
|
237
|
+
max_redirects: DEFAULT_MAX_REDIRECTS,
|
|
238
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
239
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
240
|
+
total_timeout: DEFAULT_TOTAL_TIMEOUT,
|
|
241
|
+
allow_private: false,
|
|
242
|
+
allowed_ports: DEFAULT_ALLOWED_PORTS,
|
|
243
|
+
headers: {},
|
|
244
|
+
max_pixels: nil
|
|
245
|
+
)
|
|
246
|
+
metadata_fetch(
|
|
247
|
+
url,
|
|
248
|
+
max_bytes: max_bytes,
|
|
249
|
+
max_redirects: max_redirects,
|
|
250
|
+
open_timeout: open_timeout,
|
|
251
|
+
read_timeout: read_timeout,
|
|
252
|
+
total_timeout: total_timeout,
|
|
253
|
+
allow_private: allow_private,
|
|
254
|
+
allowed_ports: allowed_ports,
|
|
255
|
+
headers: headers
|
|
256
|
+
) do |path, eof|
|
|
257
|
+
answer = SafeImage.animated?(path, max_pixels: max_pixels)
|
|
258
|
+
next CONTINUE_DOWNLOAD if !eof && answer != true
|
|
259
|
+
|
|
260
|
+
answer
|
|
155
261
|
end
|
|
156
262
|
end
|
|
157
263
|
|
|
158
|
-
def dominant_color(
|
|
159
|
-
|
|
160
|
-
|
|
264
|
+
def dominant_color(
|
|
265
|
+
url,
|
|
266
|
+
max_bytes: DEFAULT_MAX_BYTES,
|
|
267
|
+
max_redirects: DEFAULT_MAX_REDIRECTS,
|
|
268
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
269
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
270
|
+
total_timeout: DEFAULT_TOTAL_TIMEOUT,
|
|
271
|
+
allow_private: false,
|
|
272
|
+
allowed_ports: DEFAULT_ALLOWED_PORTS,
|
|
273
|
+
headers: {},
|
|
274
|
+
max_pixels: nil
|
|
275
|
+
)
|
|
276
|
+
fetch(
|
|
277
|
+
url,
|
|
278
|
+
max_bytes: max_bytes,
|
|
279
|
+
max_redirects: max_redirects,
|
|
280
|
+
open_timeout: open_timeout,
|
|
281
|
+
read_timeout: read_timeout,
|
|
282
|
+
total_timeout: total_timeout,
|
|
283
|
+
allow_private: allow_private,
|
|
284
|
+
allowed_ports: allowed_ports,
|
|
285
|
+
headers: headers
|
|
286
|
+
) { |path| SafeImage.dominant_color(path, max_pixels: max_pixels) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Single-GET download that re-attempts the local metadata probe as bytes
|
|
290
|
+
# arrive (at PREFIX_PROBE_INITIAL_BYTES, then growing by
|
|
291
|
+
# PREFIX_PROBE_GROWTH_FACTOR) and aborts the transfer as soon as the
|
|
292
|
+
# block's answer is final. The block receives (path, eof) and must return
|
|
293
|
+
# CONTINUE_DOWNLOAD while its answer could still change with more data.
|
|
294
|
+
#
|
|
295
|
+
# Safety contract: any error from a pre-EOF probe means "not enough bytes
|
|
296
|
+
# yet" — it is swallowed and the download continues, so the complete file
|
|
297
|
+
# always gets the last word with exactly the validation and error
|
|
298
|
+
# behaviour of the full-download path (validate_downloaded_image! plus an
|
|
299
|
+
# un-rescued final probe). A file that never early-exits is handled
|
|
300
|
+
# byte-for-byte like Remote.fetch handles it.
|
|
301
|
+
def metadata_fetch(
|
|
302
|
+
url,
|
|
303
|
+
max_bytes:,
|
|
304
|
+
max_redirects:,
|
|
305
|
+
open_timeout:,
|
|
306
|
+
read_timeout:,
|
|
307
|
+
total_timeout:,
|
|
308
|
+
allow_private:,
|
|
309
|
+
allowed_ports:,
|
|
310
|
+
headers:,
|
|
311
|
+
&compute
|
|
312
|
+
)
|
|
313
|
+
uri = parse_uri(url)
|
|
314
|
+
started_at = monotonic_time
|
|
315
|
+
|
|
316
|
+
Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
|
|
317
|
+
original_path = file.path
|
|
318
|
+
path = original_path
|
|
319
|
+
ext = nil
|
|
320
|
+
next_probe_at = PREFIX_PROBE_INITIAL_BYTES
|
|
321
|
+
|
|
322
|
+
begin
|
|
323
|
+
early =
|
|
324
|
+
catch(:metadata_answer) do
|
|
325
|
+
request(
|
|
326
|
+
uri,
|
|
327
|
+
io: file,
|
|
328
|
+
max_bytes: max_bytes,
|
|
329
|
+
max_redirects: max_redirects,
|
|
330
|
+
open_timeout: open_timeout,
|
|
331
|
+
read_timeout: read_timeout,
|
|
332
|
+
total_timeout: total_timeout,
|
|
333
|
+
started_at: started_at,
|
|
334
|
+
allow_private: allow_private,
|
|
335
|
+
allowed_ports: allowed_ports,
|
|
336
|
+
headers: headers,
|
|
337
|
+
# The extension is known before the body: give the tempfile its
|
|
338
|
+
# final name up front so probes dispatch on the right loader.
|
|
339
|
+
on_headers: ->(response_ext) do
|
|
340
|
+
ext = response_ext
|
|
341
|
+
renamed = original_path.sub(/\.bin\z/, ext)
|
|
342
|
+
FileUtils.mv(original_path, renamed)
|
|
343
|
+
path = renamed
|
|
344
|
+
end,
|
|
345
|
+
on_progress: ->(bytes) do
|
|
346
|
+
next if PREFIX_PROBE_EXTENSIONS.none? { |candidate| candidate == ext }
|
|
347
|
+
next if bytes < next_probe_at
|
|
348
|
+
|
|
349
|
+
next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
|
|
350
|
+
file.flush
|
|
351
|
+
answer =
|
|
352
|
+
begin
|
|
353
|
+
compute.call(path, false)
|
|
354
|
+
rescue StandardError
|
|
355
|
+
CONTINUE_DOWNLOAD
|
|
356
|
+
end
|
|
357
|
+
throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
|
|
358
|
+
end
|
|
359
|
+
)
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if early
|
|
364
|
+
early.first
|
|
365
|
+
else
|
|
366
|
+
file.flush
|
|
367
|
+
validate_downloaded_image!(path, ext)
|
|
368
|
+
compute.call(path, true)
|
|
369
|
+
end
|
|
370
|
+
ensure
|
|
371
|
+
FileUtils.rm_f(path) unless path == original_path
|
|
372
|
+
end
|
|
161
373
|
end
|
|
162
374
|
end
|
|
163
375
|
|
|
164
|
-
def request(
|
|
376
|
+
def request(
|
|
377
|
+
uri,
|
|
378
|
+
io:,
|
|
379
|
+
max_bytes:,
|
|
380
|
+
max_redirects:,
|
|
381
|
+
open_timeout:,
|
|
382
|
+
read_timeout:,
|
|
383
|
+
total_timeout:,
|
|
384
|
+
started_at:,
|
|
385
|
+
allow_private:,
|
|
386
|
+
allowed_ports:,
|
|
387
|
+
headers: {},
|
|
388
|
+
on_headers: nil,
|
|
389
|
+
on_progress: nil
|
|
390
|
+
)
|
|
165
391
|
require "net/http"
|
|
166
392
|
raise ArgumentError, "too many redirects" if max_redirects < 0
|
|
167
393
|
check_deadline!(started_at, total_timeout)
|
|
@@ -181,6 +407,7 @@ module SafeImage
|
|
|
181
407
|
|
|
182
408
|
bytes = 0
|
|
183
409
|
content_type = nil
|
|
410
|
+
ext = nil
|
|
184
411
|
|
|
185
412
|
http.request(request) do |response|
|
|
186
413
|
check_deadline!(started_at, total_timeout)
|
|
@@ -192,41 +419,84 @@ module SafeImage
|
|
|
192
419
|
if uri.scheme == "https" && redirected.scheme == "http"
|
|
193
420
|
raise UnsafePathError, "refusing HTTPS to HTTP redirect"
|
|
194
421
|
end
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
422
|
+
redirected_response =
|
|
423
|
+
request(
|
|
424
|
+
redirected,
|
|
425
|
+
io: io,
|
|
426
|
+
max_bytes: max_bytes,
|
|
427
|
+
max_redirects: max_redirects - 1,
|
|
428
|
+
open_timeout: open_timeout,
|
|
429
|
+
read_timeout: read_timeout,
|
|
430
|
+
total_timeout: total_timeout,
|
|
431
|
+
started_at: started_at,
|
|
432
|
+
allow_private: allow_private,
|
|
433
|
+
allowed_ports: allowed_ports,
|
|
434
|
+
headers: redirect_headers(headers, from: uri, to: redirected),
|
|
435
|
+
on_headers: on_headers,
|
|
436
|
+
on_progress: on_progress
|
|
437
|
+
)
|
|
438
|
+
return redirected_response
|
|
208
439
|
when Net::HTTPSuccess
|
|
209
440
|
content_length = response["content-length"].to_i
|
|
210
441
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
|
|
211
442
|
|
|
212
443
|
content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
|
|
444
|
+
# Everything the content-type and extension-agreement checks need is
|
|
445
|
+
# in the headers: reject unsupported or mismatched responses before
|
|
446
|
+
# reading a single body byte.
|
|
447
|
+
ext = extension_for(uri, content_type)
|
|
448
|
+
on_headers&.call(ext)
|
|
449
|
+
|
|
450
|
+
head = "".b
|
|
451
|
+
head_checked = false
|
|
213
452
|
response.read_body do |chunk|
|
|
214
453
|
check_deadline!(started_at, total_timeout)
|
|
215
454
|
bytes += chunk.bytesize
|
|
216
455
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
|
|
456
|
+
unless head_checked
|
|
457
|
+
head << chunk
|
|
458
|
+
if head.bytesize >= SIGNATURE_HEAD_BYTES
|
|
459
|
+
verify_signature!(ext, head)
|
|
460
|
+
head_checked = true
|
|
461
|
+
head = nil
|
|
462
|
+
end
|
|
463
|
+
end
|
|
217
464
|
io.write(chunk)
|
|
465
|
+
on_progress&.call(bytes)
|
|
218
466
|
end
|
|
467
|
+
verify_signature!(ext, head) if !head_checked && !head.empty?
|
|
219
468
|
else
|
|
220
469
|
raise Error, "remote image request failed: HTTP #{response.code}"
|
|
221
470
|
end
|
|
222
471
|
end
|
|
223
472
|
|
|
224
|
-
{ uri: uri, content_type: content_type, bytes: bytes }
|
|
473
|
+
{ uri: uri, content_type: content_type, ext: ext, bytes: bytes }
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Rejects a body whose first bytes definitively cannot belong to the
|
|
477
|
+
# format the response claimed. Formats without a fixed signature (SVG)
|
|
478
|
+
# and bytes not yet downloaded are never grounds for rejection.
|
|
479
|
+
def verify_signature!(ext, head)
|
|
480
|
+
candidates = SIGNATURES[ext]
|
|
481
|
+
return unless candidates
|
|
482
|
+
|
|
483
|
+
compatible =
|
|
484
|
+
candidates.any? do |candidate|
|
|
485
|
+
candidate.all? do |offset, bytes|
|
|
486
|
+
slice = head.byteslice(offset, bytes.bytesize).to_s
|
|
487
|
+
slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
return if compatible
|
|
491
|
+
|
|
492
|
+
raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
|
|
225
493
|
end
|
|
226
494
|
|
|
227
495
|
def parse_uri(url)
|
|
228
496
|
uri = URI.parse(url.to_s)
|
|
229
|
-
|
|
497
|
+
if %w[http https].none? { |scheme| scheme == uri.scheme }
|
|
498
|
+
raise ArgumentError, "remote image URL must be http or https"
|
|
499
|
+
end
|
|
230
500
|
raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
|
|
231
501
|
uri
|
|
232
502
|
rescue URI::InvalidURIError => e
|
|
@@ -247,9 +517,7 @@ module SafeImage
|
|
|
247
517
|
|
|
248
518
|
addresses.each do |address|
|
|
249
519
|
ip = IPAddr.new(address)
|
|
250
|
-
if blocked_ip?(ip)
|
|
251
|
-
raise UnsafePathError, "remote image host resolves to a non-public address"
|
|
252
|
-
end
|
|
520
|
+
raise UnsafePathError, "remote image host resolves to a non-public address" if blocked_ip?(ip)
|
|
253
521
|
end
|
|
254
522
|
|
|
255
523
|
# Pin the socket to a vetted address so validation and connection cannot
|
|
@@ -279,8 +547,7 @@ module SafeImage
|
|
|
279
547
|
end
|
|
280
548
|
|
|
281
549
|
def same_origin?(a, b)
|
|
282
|
-
a.scheme.to_s.downcase == b.scheme.to_s.downcase &&
|
|
283
|
-
a.host.to_s.downcase == b.host.to_s.downcase &&
|
|
550
|
+
a.scheme.to_s.downcase == b.scheme.to_s.downcase && a.host.to_s.downcase == b.host.to_s.downcase &&
|
|
284
551
|
a.port == b.port
|
|
285
552
|
end
|
|
286
553
|
|
|
@@ -295,14 +562,17 @@ module SafeImage
|
|
|
295
562
|
|
|
296
563
|
def extension_for(uri, content_type)
|
|
297
564
|
content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
|
|
298
|
-
|
|
565
|
+
unless content_ext
|
|
566
|
+
raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}"
|
|
567
|
+
end
|
|
299
568
|
|
|
300
569
|
ext = File.extname(uri.path).downcase
|
|
301
570
|
if EXTENSIONS.include?(ext)
|
|
302
571
|
normalized_ext = ext == ".jpeg" ? ".jpg" : ext
|
|
303
572
|
normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
|
|
304
573
|
unless normalized_ext == normalized_content_ext
|
|
305
|
-
raise UnsupportedFormatError,
|
|
574
|
+
raise UnsupportedFormatError,
|
|
575
|
+
"remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
|
|
306
576
|
end
|
|
307
577
|
return ext
|
|
308
578
|
end
|
|
@@ -310,12 +580,12 @@ module SafeImage
|
|
|
310
580
|
content_ext
|
|
311
581
|
end
|
|
312
582
|
|
|
313
|
-
def validate_downloaded_image!(path,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
583
|
+
def validate_downloaded_image!(path, _ext)
|
|
584
|
+
# Always go back through the public local probe path so configured
|
|
585
|
+
# sandboxing and pixel limits apply uniformly. Remote.fetch itself needs
|
|
586
|
+
# network access; once bytes are on disk, validation must re-enter the
|
|
587
|
+
# normal image-processing boundary.
|
|
588
|
+
SafeImage.probe(path)
|
|
319
589
|
end
|
|
320
590
|
end
|
|
321
591
|
end
|