safe_image 0.3.0 → 0.5.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 +61 -12
- data/README.md +140 -287
- 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 +42 -40
- 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 -290
- 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 +225 -98
- 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 +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- 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 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- data/lib/safe_image/zygote.rb +0 -619
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 = {
|
|
@@ -69,11 +76,13 @@ module SafeImage
|
|
|
69
76
|
".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
|
|
70
77
|
}.freeze
|
|
71
78
|
|
|
79
|
+
# 12 bytes covers the longest fixed magic this gate uses today (container JXL)
|
|
80
|
+
# while still rejecting mismatches before meaningful body download.
|
|
72
81
|
SIGNATURE_HEAD_BYTES = 12
|
|
73
82
|
|
|
74
|
-
# The metadata helpers probe the partially-downloaded file at these
|
|
75
|
-
#
|
|
76
|
-
#
|
|
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.
|
|
77
86
|
PREFIX_PROBE_INITIAL_BYTES = 64 * 1024
|
|
78
87
|
PREFIX_PROBE_GROWTH_FACTOR = 4
|
|
79
88
|
|
|
@@ -87,44 +96,44 @@ module SafeImage
|
|
|
87
96
|
# truncated file can report for an animated one).
|
|
88
97
|
CONTINUE_DOWNLOAD = Object.new.freeze
|
|
89
98
|
|
|
90
|
-
BLOCKED_IP_RANGES =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
128
137
|
|
|
129
138
|
def fetch(
|
|
130
139
|
url,
|
|
@@ -140,20 +149,21 @@ module SafeImage
|
|
|
140
149
|
uri = parse_uri(url)
|
|
141
150
|
started_at = monotonic_time
|
|
142
151
|
|
|
143
|
-
Tempfile.create([
|
|
144
|
-
response =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
)
|
|
157
167
|
file.flush
|
|
158
168
|
|
|
159
169
|
ext = response.fetch(:ext)
|
|
@@ -174,8 +184,31 @@ module SafeImage
|
|
|
174
184
|
end
|
|
175
185
|
end
|
|
176
186
|
|
|
177
|
-
def info(
|
|
178
|
-
|
|
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|
|
|
179
212
|
result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
|
|
180
213
|
# A truncated file can undercount frames but never overcount, so
|
|
181
214
|
# "animated" is final as soon as it is true; "not animated" is only
|
|
@@ -198,8 +231,29 @@ module SafeImage
|
|
|
198
231
|
info(url, **kwargs).type
|
|
199
232
|
end
|
|
200
233
|
|
|
201
|
-
def animated?(
|
|
202
|
-
|
|
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|
|
|
203
257
|
answer = SafeImage.animated?(path, max_pixels: max_pixels)
|
|
204
258
|
next CONTINUE_DOWNLOAD if !eof && answer != true
|
|
205
259
|
|
|
@@ -207,10 +261,29 @@ module SafeImage
|
|
|
207
261
|
end
|
|
208
262
|
end
|
|
209
263
|
|
|
210
|
-
def dominant_color(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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) }
|
|
214
287
|
end
|
|
215
288
|
|
|
216
289
|
# Single-GET download that re-attempts the local metadata probe as bytes
|
|
@@ -225,55 +298,67 @@ module SafeImage
|
|
|
225
298
|
# behaviour of the full-download path (validate_downloaded_image! plus an
|
|
226
299
|
# un-rescued final probe). A file that never early-exits is handled
|
|
227
300
|
# byte-for-byte like Remote.fetch handles it.
|
|
228
|
-
def metadata_fetch(
|
|
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
|
+
)
|
|
229
313
|
uri = parse_uri(url)
|
|
230
314
|
started_at = monotonic_time
|
|
231
315
|
|
|
232
|
-
Tempfile.create([
|
|
316
|
+
Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
|
|
233
317
|
original_path = file.path
|
|
234
318
|
path = original_path
|
|
235
319
|
ext = nil
|
|
236
320
|
next_probe_at = PREFIX_PROBE_INITIAL_BYTES
|
|
237
321
|
|
|
238
322
|
begin
|
|
239
|
-
early =
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
277
362
|
|
|
278
363
|
if early
|
|
279
364
|
early.first
|
|
@@ -288,7 +373,21 @@ module SafeImage
|
|
|
288
373
|
end
|
|
289
374
|
end
|
|
290
375
|
|
|
291
|
-
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
|
+
)
|
|
292
391
|
require "net/http"
|
|
293
392
|
raise ArgumentError, "too many redirects" if max_redirects < 0
|
|
294
393
|
check_deadline!(started_at, total_timeout)
|
|
@@ -320,21 +419,23 @@ module SafeImage
|
|
|
320
419
|
if uri.scheme == "https" && redirected.scheme == "http"
|
|
321
420
|
raise UnsafePathError, "refusing HTTPS to HTTP redirect"
|
|
322
421
|
end
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
338
439
|
when Net::HTTPSuccess
|
|
339
440
|
content_length = response["content-length"].to_i
|
|
340
441
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
|
|
@@ -379,12 +480,13 @@ module SafeImage
|
|
|
379
480
|
candidates = SIGNATURES[ext]
|
|
380
481
|
return unless candidates
|
|
381
482
|
|
|
382
|
-
compatible =
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
386
489
|
end
|
|
387
|
-
end
|
|
388
490
|
return if compatible
|
|
389
491
|
|
|
390
492
|
raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
|
|
@@ -392,7 +494,9 @@ module SafeImage
|
|
|
392
494
|
|
|
393
495
|
def parse_uri(url)
|
|
394
496
|
uri = URI.parse(url.to_s)
|
|
395
|
-
|
|
497
|
+
if %w[http https].none? { |scheme| scheme == uri.scheme }
|
|
498
|
+
raise ArgumentError, "remote image URL must be http or https"
|
|
499
|
+
end
|
|
396
500
|
raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
|
|
397
501
|
uri
|
|
398
502
|
rescue URI::InvalidURIError => e
|
|
@@ -413,9 +517,7 @@ module SafeImage
|
|
|
413
517
|
|
|
414
518
|
addresses.each do |address|
|
|
415
519
|
ip = IPAddr.new(address)
|
|
416
|
-
if blocked_ip?(ip)
|
|
417
|
-
raise UnsafePathError, "remote image host resolves to a non-public address"
|
|
418
|
-
end
|
|
520
|
+
raise UnsafePathError, "remote image host resolves to a non-public address" if blocked_ip?(ip)
|
|
419
521
|
end
|
|
420
522
|
|
|
421
523
|
# Pin the socket to a vetted address so validation and connection cannot
|
|
@@ -445,8 +547,7 @@ module SafeImage
|
|
|
445
547
|
end
|
|
446
548
|
|
|
447
549
|
def same_origin?(a, b)
|
|
448
|
-
a.scheme.to_s.downcase == b.scheme.to_s.downcase &&
|
|
449
|
-
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 &&
|
|
450
551
|
a.port == b.port
|
|
451
552
|
end
|
|
452
553
|
|
|
@@ -461,14 +562,17 @@ module SafeImage
|
|
|
461
562
|
|
|
462
563
|
def extension_for(uri, content_type)
|
|
463
564
|
content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
|
|
464
|
-
|
|
565
|
+
unless content_ext
|
|
566
|
+
raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}"
|
|
567
|
+
end
|
|
465
568
|
|
|
466
569
|
ext = File.extname(uri.path).downcase
|
|
467
570
|
if EXTENSIONS.include?(ext)
|
|
468
571
|
normalized_ext = ext == ".jpeg" ? ".jpg" : ext
|
|
469
572
|
normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
|
|
470
573
|
unless normalized_ext == normalized_content_ext
|
|
471
|
-
raise UnsupportedFormatError,
|
|
574
|
+
raise UnsupportedFormatError,
|
|
575
|
+
"remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
|
|
472
576
|
end
|
|
473
577
|
return ext
|
|
474
578
|
end
|
|
@@ -476,12 +580,12 @@ module SafeImage
|
|
|
476
580
|
content_ext
|
|
477
581
|
end
|
|
478
582
|
|
|
479
|
-
def validate_downloaded_image!(path,
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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)
|
|
485
589
|
end
|
|
486
590
|
end
|
|
487
591
|
end
|
data/lib/safe_image/result.rb
CHANGED
|
@@ -1,28 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SafeImage
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
:width,
|
|
8
|
-
:height,
|
|
9
|
-
:size,
|
|
10
|
-
:animated,
|
|
11
|
-
:orientation
|
|
12
|
-
)
|
|
4
|
+
# Public metadata return value. Optional fields (`animated`, `orientation`) are
|
|
5
|
+
# nil unless the caller opts into the extra probes.
|
|
6
|
+
Info = Data.define(:path, :type, :width, :height, :size, :animated, :orientation)
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
8
|
+
# Public image-operation return value. `output`/`output_format` are nil for
|
|
9
|
+
# metadata-only probes; image-producing operations always include them. `tier`
|
|
10
|
+
# records the concrete execution tier (for example :native_encode,
|
|
11
|
+
# :jpegtran_lossless or :jpegtran_fallback_reencode) so non-fatal degradation
|
|
12
|
+
# is observable without parsing the backend label.
|
|
13
|
+
Result =
|
|
14
|
+
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
|
+
:tier
|
|
26
|
+
) do
|
|
27
|
+
def self.build(
|
|
28
|
+
input:,
|
|
29
|
+
output: nil,
|
|
30
|
+
input_format:,
|
|
31
|
+
output_format: nil,
|
|
32
|
+
width:,
|
|
33
|
+
height:,
|
|
34
|
+
backend:,
|
|
35
|
+
duration_ms:,
|
|
36
|
+
filesize: nil,
|
|
37
|
+
optimizer: nil,
|
|
38
|
+
encoder: nil,
|
|
39
|
+
tier: nil
|
|
40
|
+
)
|
|
41
|
+
new(
|
|
42
|
+
input: input.to_s,
|
|
43
|
+
output: output&.to_s,
|
|
44
|
+
input_format: input_format,
|
|
45
|
+
output_format: output_format,
|
|
46
|
+
width: width,
|
|
47
|
+
height: height,
|
|
48
|
+
filesize: filesize || result_filesize(input, output),
|
|
49
|
+
backend: BackendLabel.build(backend, encoder: encoder),
|
|
50
|
+
duration_ms: duration_ms,
|
|
51
|
+
optimizer: optimizer,
|
|
52
|
+
tier: tier
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.metadata(input:, input_format:, width:, height:, backend:, duration_ms:)
|
|
57
|
+
build(
|
|
58
|
+
input: input,
|
|
59
|
+
input_format: input_format,
|
|
60
|
+
width: width,
|
|
61
|
+
height: height,
|
|
62
|
+
filesize: File.size(input),
|
|
63
|
+
backend: backend,
|
|
64
|
+
duration_ms: duration_ms,
|
|
65
|
+
tier: :metadata
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.result_filesize(input, output)
|
|
70
|
+
path = output || input
|
|
71
|
+
path ? File.size(path) : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def success? = true
|
|
75
|
+
end
|
|
28
76
|
end
|