metainspector 5.18.1 → 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.
@@ -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,16 +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]
14
+ @head_first = options[:head_first]
15
+ @allow_non_html_content = options[:allow_non_html_content]
21
16
  @connection_timeout = options[:connection_timeout]
22
17
  @read_timeout = options[:read_timeout]
23
- @retries = options[:retries]
24
18
  @encoding = options[:encoding]
25
- @headers = options[:headers]
26
- @faraday_options = options[:faraday_options] || {}
27
- @faraday_http_cache = options[:faraday_http_cache]
19
+ @http_session = options[:http_session] || HttpSession.new(options)
28
20
 
29
21
  response # request early so we can fail early
30
22
  end
@@ -42,12 +34,13 @@ module MetaInspector
42
34
  end
43
35
 
44
36
  def content_type
45
- return nil if response.headers['content-type'].nil?
46
- response.headers['content-type'].split(';')[0] if response
37
+ response_content_type(response)
47
38
  end
48
39
 
49
40
  def response
50
41
  @response ||= fetch
42
+ rescue URLPolicy::Failure => failure
43
+ raise failure.original
51
44
  rescue Faraday::TimeoutError => e
52
45
  raise MetaInspector::TimeoutError.new(e)
53
46
  rescue Faraday::ConnectionFailed, Faraday::SSLError, URI::InvalidURIError, Faraday::FollowRedirects::RedirectLimitReached => e
@@ -56,36 +49,23 @@ module MetaInspector
56
49
 
57
50
  private
58
51
 
59
- def fetch
60
- Timeout::timeout(fatal_timeout) do
61
- @faraday_options.merge!(:url => url)
62
- follow_redirects_options = @faraday_options.delete(:redirect) || {}
63
-
64
- session = Faraday.new(@faraday_options) do |faraday|
65
- faraday.request :retry, max: @retries
66
-
67
- faraday.request :gzip
68
-
69
- if @allow_redirections
70
- follow_redirects_options[:limit] ||= 10
71
- faraday.use Faraday::FollowRedirects::Middleware, **follow_redirects_options
72
- faraday.use :cookie_jar
73
- end
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
74
56
 
75
- if @faraday_http_cache.is_a?(Hash)
76
- @faraday_http_cache[:serializer] ||= Marshal
77
- faraday.use Faraday::HttpCache, **@faraday_http_cache
78
- end
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
79
62
 
80
- faraday.headers.merge!(@headers || {})
81
- faraday.response :encoding
82
- faraday.adapter :net_http
83
- end
63
+ def fetch
64
+ Timeout::timeout(fatal_timeout) do
65
+ session = @http_session.build(url)
84
66
 
85
- response = session.get do |req|
86
- req.options.timeout = @connection_timeout
87
- req.options.open_timeout = @read_timeout
88
- end
67
+ check_head_content_type(session) if @head_first
68
+ response = perform_request(session, :get)
89
69
 
90
70
  if @allow_redirections
91
71
  @url.url = response.env.url.to_s
@@ -97,6 +77,27 @@ module MetaInspector
97
77
  raise MetaInspector::TimeoutError.new(e)
98
78
  end
99
79
 
80
+ def check_head_content_type(session)
81
+ head_response = perform_request(session, :head)
82
+ return unless head_response.success?
83
+
84
+ type = response_content_type(head_response)
85
+ if !@allow_non_html_content && !type.nil? && type != 'text/html'
86
+ fail MetaInspector::NonHtmlError.new "The url provided contains #{type} content instead of text/html content"
87
+ end
88
+ end
89
+
90
+ def response_content_type(response)
91
+ response.headers['content-type']&.split(';')&.first
92
+ end
93
+
94
+ def perform_request(session, method)
95
+ session.public_send(method) do |req|
96
+ req.options.timeout = @connection_timeout
97
+ req.options.open_timeout = @read_timeout
98
+ end
99
+ end
100
+
100
101
  # Timeouts when connecting / reading a request are handled by Faraday, but in the
101
102
  # case of URLs that respond with streaming, Faraday will never return. In that case,
102
103
  # we'll resort to our own timeout
@@ -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.18.1'
2
+ VERSION = '5.20.0'
3
3
  end
@@ -1,6 +1,18 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe MetaInspector::Document do
4
+ describe 'max_image_downloads option' do
5
+ [-1, 1.5, '1', true, false].each do |limit|
6
+ it "should reject #{limit.inspect} before fetching the document" do
7
+ expect do
8
+ MetaInspector::Document.new('http://example.com/', max_image_downloads: limit)
9
+ end.to raise_error(ArgumentError, 'max_image_downloads must be a non-negative Integer or nil')
10
+
11
+ expect(WebMock).not_to have_requested(:get, 'http://example.com/')
12
+ end
13
+ end
14
+ end
15
+
4
16
  describe 'passing the contents of the document as html' do
5
17
  let(:doc) { MetaInspector::Document.new('http://cnn.com/', :document => "<html><head><title>Hello From Passed Html</title><a href='/hello'>Hello link</a></head><body></body></html>") }
6
18
 
@@ -17,6 +29,13 @@ describe MetaInspector::Document do
17
29
  expect(MetaInspector::Document.new('http://pagerankalert.com').to_s.class).to eq(String)
18
30
  end
19
31
 
32
+ it 'should include a nil language in the hash when the document has no declared language' do
33
+ stub_request(:get, 'http://example.com/').to_return(body: '<html><body></body></html>', headers: { 'Content-Type' => 'text/html' })
34
+ doc = MetaInspector::Document.new('http://example.com/')
35
+
36
+ expect(doc.to_hash).to include('language' => nil)
37
+ end
38
+
20
39
  it "should return a Hash with all the values set" do
21
40
  doc = MetaInspector::Document.new('http://pagerankalert.com')
22
41
  expect(doc.to_hash).to eq({
@@ -24,6 +43,7 @@ describe MetaInspector::Document do
24
43
  "scheme" => "http",
25
44
  "host" => "pagerankalert.com",
26
45
  "root_url" => "http://pagerankalert.com/",
46
+ "language" => "en",
27
47
  "title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
28
48
  "best_title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
29
49
  "author" => nil,
@@ -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
@@ -0,0 +1,195 @@
1
+ require 'spec_helper'
2
+
3
+ describe MetaInspector, 'head_first option' do
4
+ let(:page_url) { 'http://example.com/head-first' }
5
+ let(:html) { '<html><head><title>Downloaded page</title></head></html>' }
6
+ let!(:get_request) do
7
+ stub_request(:get, page_url).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
8
+ end
9
+
10
+ it 'does not send HEAD by default' do
11
+ expect(MetaInspector.new(page_url).title).to eq('Downloaded page')
12
+
13
+ expect(a_request(:head, page_url)).not_to have_been_made
14
+ expect(get_request).to have_been_requested.once
15
+ end
16
+
17
+ it 'does not send HEAD when explicitly disabled' do
18
+ expect(MetaInspector.new(page_url, head_first: false).title).to eq('Downloaded page')
19
+
20
+ expect(a_request(:head, page_url)).not_to have_been_made
21
+ end
22
+
23
+ it 'sends HEAD before GET for HTML and exposes the GET response' do
24
+ requests = []
25
+ head_request = stub_request(:head, page_url).to_return do
26
+ requests << :head
27
+ { headers: { 'Content-Type' => 'text/html; charset=UTF-8', 'X-Response' => 'head' } }
28
+ end
29
+ stub_request(:get, page_url).to_return do
30
+ requests << :get
31
+ { body: html, headers: { 'Content-Type' => 'text/html', 'X-Response' => 'get' } }
32
+ end
33
+
34
+ page = MetaInspector.new(page_url, head_first: true)
35
+
36
+ expect(page.title).to eq('Downloaded page')
37
+ expect(page.content_type).to eq('text/html')
38
+ expect(page.response.headers['x-response']).to eq('get')
39
+ expect(page.to_s).to eq(html)
40
+ expect(head_request).to have_been_requested.once
41
+ expect(requests).to eq([:head, :get])
42
+ end
43
+
44
+ it 'rejects non-HTML content without sending GET' do
45
+ head_request = stub_request(:head, page_url)
46
+ .to_return(headers: { 'Content-Type' => 'application/pdf; version=1.7' })
47
+
48
+ expect do
49
+ MetaInspector.new(page_url, head_first: true)
50
+ end.to raise_error(MetaInspector::NonHtmlError,
51
+ 'The url provided contains application/pdf content instead of text/html content')
52
+
53
+ expect(head_request).to have_been_requested.once
54
+ expect(get_request).not_to have_been_requested
55
+ end
56
+
57
+ it 'downloads non-HTML content when explicitly allowed' do
58
+ head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/plain' })
59
+ stub_request(:get, page_url).to_return(body: 'Plain text', headers: { 'Content-Type' => 'text/plain' })
60
+
61
+ page = MetaInspector.new(page_url, head_first: true, allow_non_html_content: true)
62
+
63
+ expect(page.to_s).to eq('Plain text')
64
+ expect(page.content_type).to eq('text/plain')
65
+ expect(head_request).to have_been_requested.once
66
+ expect(get_request).to have_been_requested.once
67
+ end
68
+
69
+ it 'continues to GET when HEAD has no content type' do
70
+ head_request = stub_request(:head, page_url).to_return(status: 200)
71
+
72
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
73
+
74
+ expect(head_request).to have_been_requested.once
75
+ expect(get_request).to have_been_requested.once
76
+ end
77
+
78
+ [403, 404, 405, 501].each do |status|
79
+ it "continues to GET when HEAD returns #{status}" do
80
+ head_request = stub_request(:head, page_url)
81
+ .to_return(status: status, headers: { 'Content-Type' => 'text/plain' })
82
+
83
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
84
+
85
+ expect(head_request).to have_been_requested.once
86
+ expect(get_request).to have_been_requested.once
87
+ end
88
+ end
89
+
90
+ it 'still rejects a non-HTML GET response when HEAD reports HTML' do
91
+ head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/html' })
92
+ stub_request(:get, page_url).to_return(headers: { 'Content-Type' => 'application/pdf' })
93
+
94
+ expect do
95
+ MetaInspector.new(page_url, head_first: true)
96
+ end.to raise_error(MetaInspector::NonHtmlError)
97
+
98
+ expect(head_request).to have_been_requested.once
99
+ expect(get_request).to have_been_requested.once
100
+ end
101
+
102
+ it 'does not send requests for a supplied document' do
103
+ expect(MetaInspector.new(page_url, head_first: true, document: html).title).to eq('Downloaded page')
104
+
105
+ expect(a_request(:head, page_url)).not_to have_been_made
106
+ expect(get_request).not_to have_been_requested
107
+ end
108
+
109
+ it 'sends custom headers on both requests' do
110
+ headers = { 'User-Agent' => 'Custom agent', 'Authorization' => 'Bearer test-token' }
111
+ head_request = stub_request(:head, page_url).with(headers: headers)
112
+ .to_return(headers: { 'Content-Type' => 'text/html' })
113
+ get_with_headers = stub_request(:get, page_url).with(headers: headers).to_return(body: html)
114
+
115
+ expect(MetaInspector.new(page_url, head_first: true, headers: headers).title).to eq('Downloaded page')
116
+
117
+ expect(head_request).to have_been_requested.once
118
+ expect(get_with_headers).to have_been_requested.once
119
+ end
120
+
121
+ it 'follows HEAD redirects to reject non-HTML content before GET' do
122
+ target = 'http://example.com/large.pdf'
123
+ stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => target })
124
+ target_request = stub_request(:head, target).to_return(headers: { 'Content-Type' => 'application/pdf' })
125
+
126
+ expect do
127
+ MetaInspector.new(page_url, head_first: true)
128
+ end.to raise_error(MetaInspector::NonHtmlError)
129
+
130
+ expect(target_request).to have_been_requested.once
131
+ expect(a_request(:get, /example.com/)).not_to have_been_made
132
+ end
133
+
134
+ it 'follows GET redirects independently of HEAD and exposes the final GET URL' do
135
+ head_target = 'http://example.com/head-target'
136
+ get_target = 'http://example.com/get-target'
137
+ head_request = stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => head_target })
138
+ stub_request(:head, head_target).to_return(headers: { 'Content-Type' => 'text/html' })
139
+ stub_request(:get, page_url).to_return(status: 302, headers: { 'Location' => get_target })
140
+ final_request = stub_request(:get, get_target).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
141
+
142
+ page = MetaInspector.new(page_url, head_first: true)
143
+
144
+ expect(page.title).to eq('Downloaded page')
145
+ expect(page.url).to eq(get_target)
146
+ expect(head_request).to have_been_requested.once
147
+ expect(final_request).to have_been_requested.once
148
+ expect(a_request(:get, head_target)).not_to have_been_made
149
+ end
150
+
151
+ it 'does not follow HEAD redirects when redirections are disabled' do
152
+ head_request = stub_request(:head, page_url)
153
+ .to_return(status: 302, headers: { 'Location' => 'http://example.com/target', 'Content-Type' => 'text/plain' })
154
+
155
+ page = MetaInspector.new(page_url, head_first: true, allow_redirections: false)
156
+
157
+ expect(page.title).to eq('Downloaded page')
158
+ expect(page.url).to eq(page_url)
159
+ expect(head_request).to have_been_requested.once
160
+ expect(a_request(:head, 'http://example.com/target')).not_to have_been_made
161
+ end
162
+
163
+ it 'keeps cookies from HEAD for the GET request' do
164
+ head_request = stub_request(:head, page_url)
165
+ .to_return(headers: { 'Content-Type' => 'text/html', 'Set-Cookie' => 'session=checked; path=/' })
166
+ get_with_cookie = stub_request(:get, page_url).with(headers: { 'Cookie' => 'session=checked' }).to_return(body: html)
167
+
168
+ expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
169
+
170
+ expect(head_request).to have_been_requested.once
171
+ expect(get_with_cookie).to have_been_requested.once
172
+ end
173
+
174
+ it 'wraps HEAD timeouts without sending GET' do
175
+ head_request = stub_request(:head, page_url).to_raise(Net::ReadTimeout)
176
+
177
+ expect do
178
+ MetaInspector.new(page_url, head_first: true, retries: 0)
179
+ end.to raise_error(MetaInspector::TimeoutError)
180
+
181
+ expect(head_request).to have_been_requested.once
182
+ expect(get_request).not_to have_been_requested
183
+ end
184
+
185
+ it 'wraps HEAD connection failures without sending GET' do
186
+ head_request = stub_request(:head, page_url).to_raise(Faraday::ConnectionFailed.new('connection failed'))
187
+
188
+ expect do
189
+ MetaInspector.new(page_url, head_first: true, retries: 0)
190
+ end.to raise_error(MetaInspector::RequestError)
191
+
192
+ expect(head_request).to have_been_requested.once
193
+ expect(get_request).not_to have_been_requested
194
+ end
195
+ end