Module: SafeImage::Remote

Defined in:
lib/safe_image/remote.rb

Constant Summary collapse

DEFAULT_MAX_BYTES =
20 * 1024 * 1024
DEFAULT_MAX_REDIRECTS =
3
DEFAULT_OPEN_TIMEOUT =
5
DEFAULT_READ_TIMEOUT =
10
DEFAULT_TOTAL_TIMEOUT =
30
DEFAULT_ALLOWED_PORTS =
[80, 443].freeze
USER_AGENT =
"safe_image/#{VERSION}".freeze
SAFE_CROSS_ORIGIN_REDIRECT_HEADERS =
%w[accept accept-encoding user-agent].freeze
SAFE_INITIAL_REQUEST_HEADERS =
SAFE_CROSS_ORIGIN_REDIRECT_HEADERS
FORBIDDEN_REQUEST_HEADERS =
%w[
  host
  connection
  keep-alive
  proxy-authenticate
  proxy-authorization
  proxy-connection
  te
  trailer
  transfer-encoding
  upgrade
].freeze
CONTENT_TYPE_EXTENSIONS =
{
  "image/jpeg" => ".jpg",
  "image/jpg" => ".jpg",
  "image/png" => ".png",
  "image/gif" => ".gif",
  "image/webp" => ".webp",
  "image/heic" => ".heic",
  "image/heif" => ".heif",
  "image/avif" => ".avif",
  "image/x-icon" => ".ico",
  "image/vnd.microsoft.icon" => ".ico",
  "image/jxl" => ".jxl",
  "image/svg+xml" => ".svg"
}.freeze
EXTENSIONS =
%w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
SIGNATURES =

First-bytes signatures per downloaded extension, checked as soon as the first SIGNATURE_HEAD_BYTES of the body arrive so an obviously mislabeled response is dropped without downloading the rest. Each entry lists alternative candidates; a candidate is a list of [offset, bytes] pairs that must all match. The check rejects only on a definite mismatch of every candidate against fully-available bytes, so it can never reject an image the configured backend could decode — decoders sniff these same magic bytes to pick a loader. SVG has no usable signature and is exempt.

{
  ".jpg" => [[[0, "\xFF\xD8\xFF".b]]],
  ".jpeg" => [[[0, "\xFF\xD8\xFF".b]]],
  ".png" => [[[0, "\x89PNG\r\n\x1A\n".b]]],
  ".gif" => [[[0, "GIF8".b]]],
  ".webp" => [[[0, "RIFF".b], [8, "WEBP".b]]],
  ".ico" => [[[0, "\x00\x00\x01\x00".b]]],
  ".heic" => [[[4, "ftyp".b]]],
  ".heif" => [[[4, "ftyp".b]]],
  ".avif" => [[[4, "ftyp".b]]],
  ".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
}.freeze
SIGNATURE_HEAD_BYTES =

12 bytes covers the longest fixed magic this gate uses today (container JXL) while still rejecting mismatches before meaningful body download.

12
PREFIX_PROBE_INITIAL_BYTES =

The metadata helpers probe the partially-downloaded file at these growing byte thresholds: 64KiB catches normal headers, x4 reaches pathological but valid marker chains without too many decoder attempts.

64 * 1024
PREFIX_PROBE_GROWTH_FACTOR =
4
PREFIX_PROBE_EXTENSIONS =

SVG is excluded from prefix probing: SvgMetadata enforces a total-size cap (MAX_SVG_BYTES) that probing a prefix would bypass, and remote SVGs are small enough that downloading them fully costs little.

(EXTENSIONS - [".svg"]).freeze
CONTINUE_DOWNLOAD =

Sentinel a metadata_fetch block returns when the prefix parsed but its answer could still change with more data (e.g. “not animated”, which a truncated file can report for an animated one).

Object.new.freeze
BLOCKED_IP_RANGES =
[
  # IPv4 special-use / non-public ranges. Default remote fetching is for
  # public Internet images only; callers probing trusted internal URLs must
  # opt in with allow_private: true.
  "0.0.0.0/8", # current network
  "10.0.0.0/8", # RFC1918 private-use
  "100.64.0.0/10", # RFC6598 carrier-grade NAT
  "127.0.0.0/8", # loopback
  "169.254.0.0/16", # RFC3927 link-local
  "172.16.0.0/12", # RFC1918 private-use
  "192.0.0.0/24", # IETF protocol assignments
  "192.0.2.0/24", # TEST-NET-1
  "192.31.196.0/24", # AS112-v4
  "192.52.193.0/24", # AMT
  "192.168.0.0/16", # RFC1918 private-use
  "192.175.48.0/24", # direct delegation AS112 service
  "198.18.0.0/15", # benchmark testing
  "198.51.100.0/24", # TEST-NET-2
  "203.0.113.0/24", # TEST-NET-3
  "224.0.0.0/4", # multicast
  "240.0.0.0/4", # reserved / future-use
  "255.255.255.255/32", # limited broadcast
  # IPv6 special-use / non-public ranges.
  "::/128", # unspecified
  "::1/128", # loopback
  "::/96", # deprecated IPv4-compatible IPv6
  "::ffff:0:0/96", # IPv4-mapped IPv6
  "64:ff9b::/96", # well-known NAT64 prefix
  "64:ff9b:1::/48", # local-use NAT64 prefix
  "100::/64", # discard-only prefix
  "2001::/23", # IETF protocol assignments, incl. Teredo/benchmarking
  "2001:db8::/32", # documentation
  "2002::/16", # 6to4
  "fc00::/7", # unique local address
  "fe80::/10", # link-local unicast
  "ff00::/8" # multicast
].map { |range| IPAddr.new(range) }.freeze

Class Method Summary collapse

Class Method Details

.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) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/safe_image/remote.rb', line 234

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
)
  (
    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, eof|
    answer = SafeImage.animated?(path, max_pixels: max_pixels)
    next CONTINUE_DOWNLOAD if !eof && answer != true

    answer
  end
end

.blocked_ip?(ip) ⇒ Boolean

Returns:

  • (Boolean)


530
531
532
# File 'lib/safe_image/remote.rb', line 530

def blocked_ip?(ip)
  BLOCKED_IP_RANGES.any? { |range| range.include?(ip) }
end

.check_deadline!(started_at, total_timeout) ⇒ Object

Raises:



554
555
556
557
# File 'lib/safe_image/remote.rb', line 554

def check_deadline!(started_at, total_timeout)
  return unless total_timeout
  raise Error, "remote image request timed out" if monotonic_time - started_at > total_timeout
end

.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) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/safe_image/remote.rb', line 264

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
)
  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
  ) { |path| SafeImage.dominant_color(path, max_pixels: max_pixels) }
end

.extension_for(uri, content_type) ⇒ Object



563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/safe_image/remote.rb', line 563

def extension_for(uri, content_type)
  content_ext = CONTENT_TYPE_EXTENSIONS[content_type]
  unless content_ext
    raise UnsupportedFormatError, "remote image has unsupported or missing content type: #{content_type.inspect}"
  end

  ext = File.extname(uri.path).downcase
  if EXTENSIONS.include?(ext)
    normalized_ext = ext == ".jpeg" ? ".jpg" : ext
    normalized_content_ext = content_ext == ".jpeg" ? ".jpg" : content_ext
    unless normalized_ext == normalized_content_ext
      raise UnsupportedFormatError,
            "remote image extension #{ext.inspect} does not match content type #{content_type.inspect}"
    end
    return ext
  end

  content_ext
end

.fetch(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: {}) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/safe_image/remote.rb', line 138

def fetch(
  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: {}
)
  uri = parse_uri(url)
  started_at = monotonic_time

  Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
    response =
      request(
        uri,
        io: file,
        max_bytes: max_bytes,
        max_redirects: max_redirects,
        open_timeout: open_timeout,
        read_timeout: read_timeout,
        total_timeout: total_timeout,
        started_at: started_at,
        allow_private: allow_private,
        allowed_ports: allowed_ports,
        headers: headers
      )
    file.flush

    ext = response.fetch(:ext)
    path = file.path
    if File.extname(path) != ext
      renamed = path.sub(/\.bin\z/, ext)
      FileUtils.mv(path, renamed)
      begin
        validate_downloaded_image!(renamed, ext)
        yield renamed
      ensure
        FileUtils.rm_f(renamed)
      end
    else
      validate_downloaded_image!(path, ext)
      yield path
    end
  end
end

.filtered_headers(headers) ⇒ Object



534
535
536
# File 'lib/safe_image/remote.rb', line 534

def filtered_headers(headers)
  headers.reject { |key, _| FORBIDDEN_REQUEST_HEADERS.include?(key.to_s.downcase) }
end

.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) ⇒ Object



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/safe_image/remote.rb', line 187

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
)
  (
    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, eof|
    result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
    # A truncated file can undercount frames but never overcount, so
    # "animated" is final as soon as it is true; "not animated" is only
    # provable from the complete file. Type, dimensions and orientation
    # come from the header the successful probe just parsed, so they
    # cannot change with more data.
    if animated && result.animated != true && !eof
      CONTINUE_DOWNLOAD
    else
      result
    end
  end
end

.initial_headers(headers) ⇒ Object



538
539
540
# File 'lib/safe_image/remote.rb', line 538

def initial_headers(headers)
  filtered_headers(headers).select { |key, _| SAFE_INITIAL_REQUEST_HEADERS.include?(key.to_s.downcase) }
end

.metadata_fetch(url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute) ⇒ Object

Single-GET download that re-attempts the local metadata probe as bytes arrive (at PREFIX_PROBE_INITIAL_BYTES, then growing by PREFIX_PROBE_GROWTH_FACTOR) and aborts the transfer as soon as the block’s answer is final. The block receives (path, eof) and must return CONTINUE_DOWNLOAD while its answer could still change with more data.

Safety contract: any error from a pre-EOF probe means “not enough bytes yet” — it is swallowed and the download continues, so the complete file always gets the last word with exactly the validation and error behaviour of the full-download path (validate_downloaded_image! plus an un-rescued final probe). A file that never early-exits is handled byte-for-byte like Remote.fetch handles it.



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/safe_image/remote.rb', line 301

def (
  url,
  max_bytes:,
  max_redirects:,
  open_timeout:,
  read_timeout:,
  total_timeout:,
  allow_private:,
  allowed_ports:,
  headers:,
  &compute
)
  uri = parse_uri(url)
  started_at = monotonic_time

  Tempfile.create(%w[safe-image-remote .bin], binmode: true) do |file|
    original_path = file.path
    path = original_path
    ext = nil
    next_probe_at = PREFIX_PROBE_INITIAL_BYTES

    begin
      early =
        catch(:metadata_answer) do
          request(
            uri,
            io: file,
            max_bytes: max_bytes,
            max_redirects: max_redirects,
            open_timeout: open_timeout,
            read_timeout: read_timeout,
            total_timeout: total_timeout,
            started_at: started_at,
            allow_private: allow_private,
            allowed_ports: allowed_ports,
            headers: headers,
            # The extension is known before the body: give the tempfile its
            # final name up front so probes dispatch on the right loader.
            on_headers: ->(response_ext) do
              ext = response_ext
              renamed = original_path.sub(/\.bin\z/, ext)
              FileUtils.mv(original_path, renamed)
              path = renamed
            end,
            on_progress: ->(bytes) do
              next if PREFIX_PROBE_EXTENSIONS.none? { |candidate| candidate == ext }
              next if bytes < next_probe_at

              next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
              file.flush
              answer =
                begin
                  compute.call(path, false)
                rescue StandardError
                  CONTINUE_DOWNLOAD
                end
              throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
            end
          )
          nil
        end

      if early
        early.first
      else
        file.flush
        validate_downloaded_image!(path, ext)
        compute.call(path, true)
      end
    ensure
      FileUtils.rm_f(path) unless path == original_path
    end
  end
end

.monotonic_timeObject



559
560
561
# File 'lib/safe_image/remote.rb', line 559

def monotonic_time
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

.parse_uri(url) ⇒ Object



495
496
497
498
499
500
501
502
503
504
# File 'lib/safe_image/remote.rb', line 495

def parse_uri(url)
  uri = URI.parse(url.to_s)
  if %w[http https].none? { |scheme| scheme == uri.scheme }
    raise ArgumentError, "remote image URL must be http or https"
  end
  raise ArgumentError, "remote image URL must include a host" if uri.host.to_s.empty?
  uri
rescue URI::InvalidURIError => e
  raise ArgumentError, "invalid remote image URL: #{e.message}"
end

.redirect_headers(headers, from:, to:) ⇒ Object



542
543
544
545
546
547
# File 'lib/safe_image/remote.rb', line 542

def redirect_headers(headers, from:, to:)
  headers = filtered_headers(headers)
  return headers if same_origin?(from, to)

  headers.select { |key, _| SAFE_CROSS_ORIGIN_REDIRECT_HEADERS.include?(key.to_s.downcase) }
end

.request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {}, on_headers: nil, on_progress: nil) ⇒ Object

Raises:

  • (ArgumentError)


376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/safe_image/remote.rb', line 376

def request(
  uri,
  io:,
  max_bytes:,
  max_redirects:,
  open_timeout:,
  read_timeout:,
  total_timeout:,
  started_at:,
  allow_private:,
  allowed_ports:,
  headers: {},
  on_headers: nil,
  on_progress: nil
)
  require "net/http"
  raise ArgumentError, "too many redirects" if max_redirects < 0
  check_deadline!(started_at, total_timeout)
  ipaddr = validate_uri!(uri, allow_private: allow_private, allowed_ports: allowed_ports)

  http = Net::HTTP.new(uri.host, uri.port, nil)
  http.ipaddr = ipaddr if ipaddr
  http.use_ssl = uri.scheme == "https"
  http.open_timeout = open_timeout
  http.read_timeout = read_timeout

  request = Net::HTTP::Get.new(uri)
  request["User-Agent"] = USER_AGENT
  request["Accept"] = "image/*,*/*;q=0.1"
  request["Accept-Encoding"] = "identity"
  initial_headers(headers).each { |key, value| request[key.to_s] = value.to_s }

  bytes = 0
  content_type = nil
  ext = nil

  http.request(request) do |response|
    check_deadline!(started_at, total_timeout)

    case response
    when Net::HTTPRedirection
      location = response["location"] or raise Error, "redirect without Location"
      redirected = parse_uri(uri.merge(location).to_s)
      if uri.scheme == "https" && redirected.scheme == "http"
        raise UnsafePathError, "refusing HTTPS to HTTP redirect"
      end
      redirected_response =
        request(
          redirected,
          io: io,
          max_bytes: max_bytes,
          max_redirects: max_redirects - 1,
          open_timeout: open_timeout,
          read_timeout: read_timeout,
          total_timeout: total_timeout,
          started_at: started_at,
          allow_private: allow_private,
          allowed_ports: allowed_ports,
          headers: redirect_headers(headers, from: uri, to: redirected),
          on_headers: on_headers,
          on_progress: on_progress
        )
      return redirected_response
    when Net::HTTPSuccess
      content_length = response["content-length"].to_i
      raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes

      content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
      # Everything the content-type and extension-agreement checks need is
      # in the headers: reject unsupported or mismatched responses before
      # reading a single body byte.
      ext = extension_for(uri, content_type)
      on_headers&.call(ext)

      head = "".b
      head_checked = false
      response.read_body do |chunk|
        check_deadline!(started_at, total_timeout)
        bytes += chunk.bytesize
        raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
        unless head_checked
          head << chunk
          if head.bytesize >= SIGNATURE_HEAD_BYTES
            verify_signature!(ext, head)
            head_checked = true
            head = nil
          end
        end
        io.write(chunk)
        on_progress&.call(bytes)
      end
      verify_signature!(ext, head) if !head_checked && !head.empty?
    else
      raise Error, "remote image request failed: HTTP #{response.code}"
    end
  end

  { uri: uri, content_type: content_type, ext: ext, bytes: bytes }
end

.same_origin?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


549
550
551
552
# File 'lib/safe_image/remote.rb', line 549

def same_origin?(a, b)
  a.scheme.to_s.downcase == b.scheme.to_s.downcase && a.host.to_s.downcase == b.host.to_s.downcase &&
    a.port == b.port
end

.size(url, **kwargs) ⇒ Object



226
227
228
# File 'lib/safe_image/remote.rb', line 226

def size(url, **kwargs)
  info(url, **kwargs).size
end

.type(url, **kwargs) ⇒ Object



230
231
232
# File 'lib/safe_image/remote.rb', line 230

def type(url, **kwargs)
  info(url, **kwargs).type
end

.validate_downloaded_image!(path, _ext) ⇒ Object



583
584
585
586
587
588
589
# File 'lib/safe_image/remote.rb', line 583

def validate_downloaded_image!(path, _ext)
  # Always go back through the public local probe path so configured
  # sandboxing and pixel limits apply uniformly. Remote.fetch itself needs
  # network access; once bytes are on disk, validation must re-enter the
  # normal image-processing boundary.
  SafeImage.probe(path)
end

.validate_uri!(uri, allow_private:, allowed_ports: DEFAULT_ALLOWED_PORTS) ⇒ Object

Raises:



506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/safe_image/remote.rb', line 506

def validate_uri!(uri, allow_private:, allowed_ports: DEFAULT_ALLOWED_PORTS)
  unless allow_private || allowed_ports.nil? || allowed_ports.include?(uri.port)
    raise UnsafePathError, "remote image URL uses a disallowed port"
  end
  return nil if allow_private

  require "resolv"
  resolver = Resolv::DNS.new
  resolver.timeouts = [2, 2]
  addresses = resolver.getaddresses(uri.host).map(&:to_s)
  raise UnsafePathError, "remote image host did not resolve" if addresses.empty?

  addresses.each do |address|
    ip = IPAddr.new(address)
    raise UnsafePathError, "remote image host resolves to a non-public address" if blocked_ip?(ip)
  end

  # Pin the socket to a vetted address so validation and connection cannot
  # observe different DNS answers. Prefer IPv4 first for compatibility with
  # common hosts, but either family is fine because every address above was
  # checked.
  addresses.sort_by { |address| address.include?(":") ? 1 : 0 }.first
end

.verify_signature!(ext, head) ⇒ Object

Rejects a body whose first bytes definitively cannot belong to the format the response claimed. Formats without a fixed signature (SVG) and bytes not yet downloaded are never grounds for rejection.

Raises:



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# File 'lib/safe_image/remote.rb', line 479

def verify_signature!(ext, head)
  candidates = SIGNATURES[ext]
  return unless candidates

  compatible =
    candidates.any? do |candidate|
      candidate.all? do |offset, bytes|
        slice = head.byteslice(offset, bytes.bytesize).to_s
        slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
      end
    end
  return if compatible

  raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
end