metainspector 5.19.0 → 5.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bc16dfdcdd22661ba7bb196d4db8750a5fa4d48d054197982768fa7fac44835
4
- data.tar.gz: ce356b1f0f2511069ff96329e775e3581282d68d395c4dada93a9275e5dd4aa6
3
+ metadata.gz: ba31ea21114c1932ca2dde179a2812f24d429b7420807fec232de65ea1ba824e
4
+ data.tar.gz: 99188cbec093cfd1d22cdfd3d31bf181cf193e937f6c7b23a61cb6ed39ba1ca2
5
5
  SHA512:
6
- metadata.gz: 6a669057baeb8107c8ae6940f4323f60664982fa3c8c74ce4f029b434ebfe227436c743a6b6041acbcddfdc2ea7fb38704a6126d3204b18479a8ca81b2b63a0e
7
- data.tar.gz: 123f1cbb41fd5492307d96e316b17c91fc4f17d523a12f9fd865be49a3de31d8b932ceae6365bc7e1f6f49fd9721f8626eb9a8b5bbcbf9d86b2e972aedaa1544
6
+ metadata.gz: bc39e09f57cadd29f60e6a41556878d807a629e99de37b6b64b3ebf090d32aeca63d49b38af436fa16e5fff29e4cf5e927d99df5b37f35385ea30165a8de55e3
7
+ data.tar.gz: d4a4777631f8c63d238236960148330a3dc7ba0f4ae2ec0b497cc3e03f33a5ca8daca05141dbaaa20a99a54fd933d5a5c9cb7056dd4b84b12c1a515119935574
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # MetaInspector Changelog
2
2
 
3
+ ## [Changes in 5.20.0](https://github.com/jaimeiniesta/metainspector/compare/v5.19.0...v5.20.0)
4
+
5
+ * Security: route image size downloads through Faraday so they respect configured network and redirect controls. Add optional `url_filter:` to validate the initial page URL, HEAD requests, image URLs and every redirect destination before requesting them. Returning `false` or `nil` blocks the request with `MetaInspector::RequestError`; filter exceptions propagate. Only HTTP(S) requests are supported ([GHSA-6vgc-f9v5-8939](https://github.com/jaimeiniesta/metainspector/security/advisories/GHSA-6vgc-f9v5-8939)).
6
+ * Accept `faraday_http_cache` inside `faraday_options`, which used to raise `NameError`. The top-level `faraday_http_cache` option still works but is deprecated: it prints a warning once per process. Passing both raises `ArgumentError` (#373).
7
+
3
8
  ## [Changes in 5.19.0](https://github.com/jaimeiniesta/metainspector/compare/v5.18.1...v5.19.0)
4
9
 
5
10
  * Security: stop modifying the `faraday_options` hash passed to MetaInspector. When the same hash was reused, its `redirect` options were deleted after the first request, so a redirect `callback` used to block private hosts only ran once and later requests followed any redirect ([GHSA-mm2p-6q2v-7ph5](https://github.com/jaimeiniesta/metainspector/security/advisories/GHSA-mm2p-6q2v-7ph5)). Frozen option hashes no longer raise `FrozenError`.
data/README.md CHANGED
@@ -341,8 +341,7 @@ And even customize what to do in between each redirect:
341
341
 
342
342
  ```ruby
343
343
  callback = proc do |previous_response, next_request|
344
-   ip_address = Resolv.getaddress(next_request.url.host)
345
- raise 'Invalid address' if IPAddr.new(ip_address).private?
344
+ puts "Following redirect to #{next_request.url}"
346
345
  end
347
346
 
348
347
  page = MetaInspector.new(url, faraday_options: { redirect: { callback: callback } })
@@ -352,6 +351,63 @@ page = MetaInspector.new(url, faraday_options: { redirect: { callback: callback
352
351
  The `faraday_options[:redirect]` hash is passed to the `FollowRedirects` middleware used by `Faraday`, so that we can use all available options.
353
352
  Check them [here](https://github.com/lostisland/faraday_middleware/blob/main/lib/faraday_middleware/response/follow_redirects.rb#L44).
354
353
 
354
+ ### Filtering request URLs
355
+
356
+ Use `url_filter:` to decide which destinations MetaInspector may request. The filter receives a Ruby `URI` and runs before the initial page request, the optional HEAD request, image size downloads, and every redirect destination. Only HTTP and HTTPS requests are supported.
357
+
358
+ For example, allow HTTPS requests to two known hosts:
359
+
360
+ ```ruby
361
+ allowed_hosts = %w[example.com images.example.com]
362
+
363
+ filter = proc do |uri|
364
+ uri.scheme == 'https' && allowed_hosts.include?(uri.hostname)
365
+ end
366
+
367
+ page = MetaInspector.new('https://example.com', url_filter: filter)
368
+ page.images.largest # Image requests and their redirects use the same filter.
369
+ ```
370
+
371
+ A truthy return value permits the request. Returning `false` or `nil` raises `MetaInspector::RequestError` before contacting the rejected destination. This also applies when an image request is rejected:
372
+
373
+ ```ruby
374
+ begin
375
+ page = MetaInspector.new(url, url_filter: filter)
376
+ page.images.largest
377
+ rescue MetaInspector::RequestError => error
378
+ warn error.message
379
+ end
380
+ ```
381
+
382
+ Exceptions raised by your filter propagate to the caller. The filter is optional: MetaInspector does not block private IP addresses by default. It applies to outgoing requests, including image downloads from HTML passed with `document:`. Reading image URLs with `images.to_a` or `images.owner_suggested` does not download them or run the filter.
383
+
384
+ If your application accepts arbitrary hostnames, this example rejects addresses in private, loopback, link-local and unspecified ranges, including IPv4-mapped IPv6 addresses:
385
+
386
+ ```ruby
387
+ require 'ipaddr'
388
+ require 'resolv'
389
+
390
+ zero_network = IPAddr.new('0.0.0.0/8')
391
+
392
+ filter = proc do |uri|
393
+ addresses = Resolv.getaddresses(uri.hostname)
394
+
395
+ !addresses.empty? && addresses.all? do |address|
396
+ ip = IPAddr.new(address)
397
+ ip = ip.native if ip.ipv4_mapped?
398
+
399
+ !(ip.private? || ip.loopback? || ip.link_local? ||
400
+ ip.to_i.zero? || zero_network.include?(ip))
401
+ end
402
+ end
403
+
404
+ page = MetaInspector.new(url, url_filter: filter)
405
+ ```
406
+
407
+ DNS resolution in this example happens separately from the connection, so it cannot prevent DNS rebinding. Use network egress restrictions as well when processing untrusted URLs. The example covers the listed ranges; applications may need additional address or port restrictions.
408
+
409
+ The existing redirect `callback` keeps its two arguments and runs only on redirects. Move destination validation from that callback to `url_filter:` to cover initial URLs as well as redirects, for both pages and images.
410
+
355
411
  ### Headers
356
412
 
357
413
  By default, the following headers are set:
@@ -447,7 +503,13 @@ You can disable URL normalization by passing the `normalize_url: false` option.
447
503
 
448
504
  ### Image downloading
449
505
 
450
- When you ask for the largest image on the page with `page.images.largest`, it will be determined by its height and width attributes on the HTML markup, and also by downloading a small portion of each image using the [fastimage](https://github.com/sdsykes/fastimage) gem. This is really fast as it doesn't download the entire images, normally just the headers of the image files.
506
+ Image downloading is enabled by default. `page.images.with_size` and `page.images.largest` fetch images with missing HTML dimensions; `page.images.best` also does this when there is no preferred metadata image. The page controls these image URLs, which may point to other hosts.
507
+
508
+ Image requests use Faraday with the page's proxy, TLS, headers and redirect settings, and respect [`url_filter:`](#filtering-request-urls). Downloaded bytes are passed to [fastimage](https://github.com/sdsykes/fastimage) to determine dimensions. Dimensions already present in HTML are preserved without downloading the image.
509
+
510
+ Custom page headers are preserved for images on the same origin (scheme, host and port). Requests to other origins, including image redirects, send only standard image request headers such as `User-Agent` and `Accept`; they do not receive page credentials or arbitrary custom headers.
511
+
512
+ Each size lookup stops at a 256 KiB response body budget or a two-second overall timeout, including redirects. If the dimensions cannot be determined within these limits, they remain `[0, 0]`. Streamed image responses are not stored in the HTTP response cache, and failed image requests are not retried.
451
513
 
452
514
  If you want to disable this, you can specify it like this:
453
515
 
@@ -470,13 +532,15 @@ The limit leaves `images.to_a` unchanged, but can affect `images.largest` and th
470
532
 
471
533
  ### Caching responses
472
534
 
473
- MetaInspector can be configured to use [Faraday::HttpCache](https://github.com/plataformatec/faraday-http-cache) to cache page responses. For that you should pass the `faraday_http_cache` option with at least the `:store` key, for example:
535
+ MetaInspector can be configured to use [Faraday::HttpCache](https://github.com/plataformatec/faraday-http-cache) to cache page responses. For that you should pass the `faraday_http_cache` option inside `faraday_options`, with at least the `:store` key, for example:
474
536
 
475
537
  ```ruby
476
538
  cache = ActiveSupport::Cache.lookup_store(:file_store, '/tmp/cache')
477
- page = MetaInspector.new('http://example.com', faraday_http_cache: { store: cache })
539
+ page = MetaInspector.new('http://example.com', faraday_options: { faraday_http_cache: { store: cache } })
478
540
  ```
479
541
 
542
+ The top-level `faraday_http_cache` option still works, but it is deprecated and prints a warning, once per process. Passing it in both places raises an `ArgumentError`.
543
+
480
544
  ## Exception Handling
481
545
 
482
546
  Web page scraping is tricky, you can expect to find different exceptions during the request of the page or the parsing of its contents. MetaInspector will encapsulate these exceptions on these main errors:
@@ -15,7 +15,10 @@ module MetaInspector
15
15
  # * normalize_url: true by default
16
16
  # * download_images: fetch missing image dimensions. Defaults to true
17
17
  # * max_image_downloads: maximum image size lookups, or nil for unlimited
18
- # * faraday_options: an optional hash of options to pass to Faraday on the request
18
+ # * url_filter: callable receiving a URI before each request; false/nil rejects it
19
+ # * faraday_options: an optional hash of options to pass to Faraday on the request.
20
+ # Its faraday_http_cache key configures response caching with Faraday::HttpCache
21
+ # * faraday_http_cache: deprecated, use faraday_options: { faraday_http_cache: ... }
19
22
  def initialize(initial_url, options = {})
20
23
  options = defaults.merge(options)
21
24
  @max_image_downloads = options[:max_image_downloads]
@@ -37,8 +40,10 @@ module MetaInspector
37
40
  @normalize_url = options[:normalize_url]
38
41
  @faraday_options = options[:faraday_options]
39
42
  @faraday_http_cache = options[:faraday_http_cache]
43
+ @http_session = MetaInspector::HttpSession.new(options)
40
44
  @url = MetaInspector::URL.new(initial_url, normalize: @normalize_url)
41
- @request = MetaInspector::Request.new(@url, allow_redirections: @allow_redirections,
45
+ @request = MetaInspector::Request.new(@url, http_session: @http_session,
46
+ allow_redirections: @allow_redirections,
42
47
  head_first: options[:head_first],
43
48
  allow_non_html_content: @allow_non_html_content,
44
49
  connection_timeout: @connection_timeout,
@@ -49,7 +54,8 @@ module MetaInspector
49
54
  faraday_options: @faraday_options,
50
55
  faraday_http_cache: @faraday_http_cache) unless @document
51
56
  @parser = MetaInspector::Parser.new(self, download_images: @download_images,
52
- max_image_downloads: @max_image_downloads)
57
+ max_image_downloads: @max_image_downloads,
58
+ image_downloader: MetaInspector::ImageDownloader.new(@http_session, @url, options))
53
59
  end
54
60
 
55
61
  extend Forwardable
@@ -0,0 +1,65 @@
1
+ require 'http/cookie'
2
+ require 'faraday'
3
+ require 'faraday-cookie_jar'
4
+ require 'faraday-http-cache'
5
+ require 'faraday/encoding'
6
+ require 'faraday/follow_redirects'
7
+ require 'faraday/gzip'
8
+ require 'faraday/retry'
9
+ require_relative 'url_policy'
10
+
11
+ module MetaInspector
12
+ # Builds page and binary image transports from the same caller configuration.
13
+ class HttpSession
14
+ def initialize(options = {})
15
+ @allow_redirections = options[:allow_redirections]
16
+ @retries = options[:retries]
17
+ @headers = options[:headers]
18
+ @url_filter = options[:url_filter]
19
+ if !@url_filter.nil? && !@url_filter.respond_to?(:call)
20
+ raise ArgumentError, 'url_filter must respond to call'
21
+ end
22
+ @cache = cache_option(options)
23
+ @faraday_options = (options[:faraday_options] || {}).except(:faraday_http_cache)
24
+ end
25
+
26
+ def build(url, image: false, origin: nil)
27
+ URLPolicy.validate!(url)
28
+ faraday_options = @faraday_options.merge(url: url)
29
+ redirects = (faraday_options.delete(:redirect) || {}).dup
30
+ Faraday.new(faraday_options) do |faraday|
31
+ unless image
32
+ faraday.request :retry, max: @retries
33
+ faraday.request :gzip
34
+ end
35
+ if @allow_redirections
36
+ redirects[:limit] ||= 10
37
+ faraday.use Faraday::FollowRedirects::Middleware, **redirects
38
+ faraday.use :cookie_jar
39
+ end
40
+ faraday.use URLPolicy, @url_filter, image ? origin : nil
41
+ if !image && @cache.is_a?(Hash)
42
+ cache_options = @cache.dup
43
+ cache_options[:serializer] ||= Marshal
44
+ faraday.use Faraday::HttpCache, **cache_options
45
+ end
46
+ faraday.headers.merge!(@headers || {})
47
+ faraday.response :encoding unless image
48
+ faraday.adapter :net_http
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def cache_option(options)
55
+ nested = (options[:faraday_options] || {})[:faraday_http_cache]
56
+ top_level = options[:faraday_http_cache]
57
+ return nested if top_level.nil?
58
+ unless nested.nil?
59
+ raise ArgumentError, 'Pass faraday_http_cache inside faraday_options or as a top-level option, not both'
60
+ end
61
+ Request.warn_faraday_http_cache_deprecation
62
+ top_level
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,53 @@
1
+ require 'stringio'
2
+ require 'fastimage'
3
+ require 'timeout'
4
+
5
+ module MetaInspector
6
+ class ImageDownloader
7
+ MAX_BYTES = 256 * 1024
8
+ TIMEOUT = 2
9
+
10
+ def initialize(session, origin, options = {})
11
+ @session = session
12
+ @origin = origin
13
+ @connection_timeout = [options[:connection_timeout] || TIMEOUT, TIMEOUT].min
14
+ @read_timeout = [options[:read_timeout] || TIMEOUT, TIMEOUT].min
15
+ end
16
+
17
+ def size(url)
18
+ bytes = ''.b
19
+ received = 0
20
+ status = nil
21
+ stop = Object.new
22
+ Timeout.timeout(TIMEOUT) do
23
+ session = @session.build(url, image: true, origin: @origin.url)
24
+ catch(stop) do
25
+ session.get do |request|
26
+ request.headers['Range'] = "bytes=0-#{MAX_BYTES - 1}"
27
+ request.headers['Accept-Encoding'] = 'identity'
28
+ request.options.open_timeout = @connection_timeout
29
+ request.options.timeout = @read_timeout
30
+ request.options.on_data = proc do |chunk, _total, env|
31
+ status = env.status
32
+ remaining = MAX_BYTES - received
33
+ received += chunk.bytesize
34
+ # Count redirect bodies too, across the entire lookup.
35
+ if (200..299).cover?(status)
36
+ bytes << chunk.b.byteslice(0, remaining)
37
+ elsif !(300..399).cover?(status)
38
+ throw stop
39
+ end
40
+ throw stop if received >= MAX_BYTES
41
+ end
42
+ end
43
+ end
44
+ end
45
+ FastImage.size(StringIO.new(bytes)) if status && (200..299).cover?(status)
46
+ rescue URLPolicy::Failure => failure
47
+ raise failure.original
48
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError,
49
+ Faraday::FollowRedirects::RedirectLimitReached, URI::InvalidURIError, Timeout::Error
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -15,7 +15,8 @@ module MetaInspector
15
15
  @links_parser = MetaInspector::Parsers::LinksParser.new(self)
16
16
  @download_images = options[:download_images]
17
17
  @images_parser = MetaInspector::Parsers::ImagesParser.new(self, download_images: @download_images,
18
- max_image_downloads: options[:max_image_downloads])
18
+ max_image_downloads: options[:max_image_downloads],
19
+ image_downloader: options[:image_downloader])
19
20
  @texts_parser = MetaInspector::Parsers::TextsParser.new(self)
20
21
 
21
22
  parsed # parse early so we can fail early
@@ -1,4 +1,4 @@
1
- require 'fastimage'
1
+ require_relative '../image_downloader'
2
2
 
3
3
  module MetaInspector
4
4
  module Parsers
@@ -9,6 +9,7 @@ module MetaInspector
9
9
  include Enumerable
10
10
 
11
11
  def initialize(main_parser, options = {})
12
+ @image_downloader = options[:image_downloader]
12
13
  @download_images = options[:download_images]
13
14
  @max_image_downloads = options[:max_image_downloads]
14
15
  super(main_parser)
@@ -48,7 +49,7 @@ module MetaInspector
48
49
  if width.nil? || height.nil?
49
50
  if @download_images && (@max_image_downloads.nil? || download_attempts < @max_image_downloads)
50
51
  download_attempts += 1
51
- width, height = FastImage.size(url)
52
+ width, height = @image_downloader.size(url)
52
53
  else
53
54
  width, height = [0, 0]
54
55
  end
@@ -1,12 +1,5 @@
1
- require 'http/cookie'
2
- require 'faraday'
3
- require 'faraday-cookie_jar'
4
- require 'faraday-http-cache'
5
- require 'faraday/encoding'
6
- require 'faraday/follow_redirects'
7
- require 'faraday/gzip'
8
- require 'faraday/retry'
9
1
  require 'timeout'
2
+ require_relative 'http_session'
10
3
 
11
4
  module MetaInspector
12
5
 
@@ -15,18 +8,15 @@ module MetaInspector
15
8
  def initialize(initial_url, options = {})
16
9
  @url = initial_url
17
10
 
18
- fail MetaInspector::RequestError.new('URL must be HTTP') unless @url.url =~ /http[s]?:\/\//i
11
+ URLPolicy.validate!(@url.url)
19
12
 
20
13
  @allow_redirections = options[:allow_redirections]
21
14
  @head_first = options[:head_first]
22
15
  @allow_non_html_content = options[:allow_non_html_content]
23
16
  @connection_timeout = options[:connection_timeout]
24
17
  @read_timeout = options[:read_timeout]
25
- @retries = options[:retries]
26
18
  @encoding = options[:encoding]
27
- @headers = options[:headers]
28
- @faraday_options = options[:faraday_options] || {}
29
- @faraday_http_cache = options[:faraday_http_cache]
19
+ @http_session = options[:http_session] || HttpSession.new(options)
30
20
 
31
21
  response # request early so we can fail early
32
22
  end
@@ -49,6 +39,8 @@ module MetaInspector
49
39
 
50
40
  def response
51
41
  @response ||= fetch
42
+ rescue URLPolicy::Failure => failure
43
+ raise failure.original
52
44
  rescue Faraday::TimeoutError => e
53
45
  raise MetaInspector::TimeoutError.new(e)
54
46
  rescue Faraday::ConnectionFailed, Faraday::SSLError, URI::InvalidURIError, Faraday::FollowRedirects::RedirectLimitReached => e
@@ -57,34 +49,20 @@ module MetaInspector
57
49
 
58
50
  private
59
51
 
52
+ # Once per process: apps fetch many pages with the same options, and a warning
53
+ # per request would flood their logs
54
+ def self.warn_faraday_http_cache_deprecation
55
+ return if @faraday_http_cache_deprecation_warned
56
+
57
+ @faraday_http_cache_deprecation_warned = true
58
+ warn 'DEPRECATION WARNING: the top-level faraday_http_cache option is deprecated and will be removed ' \
59
+ 'in the next major version. Pass it inside faraday_options instead: ' \
60
+ 'faraday_options: { faraday_http_cache: { store: cache } }'
61
+ end
62
+
60
63
  def fetch
61
64
  Timeout::timeout(fatal_timeout) do
62
- # Work on copies: the caller may reuse or freeze the hashes it passed in, and
63
- # losing :redirect would drop a callback that blocks redirects to private hosts
64
- faraday_options = @faraday_options.merge(:url => url)
65
- follow_redirects_options = (faraday_options.delete(:redirect) || {}).dup
66
-
67
- session = Faraday.new(faraday_options) do |faraday|
68
- faraday.request :retry, max: @retries
69
-
70
- faraday.request :gzip
71
-
72
- if @allow_redirections
73
- follow_redirects_options[:limit] ||= 10
74
- faraday.use Faraday::FollowRedirects::Middleware, **follow_redirects_options
75
- faraday.use :cookie_jar
76
- end
77
-
78
- if @faraday_http_cache.is_a?(Hash)
79
- cache_options = @faraday_http_cache.dup
80
- cache_options[:serializer] ||= Marshal
81
- faraday.use Faraday::HttpCache, **cache_options
82
- end
83
-
84
- faraday.headers.merge!(@headers || {})
85
- faraday.response :encoding
86
- faraday.adapter :net_http
87
- end
65
+ session = @http_session.build(url)
88
66
 
89
67
  check_head_content_type(session) if @head_first
90
68
  response = perform_request(session, :get)
@@ -0,0 +1,58 @@
1
+ module MetaInspector
2
+ # Runs inside redirect handling, before cache access or the network adapter.
3
+ class URLPolicy < Faraday::Middleware
4
+ class Failure < StandardError
5
+ attr_reader :original
6
+
7
+ def initialize(original)
8
+ @original = original
9
+ super('URL policy failed')
10
+ end
11
+ end
12
+
13
+ IMAGE_HEADERS = %w[user-agent accept accept-language accept-encoding range].freeze
14
+
15
+ def self.validate!(value)
16
+ uri = URI.parse(value.to_s)
17
+ unless uri.is_a?(URI::HTTP) && uri.hostname && !uri.hostname.empty?
18
+ raise MetaInspector::RequestError, 'URL must be HTTP or HTTPS'
19
+ end
20
+ uri
21
+ rescue URI::InvalidURIError => error
22
+ raise MetaInspector::RequestError.new(error)
23
+ end
24
+
25
+ def initialize(app, filter, image_origin = nil)
26
+ super(app)
27
+ @filter = filter
28
+ @image_origin = image_origin && self.class.validate!(image_origin)
29
+ end
30
+
31
+ def call(env)
32
+ uri = self.class.validate!(env.url)
33
+ if @image_origin && origin(uri) != origin(@image_origin)
34
+ env.request_headers.delete_if { |key, _| !IMAGE_HEADERS.include?(key.downcase) }
35
+ end
36
+ check_filter(uri) if @filter
37
+ @app.call(env)
38
+ end
39
+
40
+ private
41
+
42
+ def origin(uri)
43
+ [uri.scheme.downcase, uri.hostname.downcase, uri.port]
44
+ end
45
+
46
+ def check_filter(uri)
47
+ # Parsing detaches the strings too: URI#dup alone shares its hostname.
48
+ begin
49
+ allowed = @filter.call(URI.parse(uri.to_s).freeze)
50
+ rescue StandardError => error
51
+ raise Failure.new(error)
52
+ end
53
+ unless allowed
54
+ raise Failure.new(MetaInspector::RequestError.new('Request blocked by url_filter'))
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,3 +1,3 @@
1
1
  module MetaInspector
2
- VERSION = '5.19.0'
2
+ VERSION = '5.20.0'
3
3
  end
@@ -0,0 +1,114 @@
1
+ require 'spec_helper'
2
+ require 'socket'
3
+
4
+ RSpec.describe 'bounded image downloads' do
5
+ let(:png) { "\x89PNG\r\n\x1a\n".b + [13].pack('N') + 'IHDR' + [37, 29].pack('NN') + "\x08\x02\x00\x00\x00".b }
6
+
7
+ it 'inherits proxy and TLS options without modifying caller-owned hashes' do
8
+ image = 'https://images.example/image.png'
9
+ stub_request(:get, image).to_return(body: png)
10
+ ssl = { verify: false }.freeze
11
+ redirect = { limit: 2 }.freeze
12
+ options = { proxy: 'http://proxy.example:3128', ssl: ssl, redirect: redirect }.freeze
13
+ expect(Faraday).to receive(:new).with(hash_including(proxy: options[:proxy], ssl: ssl)).and_call_original
14
+ page = MetaInspector.new('https://page.example/', document: %(<img src="#{image}">), faraday_options: options)
15
+ expect(page.images.with_size).to eq([[image, 37, 29]])
16
+ expect(options).to eq(proxy: 'http://proxy.example:3128', ssl: ssl, redirect: redirect)
17
+ end
18
+
19
+ it 'does not cache streamed image responses' do
20
+ cache = Class.new(Hash) do
21
+ def read(key); self[key]; end
22
+ def write(key, value); self[key] = value; end
23
+ end.new
24
+ stub_request(:get, 'https://images.example/image.png').to_return(body: png,
25
+ headers: { 'Cache-Control' => 'public, max-age=3600' })
26
+ 2.times do
27
+ page = MetaInspector.new('https://page.example/', document: '<img src="https://images.example/image.png">',
28
+ faraday_options: { faraday_http_cache: { store: cache } })
29
+ expect(page.images.with_size.first.last(2)).to eq([37, 29])
30
+ end
31
+ expect(cache).to be_empty
32
+ expect(a_request(:get, 'https://images.example/image.png')).to have_been_made.twice
33
+ end
34
+
35
+ context 'with a real HTTP server' do
36
+ around do |example|
37
+ @server = TCPServer.new('127.0.0.1', 0)
38
+ @url = "http://127.0.0.1:#{@server.addr[1]}/image"
39
+ # WebMock buffers real responses before yielding to Net::HTTP's block.
40
+ # Disable its adapter here to exercise actual incremental reads.
41
+ WebMock.disable!
42
+ example.run
43
+ ensure
44
+ @server&.close
45
+ @worker&.kill
46
+ @worker&.join
47
+ WebMock.enable!
48
+ WebMock.disable_net_connect!
49
+ end
50
+
51
+ def serve(&block)
52
+ @worker = Thread.new do
53
+ socket = @server.accept
54
+ while (line = socket.gets) && line != "\r\n"; end
55
+ block.call(socket)
56
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
57
+ # Reaching the byte/time limit closes the peer connection.
58
+ ensure
59
+ socket&.close
60
+ end
61
+ end
62
+
63
+ it 'bounds binary IO and stops a server that ignores Range before reading its full body' do
64
+ sent = 0
65
+ serve do |socket|
66
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 4194304\r\nContent-Type: image/png\r\nConnection: close\r\n\r\n")
67
+ socket.write(png)
68
+ 1024.times do
69
+ socket.write('x' * 4096)
70
+ sent += 4096
71
+ sleep 0.002
72
+ end
73
+ end
74
+ expect(FastImage).to receive(:size).with(an_instance_of(StringIO)) do |io|
75
+ expect(io.string.bytesize).to eq(256 * 1024)
76
+ expect(io.string.encoding).to eq(Encoding::BINARY)
77
+ [37, 29]
78
+ end
79
+ page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
80
+ expect(page.images.with_size).to eq([[@url, 37, 29]])
81
+ expect(sent).to be < 4 * 1024 * 1024
82
+ end
83
+
84
+ it 'also stops oversized redirect bodies before following their location' do
85
+ sent = 0
86
+ serve do |socket|
87
+ socket.write("HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 4194304\r\nConnection: close\r\n\r\n")
88
+ 1024.times do
89
+ socket.write('x' * 4096)
90
+ sent += 4096
91
+ sleep 0.0005
92
+ end
93
+ end
94
+ page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
95
+ expect(page.images.with_size).to eq([[@url, 0, 0]])
96
+ expect(sent).to be < 4 * 1024 * 1024
97
+ end
98
+
99
+ it 'bounds the entire image request even while response data keeps arriving' do
100
+ serve do |socket|
101
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n")
102
+ loop do
103
+ socket.write('x')
104
+ sleep 0.02
105
+ end
106
+ end
107
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
109
+ expect(page.images.with_size).to eq([[@url, 0, 0]])
110
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
111
+ expect(elapsed).to be < 2.8
112
+ end
113
+ end
114
+ end
@@ -5,14 +5,65 @@ describe MetaInspector do
5
5
  expect(MetaInspector.new('http://example.com').class).to eq(MetaInspector::Document)
6
6
  end
7
7
 
8
- it "cache request" do
9
- # Creates a memory cache (a Hash that responds to #read, #write and #delete)
10
- cache = Hash.new
11
- def cache.read(k) self[k]; end
12
- def cache.write(k, v) self[k] = v; end
8
+ describe "response caching" do
9
+ # A memory cache (a Hash that responds to #read and #write)
10
+ let(:cache) do
11
+ store = Hash.new
12
+ def store.read(k) self[k]; end
13
+ def store.write(k, v) self[k] = v; end
14
+ store
15
+ end
13
16
 
14
- MetaInspector.new('http://example.com', faraday_http_cache: { store: cache })
17
+ # The deprecation warning prints once per process, so each example starts as a fresh process would
18
+ before { MetaInspector::Request.instance_variable_set(:@faraday_http_cache_deprecation_warned, nil) }
15
19
 
16
- expect(cache.keys).not_to be_empty
20
+ it "caches responses with faraday_options[:faraday_http_cache]" do
21
+ MetaInspector.new('http://example.com', faraday_options: { faraday_http_cache: { store: cache } })
22
+
23
+ expect(cache.keys).not_to be_empty
24
+ end
25
+
26
+ it "does not warn when the cache is configured inside faraday_options" do
27
+ expect do
28
+ MetaInspector.new('http://example.com', faraday_options: { faraday_http_cache: { store: cache } })
29
+ end.not_to output.to_stderr
30
+ end
31
+
32
+ it "leaves the faraday_options hash it was given unchanged" do
33
+ cache_options = { store: cache }
34
+ faraday_options = { faraday_http_cache: cache_options }
35
+
36
+ MetaInspector.new('http://example.com', faraday_options: faraday_options)
37
+
38
+ expect(faraday_options).to eq({ faraday_http_cache: { store: cache } })
39
+ expect(cache_options).to eq({ store: cache })
40
+ end
41
+
42
+ it "still caches responses with the deprecated top-level faraday_http_cache option" do
43
+ expect do
44
+ MetaInspector.new('http://example.com', faraday_http_cache: { store: cache })
45
+ end.to output.to_stderr
46
+
47
+ expect(cache.keys).not_to be_empty
48
+ end
49
+
50
+ it "warns that the top-level faraday_http_cache option is deprecated" do
51
+ expect do
52
+ MetaInspector.new('http://example.com', faraday_http_cache: { store: cache })
53
+ end.to output(/DEPRECATION.*faraday_http_cache.*faraday_options/).to_stderr
54
+ end
55
+
56
+ it "prints the deprecation warning only once per process" do
57
+ expect do
58
+ 3.times { MetaInspector.new('http://example.com', faraday_http_cache: { store: cache }) }
59
+ end.to output(/\ADEPRECATION WARNING: [^\n]*\n\z/).to_stderr
60
+ end
61
+
62
+ it "raises ArgumentError when the cache is configured in both places" do
63
+ expect do
64
+ MetaInspector.new('http://example.com', faraday_http_cache: { store: cache },
65
+ faraday_options: { faraday_http_cache: { store: cache } })
66
+ end.to raise_error(ArgumentError, /faraday_http_cache/)
67
+ end
17
68
  end
18
69
  end
data/spec/request_spec.rb CHANGED
@@ -91,7 +91,7 @@ describe MetaInspector::Request do
91
91
  def cache.write(k, v) self[k] = v; end
92
92
  cache_options = { store: cache }
93
93
 
94
- MetaInspector::Request.new(url('http://example.com'), faraday_http_cache: cache_options)
94
+ MetaInspector::Request.new(url('http://example.com'), faraday_options: { faraday_http_cache: cache_options })
95
95
 
96
96
  expect(cache_options).to eq({ store: cache })
97
97
  end
@@ -0,0 +1,206 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'outbound URL policy' do
4
+ let(:url) { 'https://public.example/page' }
5
+ let(:image_url) { 'https://public.example/image.png' }
6
+ let(:html) { '<img src="/image.png">' }
7
+ let(:png) { "\x89PNG\r\n\x1a\n".b + [13].pack('N') + 'IHDR' + [37, 29].pack('NN') + "\x08\x02\x00\x00\x00".b }
8
+
9
+ before do
10
+ stub_request(:get, url).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
11
+ stub_request(:get, image_url).to_return(body: png, headers: { 'Content-Type' => 'image/png; charset=UTF-8' })
12
+ end
13
+
14
+ [false, nil].each do |result|
15
+ it "blocks the initial GET when the filter returns #{result.inspect}" do
16
+ expect { MetaInspector.new(url, url_filter: ->(_) { result }) }.to raise_error(MetaInspector::RequestError)
17
+ expect(a_request(:get, url)).not_to have_been_made
18
+ end
19
+ end
20
+
21
+ it 'passes a standard Ruby URI and permits truthy results' do
22
+ seen = []
23
+ page = MetaInspector.new(url, url_filter: ->(uri) { seen << uri; :allowed })
24
+ expect(page.images.with_size).to eq([[image_url, 37, 29]])
25
+ expect(seen.map(&:to_s)).to eq([url, image_url])
26
+ expect(seen).to all(be_a(URI::HTTP))
27
+ end
28
+
29
+ it 'does not permit mutations of the filter URI to change the request host' do
30
+ filter = ->(uri) { uri.host.replace('internal.example'); true }
31
+ expect(MetaInspector.new(url, url_filter: filter).url).to eq(url)
32
+ expect(a_request(:get, 'https://internal.example/page')).not_to have_been_made
33
+ end
34
+
35
+ [RuntimeError, Faraday::TimeoutError, Faraday::ConnectionFailed, URI::InvalidURIError, Timeout::Error].each do |error_class|
36
+ [:page, :image].each do |target|
37
+ it "preserves a filter's #{error_class} on a #{target}" do
38
+ error = error_class.new('policy failed')
39
+ filter = ->(_) { raise error }
40
+ expect do
41
+ if target == :page
42
+ MetaInspector.new(url, url_filter: filter)
43
+ else
44
+ MetaInspector.new(url, document: html, url_filter: filter).images.with_size
45
+ end
46
+ end.to raise_error { |actual| expect(actual).to equal(error) }
47
+ end
48
+ end
49
+ end
50
+
51
+ it 'checks HEAD and checks GET again after HEAD' do
52
+ stub_request(:head, url).to_return(headers: { 'Content-Type' => 'text/html' })
53
+ calls = 0
54
+ expect do
55
+ MetaInspector.new(url, head_first: true, url_filter: ->(_) { calls += 1; calls == 1 })
56
+ end.to raise_error(MetaInspector::RequestError)
57
+ expect(a_request(:head, url)).to have_been_made.once
58
+ expect(a_request(:get, url)).not_to have_been_made
59
+ end
60
+
61
+ it 'blocks HEAD before making a request' do
62
+ expect { MetaInspector.new(url, head_first: true, url_filter: ->(_) { false }) }.to raise_error(MetaInspector::RequestError)
63
+ expect(a_request(:head, url)).not_to have_been_made
64
+ end
65
+
66
+ [:head, :get].each do |method|
67
+ it "checks every #{method} redirect after the existing redirects-only callback" do
68
+ urls = [url, 'https://public.example/next', 'https://internal.example/private']
69
+ urls.each_cons(2) { |from, to| stub_request(method, from).to_return(status: 302, headers: { 'Location' => to }) }
70
+ seen = []
71
+ redirects = []
72
+ callback = ->(previous, following) { redirects << [previous.url.to_s, following.url.to_s] }
73
+ filter = ->(uri) { seen << uri.to_s; uri.host == 'public.example' }
74
+ expect do
75
+ MetaInspector.new(url, head_first: method == :head, url_filter: filter,
76
+ faraday_options: { redirect: { callback: callback }.freeze }.freeze)
77
+ end.to raise_error(MetaInspector::RequestError)
78
+ expect(seen).to eq(urls)
79
+ expect(redirects).to eq(urls.each_cons(2).to_a)
80
+ expect(a_request(method, urls.last)).not_to have_been_made
81
+ end
82
+ end
83
+
84
+ it 'validates redirect destinations changed by the existing callback' do
85
+ stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/next' })
86
+ callback = ->(_, following) { following.url.host = 'internal.example' }
87
+ expect do
88
+ MetaInspector.new(url, url_filter: ->(uri) { uri.host == 'public.example' }, faraday_options: { redirect: { callback: callback } })
89
+ end.to raise_error(MetaInspector::RequestError)
90
+ expect(a_request(:get, 'https://internal.example/next')).not_to have_been_made
91
+ end
92
+
93
+ it 'runs policy before a cached response can be reused' do
94
+ cache = Class.new(Hash) do
95
+ def read(key); self[key]; end
96
+ def write(key, value); self[key] = value; end
97
+ end.new
98
+ stub_request(:get, url).to_return(body: html, headers: { 'Cache-Control' => 'public, max-age=3600' })
99
+ permitted = true
100
+ options = { url_filter: ->(_) { permitted }, faraday_options: { faraday_http_cache: { store: cache } } }
101
+ MetaInspector.new(url, options)
102
+ expect(cache).not_to be_empty
103
+ permitted = false
104
+ expect { MetaInspector.new(url, options) }.to raise_error(MetaInspector::RequestError)
105
+ expect(a_request(:get, url)).to have_been_made.once
106
+ end
107
+
108
+ it 'blocks images from inline HTML before downloading' do
109
+ page = MetaInspector.new(url, document: html, url_filter: ->(_) { false })
110
+ expect { page.images.with_size }.to raise_error(MetaInspector::RequestError)
111
+ expect(a_request(:get, url)).not_to have_been_made
112
+ expect(a_request(:get, image_url)).not_to have_been_made
113
+ end
114
+
115
+ it 'checks every image redirect' do
116
+ stub_request(:get, image_url).to_return(status: 302, headers: { 'Location' => '/second.png' })
117
+ stub_request(:get, 'https://public.example/second.png').to_return(status: 302, headers: { 'Location' => 'http://internal.example/private' })
118
+ page = MetaInspector.new(url, document: html, url_filter: ->(uri) { uri.host == 'public.example' })
119
+ expect { page.images.with_size }.to raise_error(MetaInspector::RequestError)
120
+ expect(a_request(:get, 'http://internal.example/private')).not_to have_been_made
121
+ end
122
+
123
+ ['file:///etc/passwd', 'ftp://files.example/image', 'data:image/png;base64,AAAA', 'ftp://files.example/http://public.example/'].each do |other|
124
+ it "rejects non-HTTP network URL #{other}" do
125
+ expect { MetaInspector.new(other) }.to raise_error(MetaInspector::RequestError)
126
+ page = MetaInspector.new(url, document: %(<img src="#{other}">))
127
+ expect { page.images.with_size }.to raise_error(MetaInspector::RequestError)
128
+ end
129
+ end
130
+
131
+ it 'rejects non-HTTP redirect schemes' do
132
+ stub_request(:get, url).to_return(status: 302, headers: { 'Location' => 'file:///etc/passwd' })
133
+ expect { MetaInspector.new(url) }.to raise_error(MetaInspector::RequestError)
134
+ end
135
+
136
+ it 'uses the page headers and redirects-only callback for images' do
137
+ headers = { 'Authorization' => 'Bearer test', 'User-Agent' => 'Policy app' }.freeze
138
+ stub_request(:get, image_url).with(headers: headers).to_return(status: 302, headers: { 'Location' => '/final.png' })
139
+ stub_request(:get, 'https://public.example/final.png').with(headers: headers).to_return(body: png)
140
+ redirects = []
141
+ page = MetaInspector.new(url, document: html, headers: headers,
142
+ faraday_options: { redirect: { callback: ->(old, following) { redirects << [old.url.to_s, following.url.to_s] } } })
143
+ expect(page.images.with_size).to eq([[image_url, 37, 29]])
144
+ expect(redirects).to eq([[image_url, 'https://public.example/final.png']])
145
+ end
146
+
147
+ it 'does not follow image redirects when disabled' do
148
+ stub_request(:get, image_url).to_return(status: 302, headers: { 'Location' => '/final.png' })
149
+ page = MetaInspector.new(url, document: html, allow_redirections: false)
150
+ expect(page.images.with_size).to eq([[image_url, 0, 0]])
151
+ expect(a_request(:get, 'https://public.example/final.png')).not_to have_been_made
152
+ end
153
+
154
+ it 'uses binary IO rather than a URL for image parsing' do
155
+ expect(FastImage).to receive(:size).with(an_instance_of(StringIO)).and_call_original
156
+ expect(MetaInspector.new(url, document: html).images.with_size).to eq([[image_url, 37, 29]])
157
+ end
158
+
159
+ [:to_timeout, :connection_failure, :unparseable].each do |failure|
160
+ it "returns zero dimensions for image #{failure}" do
161
+ request = stub_request(:get, image_url)
162
+ case failure
163
+ when :to_timeout then request.to_timeout
164
+ when :connection_failure then request.to_raise(Faraday::ConnectionFailed)
165
+ when :unparseable then request.to_return(body: 'not an image')
166
+ end
167
+ expect(MetaInspector.new(url, document: html).images.with_size).to eq([[image_url, 0, 0]])
168
+ expect(request).to have_been_requested.once
169
+ end
170
+ end
171
+ it 'rejects a non-callable filter even for inline HTML' do
172
+ expect { MetaInspector.new(url, document: html, url_filter: false) }.to raise_error(ArgumentError, /url_filter/)
173
+ end
174
+
175
+ [
176
+ 'https://other.example/image.png',
177
+ 'http://public.example/image.png',
178
+ 'https://public.example:8443/image.png'
179
+ ].each do |external|
180
+ it "does not send custom page headers to a different image origin #{external}" do
181
+ secret_headers = { 'Authorization' => 'Bearer secret', 'Cookie' => 'session=secret', 'X-Api-Key' => 'secret' }
182
+ stub_request(:get, external).with do |request|
183
+ expect(request.headers.keys).not_to include('Authorization', 'Cookie', 'X-Api-Key')
184
+ true
185
+ end.to_return(body: png)
186
+ page = MetaInspector.new(url, document: %(<img src="#{external}">),
187
+ headers: secret_headers.merge('User-Agent' => 'Policy app'),
188
+ faraday_options: { headers: { 'X-Faraday-Secret' => 'secret' } })
189
+ expect(page.images.with_size).to eq([[external, 37, 29]])
190
+ expect(a_request(:get, external).with { |r| r.headers.key?('X-Faraday-Secret') }).not_to have_been_made
191
+ end
192
+ end
193
+
194
+ it 'retains custom headers on the page origin and strips them on image redirects' do
195
+ secrets = { 'Authorization' => 'Bearer secret', 'Cookie' => 'session=secret', 'X-Api-Key' => 'secret' }
196
+ external = 'https://other.example/image.png'
197
+ stub_request(:get, image_url).with(headers: secrets).to_return(status: 302, headers: { 'Location' => external })
198
+ stub_request(:get, external).with do |request|
199
+ expect(request.headers.keys).not_to include('Authorization', 'Cookie', 'X-Api-Key')
200
+ true
201
+ end.to_return(body: png)
202
+ page = MetaInspector.new(url, document: html, headers: secrets)
203
+ expect(page.images.with_size).to eq([[image_url, 37, 29]])
204
+ end
205
+
206
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metainspector
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.19.0
4
+ version: 5.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaime Iniesta
@@ -326,6 +326,8 @@ files:
326
326
  - lib/meta_inspector.rb
327
327
  - lib/meta_inspector/document.rb
328
328
  - lib/meta_inspector/errors.rb
329
+ - lib/meta_inspector/http_session.rb
330
+ - lib/meta_inspector/image_downloader.rb
329
331
  - lib/meta_inspector/parser.rb
330
332
  - lib/meta_inspector/parsers/base.rb
331
333
  - lib/meta_inspector/parsers/head_links.rb
@@ -335,6 +337,7 @@ files:
335
337
  - lib/meta_inspector/parsers/texts.rb
336
338
  - lib/meta_inspector/request.rb
337
339
  - lib/meta_inspector/url.rb
340
+ - lib/meta_inspector/url_policy.rb
338
341
  - lib/meta_inspector/version.rb
339
342
  - lib/metainspector.rb
340
343
  - meta_inspector.gemspec
@@ -406,6 +409,7 @@ files:
406
409
  - spec/fixtures/wordpress_site.response
407
410
  - spec/fixtures/youtube.response
408
411
  - spec/fixtures/youtube_short_title.response
412
+ - spec/image_download_spec.rb
409
413
  - spec/meta_inspector/head_first_spec.rb
410
414
  - spec/meta_inspector/head_links_spec.rb
411
415
  - spec/meta_inspector/images_spec.rb
@@ -417,6 +421,7 @@ files:
417
421
  - spec/parser_spec.rb
418
422
  - spec/request_spec.rb
419
423
  - spec/spec_helper.rb
424
+ - spec/url_filter_spec.rb
420
425
  - spec/url_spec.rb
421
426
  homepage: https://github.com/jaimeiniesta/metainspector
422
427
  licenses: