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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +43 -41
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -281
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +250 -48
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
@@ -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: requiring them pulls in resolv, which (Ruby 3.4+) reads
11
- # /etc/resolv.conf eagerly. The Landlock-sandboxed worker loads this file but
12
- # never performs remote fetches, and on hosts where /etc/resolv.conf is a
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 connection keep-alive proxy-authenticate proxy-authorization
31
- proxy-connection te trailer transfer-encoding upgrade
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
- BLOCKED_IP_RANGES = [
52
- # IPv4 special-use / non-public ranges. Default remote fetching is for
53
- # public Internet images only; callers probing trusted internal URLs must
54
- # opt in with allow_private: true.
55
- "0.0.0.0/8", # current network
56
- "10.0.0.0/8", # RFC1918 private-use
57
- "100.64.0.0/10", # RFC6598 carrier-grade NAT
58
- "127.0.0.0/8", # loopback
59
- "169.254.0.0/16", # RFC3927 link-local
60
- "172.16.0.0/12", # RFC1918 private-use
61
- "192.0.0.0/24", # IETF protocol assignments
62
- "192.0.2.0/24", # TEST-NET-1
63
- "192.31.196.0/24", # AS112-v4
64
- "192.52.193.0/24", # AMT
65
- "192.168.0.0/16", # RFC1918 private-use
66
- "192.175.48.0/24", # direct delegation AS112 service
67
- "198.18.0.0/15", # benchmark testing
68
- "198.51.100.0/24", # TEST-NET-2
69
- "203.0.113.0/24", # TEST-NET-3
70
- "224.0.0.0/4", # multicast
71
- "240.0.0.0/4", # reserved / future-use
72
- "255.255.255.255/32", # limited broadcast
73
-
74
- # IPv6 special-use / non-public ranges.
75
- "::/128", # unspecified
76
- "::1/128", # loopback
77
- "::/96", # deprecated IPv4-compatible IPv6
78
- "::ffff:0:0/96", # IPv4-mapped IPv6
79
- "64:ff9b::/96", # well-known NAT64 prefix
80
- "64:ff9b:1::/48", # local-use NAT64 prefix
81
- "100::/64", # discard-only prefix
82
- "2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
83
- "2001:db8::/32", # documentation
84
- "2002::/16", # 6to4
85
- "fc00::/7", # unique local address
86
- "fe80::/10", # link-local unicast
87
- "ff00::/8" # multicast
88
- ].map { |range| IPAddr.new(range) }.freeze
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(["safe-image-remote", ".bin"], binmode: true) do |file|
105
- response = request(
106
- uri,
107
- io: file,
108
- max_bytes: max_bytes,
109
- max_redirects: max_redirects,
110
- open_timeout: open_timeout,
111
- read_timeout: read_timeout,
112
- total_timeout: total_timeout,
113
- started_at: started_at,
114
- allow_private: allow_private,
115
- allowed_ports: allowed_ports,
116
- headers: headers
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 = extension_for(response.fetch(:uri), response.fetch(:content_type))
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(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)
139
- 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|
140
- SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
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?(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)
153
- 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|
154
- SafeImage.animated?(path, max_pixels: max_pixels)
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(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)
159
- 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|
160
- SafeImage.dominant_color(path, max_pixels: max_pixels)
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(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {})
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
- return request(
196
- redirected,
197
- io: io,
198
- max_bytes: max_bytes,
199
- max_redirects: max_redirects - 1,
200
- open_timeout: open_timeout,
201
- read_timeout: read_timeout,
202
- total_timeout: total_timeout,
203
- started_at: started_at,
204
- allow_private: allow_private,
205
- allowed_ports: allowed_ports,
206
- headers: redirect_headers(headers, from: uri, to: redirected)
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
- raise ArgumentError, "remote image URL must be http or https" unless %w[http https].include?(uri.scheme)
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
- raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}" unless content_ext
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, "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
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, ext)
314
- if ext == ".svg"
315
- SvgMetadata.probe(path)
316
- else
317
- SafeImage.probe(path)
318
- end
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