gemirro 2.0.0 → 3.0.1

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.
data/lib/gemirro/http.rb CHANGED
@@ -2,61 +2,135 @@
2
2
 
3
3
  module Gemirro
4
4
  ##
5
- # The Http class is responsible for executing GET request
6
- # to a specific url and return an response as an HTTP::Message
7
- #
8
- # @!attribute [r] client
9
- # @return [HTTPClient]
5
+ # The Http class is responsible for executing GET requests against a
6
+ # given URL, following redirects and reusing keep-alive connections.
10
7
  #
11
8
  class Http
12
- attr_accessor :client
13
-
14
9
  ##
15
- # Requests the given HTTP resource.
16
- #
17
- # @param [String] url
18
- # @return [HTTP::Message]
10
+ # Raised when a request comes back with a non-successful,
11
+ # non-redirect HTTP status.
19
12
  #
20
- def self.get(url)
21
- response = client.get(url, follow_redirect: true)
13
+ class Error < StandardError; end
22
14
 
23
- raise HTTPClient::BadResponseError, response.reason unless HTTP::Status.successful?(response.status)
15
+ MAX_REDIRECTS = 10
24
16
 
25
- response
26
- end
17
+ class << self
18
+ ##
19
+ # Requests the given HTTP resource, following redirects.
20
+ #
21
+ # @param [String] url
22
+ # @return [Net::HTTPResponse]
23
+ #
24
+ def get(url)
25
+ fetch(URI.parse(url), MAX_REDIRECTS)
26
+ end
27
27
 
28
- ##
29
- # @return [HTTPClient]
30
- #
31
- def self.client
32
- client ||= HTTPClient.new
33
- config = Utils.configuration
34
- if defined?(config.upstream_user)
35
- user = config.upstream_user
36
- password = config.upstream_password
37
- domain = config.upstream_domain
38
- client.set_auth(domain, user, password)
28
+ ##
29
+ # Closes and forgets every connection cached for the current
30
+ # Thread. Mostly useful for tests.
31
+ #
32
+ def reset!
33
+ pool.each_value { |connection| connection.finish if connection.started? }
34
+ pool.clear
35
+ end
36
+
37
+ private
38
+
39
+ ##
40
+ # @param [URI] uri
41
+ # @param [Integer] redirects_left
42
+ # @return [Net::HTTPResponse]
43
+ #
44
+ def fetch(uri, redirects_left)
45
+ response = connection_for(uri).request(build_request(uri))
46
+
47
+ case response
48
+ when Net::HTTPRedirection
49
+ raise Error, "Too many redirects for #{uri}" if redirects_left <= 0
50
+
51
+ fetch(URI.parse(response['location']), redirects_left - 1)
52
+ when Net::HTTPSuccess
53
+ response
54
+ else
55
+ raise Error, "#{response.code} #{response.message}"
56
+ end
57
+ end
58
+
59
+ ##
60
+ # @param [URI] uri
61
+ # @return [Net::HTTP::Get]
62
+ #
63
+ def build_request(uri)
64
+ request = Net::HTTP::Get.new(uri)
65
+ config = Utils.configuration
66
+ request.basic_auth(config.upstream_user, config.upstream_password) if defined?(config.upstream_user)
67
+
68
+ request
39
69
  end
40
70
 
41
- if defined?(config.proxy)
42
- proxy = config.proxy
43
- client.proxy = (proxy)
71
+ ##
72
+ # Returns a started, keep-alive connection for the given URI,
73
+ # reusing one already opened by the current Thread when possible.
74
+ #
75
+ # @param [URI] uri
76
+ # @return [Net::HTTP]
77
+ #
78
+ def connection_for(uri)
79
+ key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
80
+ pool[key] ||= build_connection(uri)
44
81
  end
45
82
 
46
- # Use my own ca file for self signed cert
47
- if defined?(config.rootca)
48
- abort "The configuration file #{config.rootca} does not exist" unless File.file?(config.rootca)
49
- client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_PEER
50
- client.ssl_config.set_trust_ca(config.rootca)
51
- elsif defined?(config.verify_mode)
52
- client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE unless config.verify_mode
83
+ ##
84
+ # Connection pool for the current Thread. Kept per-Thread so
85
+ # sockets are never shared/interleaved across concurrent requests.
86
+ #
87
+ # @return [Hash]
88
+ #
89
+ def pool
90
+ Thread.current[:gemirro_http_pool] ||= {}
53
91
  end
54
92
 
55
- # Enforce base auth
56
- if defined?(config.basic_auth) && config.basic_auth
57
- client.www_auth.basic_auth.force_auth = (true)
93
+ ##
94
+ # @param [URI] uri
95
+ # @return [Net::HTTP]
96
+ #
97
+ def build_connection(uri)
98
+ config = Utils.configuration
99
+ http = Net::HTTP.new(uri.host, uri.port, *proxy_args(config))
100
+ http.use_ssl = uri.scheme == 'https'
101
+ configure_ssl(http, config)
102
+
103
+ http.start
104
+ end
105
+
106
+ ##
107
+ # @param [Net::HTTP] http
108
+ # @param [Gemirro::Configuration] config
109
+ #
110
+ def configure_ssl(http, config)
111
+ if defined?(config.rootca)
112
+ abort "The configuration file #{config.rootca} does not exist" unless File.file?(config.rootca)
113
+
114
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
115
+ http.ca_file = config.rootca
116
+ elsif defined?(config.verify_mode) && !config.verify_mode
117
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
118
+ end
119
+ end
120
+
121
+ ##
122
+ # Splits a `proxy 'http://user:pass@host:port'` configuration
123
+ # value into the args expected by `Net::HTTP.new`.
124
+ #
125
+ # @param [Gemirro::Configuration] config
126
+ # @return [Array]
127
+ #
128
+ def proxy_args(config)
129
+ return [] unless defined?(config.proxy) && config.proxy
130
+
131
+ proxy_uri = URI.parse(config.proxy)
132
+ [proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password]
58
133
  end
59
- @client = client
60
134
  end
61
135
  end
62
136
  end
@@ -119,7 +119,7 @@ module Gemirro
119
119
  source_host = Gemirro.configuration.source.host
120
120
  Utils.logger.info("Download from source #{source_host}/#{file}")
121
121
  resp = Http.get("#{source_host}/#{File.basename(file)}")
122
- return unless resp.code == 200
122
+ return unless resp.code.to_i == 200
123
123
 
124
124
  resp.body
125
125
  end
@@ -27,10 +27,8 @@ module Gemirro
27
27
  #
28
28
  def write(content)
29
29
  FileUtils.mkdir_p(File.dirname(@path))
30
- handle = File.open(@path, 'w')
31
-
32
- handle.write(content)
33
- handle.close
30
+ File.write(@path, content)
31
+ nil
34
32
  end
35
33
 
36
34
  ##
@@ -39,12 +37,7 @@ module Gemirro
39
37
  # @return [String]
40
38
  #
41
39
  def read
42
- handle = File.open(@path, 'r')
43
- content = handle.read
44
-
45
- handle.close
46
-
47
- content
40
+ File.read(@path)
48
41
  end
49
42
  end
50
43
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'sinatra/base'
4
- require 'thin'
4
+ require 'puma'
5
5
  require 'uri'
6
6
  require 'addressable/uri'
7
7
  require 'base64'
@@ -33,6 +33,7 @@ module Gemirro
33
33
  config.server.port = '2000' if config.server.port.nil?
34
34
 
35
35
  set :static, true
36
+ set :server, 'puma'
36
37
 
37
38
  set :views, Gemirro::Configuration.views_directory
38
39
  set :port, config.server.port
data/lib/gemirro/utils.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'addressable/uri'
3
4
  require 'gemirro/gem_version'
4
5
 
5
6
  module Gemirro
@@ -25,6 +26,7 @@ module Gemirro
25
26
  URI_REGEXP = /^(.*)-(\d+(?:\.\d+){1,4}.*?)(?:-(x86-(?:(?:mswin|mingw)(?:32|64)).*?|java))?\.(gem(?:spec\.rz)?)$/
26
27
  GEMSPEC_TYPE = 'gemspec.rz'
27
28
  GEM_TYPE = 'gem'
29
+ SAFE_HOMEPAGE_SCHEMES = %w[http https].freeze
28
30
 
29
31
  ##
30
32
  # Generate Gems collection from Marshal dump - always the .local file
@@ -156,6 +158,31 @@ module Gemirro
156
158
  end
157
159
  end
158
160
 
161
+ ##
162
+ # Return a normalized homepage URL that is safe to use as a link target,
163
+ # or nil when it is missing, unparsable or not an http(s) URL with a host.
164
+ # Gem metadata comes from upstream sources and must not be trusted.
165
+ #
166
+ # @param [String] homepage
167
+ # @return [String, nil]
168
+ #
169
+ def self.safe_homepage(homepage)
170
+ return if homepage.nil?
171
+
172
+ # Browsers ignore control characters and whitespace inside a scheme
173
+ # (e.g. "java\tscript:"), so drop them before validating.
174
+ value = homepage.to_s.gsub(/[[:cntrl:][:space:]]/, '')
175
+ return if value.empty?
176
+
177
+ uri = Addressable::URI.parse(value).normalize
178
+ return unless SAFE_HOMEPAGE_SCHEMES.include?(uri.scheme&.downcase)
179
+ return if uri.host.nil? || uri.host.empty?
180
+
181
+ uri.to_s
182
+ rescue Addressable::URI::InvalidURIError, ArgumentError, TypeError
183
+ nil
184
+ end
185
+
159
186
  ##
160
187
  # Try to fetch gem and download its if it's possible, and
161
188
  # build and install indicies.
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Gemirro Version
4
4
  module Gemirro
5
- VERSION = '2.0.0'
5
+ VERSION = '3.0.1'
6
6
  end
data/lib/gemirro.rb CHANGED
@@ -4,14 +4,15 @@ require 'builder'
4
4
  require 'confstruct'
5
5
  require 'digest/sha2'
6
6
  require 'fileutils'
7
- require 'httpclient'
8
7
  require 'json'
9
8
  require 'logger'
9
+ require 'net/http'
10
+ require 'openssl'
11
+ require 'optparse'
10
12
  require 'parallel'
11
13
  require 'rubygems'
12
14
  require 'rubygems/indexer'
13
15
  require 'rubygems/user_interaction'
14
- require 'slop'
15
16
  require 'stringio'
16
17
  require 'tempfile'
17
18
  require 'zlib'
@@ -34,8 +35,3 @@ require 'gemirro/versions_fetcher'
34
35
  require 'gemirro/gems_fetcher'
35
36
 
36
37
  require 'gemirro/cli'
37
- require 'gemirro/cli/index'
38
- require 'gemirro/cli/init'
39
- require 'gemirro/cli/list'
40
- require 'gemirro/cli/server'
41
- require 'gemirro/cli/update'
@@ -1,7 +1,6 @@
1
1
  require 'spec_helper'
2
2
  require 'gemirro/cli'
3
3
  require 'gemirro/mirror_file'
4
- require 'slop'
5
4
 
6
5
  # Gemirro tests
7
6
  module Gemirro
@@ -10,21 +9,47 @@ module Gemirro
10
9
  describe 'CLI' do
11
10
  include FakeFS::SpecHelpers
12
11
 
13
- it 'should return options' do
14
- options = CLI.options
15
- expect(options).to be_a(::Slop)
16
- expect(options.config[:strict]).to be_truthy
17
- expect(options.config[:banner])
18
- .to eq('Usage: gemirro [COMMAND] [OPTIONS]')
19
- expect(options.to_s)
20
- .to match(/-v, --version(\s+)Shows the current version/)
21
- expect(options.to_s)
22
- .to match(/-h, --help(\s+)Display this help message./)
12
+ it 'should print version information for -v' do
13
+ expect { CLI.run(['-v']) }.to output(/gemirro v.* on ruby/).to_stdout
14
+ end
15
+
16
+ it 'should print version information for --version' do
17
+ expect { CLI.run(['--version']) }.to output(/gemirro v.* on ruby/).to_stdout
18
+ end
19
+
20
+ it 'should print usage for -h' do
21
+ expect { CLI.run(['-h']) }.to output(/Usage: gemirro \[COMMAND\] \[OPTIONS\]/).to_stdout
22
+ end
23
+
24
+ it 'should print usage for --help' do
25
+ expect { CLI.run(['--help']) }.to output(/Usage: gemirro \[COMMAND\] \[OPTIONS\]/).to_stdout
26
+ end
27
+
28
+ it 'should print usage when no argument is given' do
29
+ expect { CLI.run([]) }.to output(/Usage: gemirro \[COMMAND\] \[OPTIONS\]/).to_stdout
30
+ end
23
31
 
24
- version = options.fetch_option(:v)
25
- expect(version.short).to eq('v')
26
- expect(version.long).to eq('version')
27
- expect { version.call }.to output(/gemirro v.* on ruby/).to_stdout
32
+ it 'should list the available commands in the usage banner' do
33
+ output = capture_stdout { CLI.run(['-h']) }
34
+
35
+ CLI::COMMANDS.each do |name, description|
36
+ expect(output).to match(/#{name}(\s+)#{Regexp.escape(description)}/)
37
+ end
38
+ end
39
+
40
+ it 'should set $PROGRAM_NAME' do
41
+ $PROGRAM_NAME = 'rspec'
42
+ CLI.run(['-v'])
43
+ expect($PROGRAM_NAME).to eq('gemirro')
44
+ end
45
+
46
+ it 'should print an error and exit for an unknown command' do
47
+ expect { CLI.run(['nope']) }.to output(/Unknown command: nope/).to_stdout.and raise_error(SystemExit)
48
+ end
49
+
50
+ it 'should print an option error for an unknown flag' do
51
+ expect { CLI.run(['list', '--nope']) }
52
+ .to output(/invalid option: --nope/).to_stdout.and raise_error(SystemExit)
28
53
  end
29
54
 
30
55
  it 'should retrieve version information' do
@@ -45,6 +70,15 @@ module Gemirro
45
70
  file.write('test')
46
71
  expect { CLI.load_configuration('config.rb') }.to raise_error LoadError
47
72
  end
73
+
74
+ def capture_stdout
75
+ original = $stdout
76
+ $stdout = StringIO.new
77
+ yield
78
+ $stdout.string
79
+ ensure
80
+ $stdout = original
81
+ end
48
82
  end
49
83
  end
50
84
  end
@@ -47,7 +47,7 @@ module Gemirro
47
47
  version = ::Gem::Version.new('0.0.1')
48
48
  Utils.configuration.ignore_gem('gemirro', '0.0.1', 'ruby')
49
49
  allow(@source).to receive(:fetch_gem)
50
- .once.with('gemirro', version).and_raise(ArgumentError)
50
+ .once.with('gemirro-0.0.1.gem').and_raise(ArgumentError)
51
51
  allow(Utils.logger).to receive(:error)
52
52
  .once.with(/Failed to retrieve/)
53
53
  allow(Utils.logger).to receive(:debug)
@@ -1,5 +1,6 @@
1
1
  require 'spec_helper'
2
- require 'httpclient'
2
+ require 'webmock/rspec'
3
+ require 'gemirro/utils'
3
4
  require 'gemirro/http'
4
5
 
5
6
  module Gemirro
@@ -7,88 +8,91 @@ module Gemirro
7
8
  let(:config) { double('Configuration') }
8
9
 
9
10
  before do
10
- Http.instance_variable_set(:@client, nil)
11
+ Http.reset!
11
12
  allow(Utils).to receive(:configuration).and_return(config)
12
13
  end
13
14
 
14
- describe '.client' do
15
- it 'initializes a new HTTPClient' do
16
- expect(Http.client).to be_a(HTTPClient)
17
- end
15
+ after { Http.reset! }
18
16
 
19
- context 'with proxy configuration' do
20
- it 'sets proxy configuration' do
21
- allow(config).to receive(:proxy).and_return('http://proxy.example.com:8080')
17
+ describe '.get' do
18
+ context 'with successful response' do
19
+ it 'returns the response' do
20
+ stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'content')
21
+
22
+ response = Http.get('http://example.com/')
23
+ expect(response).to be_a(Net::HTTPSuccess)
24
+ expect(response.body).to eq('content')
25
+ end
26
+
27
+ it 'reuses the connection for a second request on the same host' do
28
+ stub_request(:get, 'http://example.com/one').to_return(status: 200, body: 'one')
29
+ stub_request(:get, 'http://example.com/two').to_return(status: 200, body: 'two')
30
+
31
+ Http.get('http://example.com/one')
32
+ Http.get('http://example.com/two')
22
33
 
23
- expect(Http.client.proxy.to_s).to eq('http://proxy.example.com:8080')
34
+ expect(a_request(:get, 'http://example.com/one')).to have_been_made.once
35
+ expect(a_request(:get, 'http://example.com/two')).to have_been_made.once
24
36
  end
25
37
  end
26
38
 
27
- context 'with SSL configuration' do
28
- context 'with invalid root CA path' do
29
- before do
30
- allow(config).to receive(:rootca).and_return('/nonexistent/ca.crt')
31
- allow(File).to receive(:file?).with('/nonexistent/ca.crt').and_return(false)
32
- end
39
+ context 'with a redirect' do
40
+ it 'follows it and returns the final response' do
41
+ stub_request(:get, 'http://example.com/old')
42
+ .to_return(status: 302, headers: { 'Location' => 'http://example.com/new' })
43
+ stub_request(:get, 'http://example.com/new').to_return(status: 200, body: 'moved')
33
44
 
34
- it 'aborts with error message' do
35
- expect { Http.client }.to raise_error(SystemExit)
36
- end
45
+ expect(Http.get('http://example.com/old').body).to eq('moved')
37
46
  end
38
47
 
39
- context 'with verify_mode disabled' do
40
- before do
41
- allow(config).to receive(:verify_mode).and_return(false)
42
- end
48
+ it 'raises after too many redirects' do
49
+ stub_request(:get, 'http://example.com/loop')
50
+ .to_return(status: 302, headers: { 'Location' => 'http://example.com/loop' })
43
51
 
44
- it 'sets SSL verify mode to VERIFY_NONE' do
45
- expect(Http.client.ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE)
46
- end
52
+ expect { Http.get('http://example.com/loop') }
53
+ .to raise_error(Http::Error, /Too many redirects/)
47
54
  end
48
55
  end
49
56
 
50
- context 'with basic auth forced' do
51
- before do
52
- allow(config).to receive(:basic_auth).and_return(true)
53
- end
57
+ context 'with an error response' do
58
+ it 'raises Http::Error for a failed request' do
59
+ stub_request(:get, 'http://example.com/missing').to_return(status: [404, 'Not Found'])
54
60
 
55
- it 'forces basic authentication' do
56
- expect(Http.client.www_auth.basic_auth.force_auth).to be true
61
+ expect { Http.get('http://example.com/missing') }
62
+ .to raise_error(Http::Error, '404 Not Found')
57
63
  end
58
64
  end
59
- end
60
65
 
61
- describe '.get' do
62
- let(:client) { instance_double(HTTPClient) }
66
+ context 'with upstream authentication configured' do
67
+ it 'sends a basic auth header on every request' do
68
+ allow(config).to receive(:upstream_user).and_return('user')
69
+ allow(config).to receive(:upstream_password).and_return('pass')
70
+ stub_request(:get, 'http://example.com/private')
71
+ .with(basic_auth: %w[user pass])
72
+ .to_return(status: 200, body: 'ok')
63
73
 
64
- before do
65
- allow(Http).to receive(:client).and_return(client)
74
+ expect(Http.get('http://example.com/private').body).to eq('ok')
75
+ end
66
76
  end
67
77
 
68
- context 'with successful response' do
69
- let(:response) { double('Response', status: 200, body: 'content') }
78
+ context 'with proxy configuration' do
79
+ it 'routes the request through the configured proxy' do
80
+ allow(config).to receive(:proxy).and_return('http://proxy.example.com:8080')
81
+ stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'content')
70
82
 
71
- it 'returns response for successful request' do
72
- allow(client).to receive(:get)
73
- .with('http://example.com', follow_redirect: true)
74
- .and_return(response)
83
+ Http.get('http://example.com/')
75
84
 
76
- expect(Http.get('http://example.com')).to eq(response)
85
+ expect(WebMock).to have_requested(:get, 'http://example.com/')
77
86
  end
78
87
  end
79
88
 
80
- context 'with error response' do
81
- let(:response) { double('Response', status: 404, reason: 'Not Found') }
82
-
83
- it 'raises BadResponseError for failed request' do
84
- allow(client).to receive(:get)
85
- .with('http://example.com', follow_redirect: true)
86
- .and_return(response)
89
+ context 'with an invalid rootca path' do
90
+ it 'aborts with an error message' do
91
+ allow(config).to receive(:rootca).and_return('/nonexistent/ca.crt')
87
92
 
88
- expect { Http.get('http://example.com') }
89
- .to raise_error(HTTPClient::BadResponseError, 'Not Found')
93
+ expect { Http.get('https://example.com/') }.to raise_error(SystemExit)
90
94
  end
91
95
  end
92
96
  end
93
97
  end
94
- end
98
+ end
@@ -1,8 +1,9 @@
1
+ require 'spec_helper'
1
2
  require 'rack/test'
2
3
  require 'json'
3
4
  require 'parallel'
4
5
  require 'sinatra/base'
5
- require 'thin'
6
+ require 'puma'
6
7
  require 'base64'
7
8
  require 'gemirro/utils'
8
9
  require 'gemirro/mirror_directory'
@@ -80,6 +81,39 @@ module Gemirro
80
81
  expect(last_response.status).to eq(200)
81
82
  expect(last_response).to be_ok
82
83
  end
84
+
85
+ it 'should not link a javascript: homepage from gem metadata' do
86
+ MirrorFile.new('/var/www/gemirro/versions.md5.aaa256.list').write(%(created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0 checksum))
87
+ spec = ::Gem::Specification.new do |s|
88
+ s.name = 'volay'
89
+ s.version = '0.1.0'
90
+ s.authors = ['Mallory']
91
+ s.homepage = 'javascript:alert(1)'
92
+ end
93
+ allow(Utils).to receive(:spec_for).and_return(spec)
94
+
95
+ get '/gem/volay'
96
+
97
+ expect(last_response).to be_ok
98
+ expect(last_response.body).to include('Mallory')
99
+ expect(last_response.body).not_to include('javascript:')
100
+ end
101
+
102
+ it 'should link an https homepage from gem metadata' do
103
+ MirrorFile.new('/var/www/gemirro/versions.md5.aaa256.list').write(%(created_at: 2025-01-01T00:00:00Z\n---\nvolay 0.1.0 checksum))
104
+ spec = ::Gem::Specification.new do |s|
105
+ s.name = 'volay'
106
+ s.version = '0.1.0'
107
+ s.authors = ['Alice']
108
+ s.homepage = 'https://example.com/volay'
109
+ end
110
+ allow(Utils).to receive(:spec_for).and_return(spec)
111
+
112
+ get '/gem/volay'
113
+
114
+ expect(last_response).to be_ok
115
+ expect(last_response.body).to include('href="https://example.com/volay"')
116
+ end
83
117
 
84
118
  it 'responds to compact_index /names' do
85
119
  MirrorFile.new('/var/www/gemirro/names.md5.aaa256.list').write(%(---\nvolay))
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require 'gemirro/configuration'
2
3
  require 'gemirro/http'
3
4
  require 'gemirro/utils'
4
5
  require 'gemirro/source'
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'gemirro/utils'
5
+
6
+ # Utils tests
7
+ module Gemirro
8
+ describe Utils do
9
+ describe '.safe_homepage' do
10
+ it 'keeps http and https URLs' do
11
+ expect(Utils.safe_homepage('http://example.com')).to eq('http://example.com/')
12
+ expect(Utils.safe_homepage('https://example.com/foo?a=1')).to eq('https://example.com/foo?a=1')
13
+ expect(Utils.safe_homepage('HTTPS://Example.com/x')).to eq('https://example.com/x')
14
+ end
15
+
16
+ it 'rejects missing or empty values' do
17
+ expect(Utils.safe_homepage(nil)).to be_nil
18
+ expect(Utils.safe_homepage('')).to be_nil
19
+ expect(Utils.safe_homepage(" \n")).to be_nil
20
+ end
21
+
22
+ [
23
+ 'javascript:alert(1)',
24
+ 'JavaScript:alert(1)',
25
+ ' javascript:alert(1)',
26
+ "java\tscript:alert(1)",
27
+ "java\nscript:alert(1)",
28
+ "\u0000javascript:alert(1)",
29
+ 'data:text/html,<script>alert(1)</script>',
30
+ 'vbscript:msgbox(1)',
31
+ 'file:///etc/passwd',
32
+ 'javascript%3Aalert(1)',
33
+ '//evil.example.com',
34
+ '/relative/path',
35
+ 'https://'
36
+ ].each do |value|
37
+ it "rejects #{value.inspect}" do
38
+ expect(Utils.safe_homepage(value)).to be_nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end