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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +15 -0
- data/README.md +125 -6
- data/lib/meta_inspector/document.rb +25 -5
- data/lib/meta_inspector/http_session.rb +65 -0
- data/lib/meta_inspector/image_downloader.rb +53 -0
- data/lib/meta_inspector/parser.rb +4 -2
- data/lib/meta_inspector/parsers/images.rb +19 -14
- data/lib/meta_inspector/parsers/links.rb +2 -1
- data/lib/meta_inspector/parsers/texts.rb +13 -4
- data/lib/meta_inspector/request.rb +43 -42
- data/lib/meta_inspector/url_policy.rb +58 -0
- data/lib/meta_inspector/version.rb +1 -1
- data/spec/document_spec.rb +20 -0
- data/spec/image_download_spec.rb +114 -0
- data/spec/meta_inspector/head_first_spec.rb +195 -0
- data/spec/meta_inspector/images_spec.rb +233 -0
- data/spec/meta_inspector/links_spec.rb +22 -0
- data/spec/meta_inspector/meta_inspector_spec.rb +58 -7
- data/spec/meta_inspector/redirections_spec.rb +20 -6
- data/spec/meta_inspector/texts_spec.rb +115 -0
- data/spec/request_spec.rb +29 -0
- data/spec/url_filter_spec.rb +206 -0
- metadata +7 -1
|
@@ -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,8 @@ 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
|
|
413
|
+
- spec/meta_inspector/head_first_spec.rb
|
|
409
414
|
- spec/meta_inspector/head_links_spec.rb
|
|
410
415
|
- spec/meta_inspector/images_spec.rb
|
|
411
416
|
- spec/meta_inspector/links_spec.rb
|
|
@@ -416,6 +421,7 @@ files:
|
|
|
416
421
|
- spec/parser_spec.rb
|
|
417
422
|
- spec/request_spec.rb
|
|
418
423
|
- spec/spec_helper.rb
|
|
424
|
+
- spec/url_filter_spec.rb
|
|
419
425
|
- spec/url_spec.rb
|
|
420
426
|
homepage: https://github.com/jaimeiniesta/metainspector
|
|
421
427
|
licenses:
|