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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +69 -5
- data/lib/meta_inspector/document.rb +9 -3
- data/lib/meta_inspector/http_session.rb +65 -0
- data/lib/meta_inspector/image_downloader.rb +53 -0
- data/lib/meta_inspector/parser.rb +2 -1
- data/lib/meta_inspector/parsers/images.rb +3 -2
- data/lib/meta_inspector/request.rb +17 -39
- data/lib/meta_inspector/url_policy.rb +58 -0
- data/lib/meta_inspector/version.rb +1 -1
- data/spec/image_download_spec.rb +114 -0
- data/spec/meta_inspector/meta_inspector_spec.rb +58 -7
- data/spec/request_spec.rb +1 -1
- data/spec/url_filter_spec.rb +206 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba31ea21114c1932ca2dde179a2812f24d429b7420807fec232de65ea1ba824e
|
|
4
|
+
data.tar.gz: 99188cbec093cfd1d22cdfd3d31bf181cf193e937f6c7b23a61cb6ed39ba1ca2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# *
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
9
|
-
#
|
|
10
|
-
cache
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|