diff --git a/Gemfile b/Gemfile index ac7ab1c227ef31c2defe136d1a97396246908fba..8708f4a228a486ce37afbb67c128ed058dd528e9 100644 --- a/Gemfile +++ b/Gemfile @@ -682,9 +682,10 @@ gem 'telesignenterprise', '~> 2.2' # rubocop:todo Gemfile/MissingFeatureCategory # BufferedIO patch # Updating this version will require updating scripts/allowed_warnings.txt gem 'net-protocol', '~> 0.1.3' # rubocop:todo Gemfile/MissingFeatureCategory -# Lock this until we make DNS rebinding work with the updated net-http: -# https://gitlab.com/gitlab-org/gitlab/-/issues/413528 -gem 'net-http', '= 0.1.1' # rubocop:todo Gemfile/MissingFeatureCategory + +# This is locked to 0.4.1 because we patch Net::HTTP#connect in +# gems/gitlab-http/lib/net_http/connect_patch.rb. +gem 'net-http', '= 0.4.1', feature_category: :shared gem 'duo_api', '~> 1.3' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index abf7193709dc721fa0e2fa0cab5aaa33ae792208..bdffbf048115cac2ad924dc0a6deb320b29efc61 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -395,7 +395,7 @@ {"name":"nap","version":"1.1.0","platform":"ruby","checksum":"949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576"}, {"name":"neighbor","version":"0.3.2","platform":"ruby","checksum":"b795bbcc24b1b9ae82d9f7e97a3461b0b3607d24a85a7acbed776bd498e7eba8"}, {"name":"nenv","version":"0.3.0","platform":"ruby","checksum":"d9de6d8fb7072228463bf61843159419c969edb34b3cef51832b516ae7972765"}, -{"name":"net-http","version":"0.1.1","platform":"ruby","checksum":"75a4e109b6f9af32fad0e98a6180c47aceb415927ca3bd70c8fc3e7dbbabbe86"}, +{"name":"net-http","version":"0.4.1","platform":"ruby","checksum":"a96efc5ea18bcb9715e24dda4159d10f67ff0345c8a980d04630028055b2c282"}, {"name":"net-http-persistent","version":"4.0.1","platform":"ruby","checksum":"2752f4cce05fd1c45e0537c6f3a98fa5a4899efd5f88e63c104ed5f05cbddef9"}, {"name":"net-imap","version":"0.3.4","platform":"ruby","checksum":"a82a59e2a429433dc54cae5a8b2979ffe49da8c66085740811bfa337dc3729b5"}, {"name":"net-ldap","version":"0.17.1","platform":"ruby","checksum":"52571b55f9157120833ac1667f2969ce0139251811d0a9b64657c1c135069cf9"}, diff --git a/Gemfile.lock b/Gemfile.lock index 40a2741aa94d45b6a16da0d847565cb1df0a4546..6ba258f78aa26d9a5364795189d0fdf0002187ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,7 @@ PATH concurrent-ruby (~> 1.2) httparty (~> 0.21.0) ipaddress (~> 0.8.3) + net-http (= 0.4.1) railties (~> 7) PATH @@ -1115,8 +1116,7 @@ GEM neighbor (0.3.2) activerecord (>= 6.1) nenv (0.3.0) - net-http (0.1.1) - net-protocol + net-http (0.4.1) uri net-http-persistent (4.0.1) connection_pool (~> 2.2) @@ -2095,7 +2095,7 @@ DEPENDENCIES minitest (~> 5.11.0) multi_json (~> 1.14.1) neighbor (~> 0.3.2) - net-http (= 0.1.1) + net-http (= 0.4.1) net-ldap (~> 0.17.1) net-ntp net-protocol (~> 0.1.3) diff --git a/gems/gitlab-http/Gemfile.lock b/gems/gitlab-http/Gemfile.lock index 149a6805f05515e321ccebec410182faa91c7862..afdf6f3dce28231a872c12b46f2dd5aae662cfae 100644 --- a/gems/gitlab-http/Gemfile.lock +++ b/gems/gitlab-http/Gemfile.lock @@ -23,6 +23,7 @@ PATH concurrent-ruby (~> 1.2) httparty (~> 0.21.0) ipaddress (~> 0.8.3) + net-http (= 0.4.1) railties (~> 7) GEM @@ -82,6 +83,8 @@ GEM mini_mime (1.1.2) minitest (5.18.1) multi_xml (0.6.0) + net-http (0.4.1) + uri nokogiri (1.15.4) racc (~> 1.4) parallel (1.23.0) @@ -165,10 +168,12 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.4.2) + uri (0.13.0) webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.8.1) zeitwerk (2.6.8) PLATFORMS @@ -183,6 +188,7 @@ DEPENDENCIES rubocop (~> 1.50.2) rubocop-rspec (~> 2.22) webmock (~> 3.18.1) + webrick (~> 1.8) BUNDLED WITH 2.4.14 diff --git a/gems/gitlab-http/gitlab-http.gemspec b/gems/gitlab-http/gitlab-http.gemspec index 27366246bb1a2d72297a5a8c6de5bab6f56cc9dd..2378945cbcc35954992cad6f108a7aa603484ebe 100644 --- a/gems/gitlab-http/gitlab-http.gemspec +++ b/gems/gitlab-http/gitlab-http.gemspec @@ -24,10 +24,13 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'httparty', '~> 0.21.0' spec.add_runtime_dependency 'ipaddress', '~> 0.8.3' spec.add_runtime_dependency "railties", "~> 7" + # See lib/net_http/connect_patch.rb + spec.add_runtime_dependency "net-http", "= 0.4.1" spec.add_development_dependency 'gitlab-styles', '~> 10.1.0' spec.add_development_dependency 'rspec-rails', '~> 6.0.3' spec.add_development_dependency "rubocop", "~> 1.50.2" spec.add_development_dependency "rubocop-rspec", "~> 2.22" spec.add_development_dependency 'webmock', '~> 3.18.1' + spec.add_development_dependency 'webrick', '~> 1.8' end diff --git a/gems/gitlab-http/lib/gitlab/http_v2/patches.rb b/gems/gitlab-http/lib/gitlab/http_v2/patches.rb index 3d26fbc6447d3ec0c6edd116f02b541238a7bdd7..cd89b2e1b4446526a94a16dfc6c25ff39d497688 100644 --- a/gems/gitlab-http/lib/gitlab/http_v2/patches.rb +++ b/gems/gitlab-http/lib/gitlab/http_v2/patches.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true require_relative "../../hostname_override_patch" -require_relative "../../net_http/protocol_patch" +require_relative "../../net_http/connect_patch" require_relative "../../net_http/response_patch" require_relative "../../httparty/response_patch" diff --git a/gems/gitlab-http/lib/net_http/connect_patch.rb b/gems/gitlab-http/lib/net_http/connect_patch.rb new file mode 100644 index 0000000000000000000000000000000000000000..e737f0fcedffef04a37d32df6f45bf8741679981 --- /dev/null +++ b/gems/gitlab-http/lib/net_http/connect_patch.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +# This patches Net::HTTP#connect to handle the hostname override patch, +# which is needed for Server Side Request Forgery (SSRF) +# protection. This stopped working in net-http v0.2.2 due to +# https://github.com/ruby/net-http/pull/36. +# https://github.com/ruby/net-http/issues/141 is outstanding to make +# this less hacky, but for now we restore the previous behavior by +# setting the SNI hostname with the hostname override, if available. +require 'net/http' + +module Net + class HTTP < Protocol + # rubocop:disable Cop/LineBreakAroundConditionalBlock -- This is upstream code + # rubocop:disable Layout/ArgumentAlignment -- This is upstream code + # rubocop:disable Layout/AssignmentIndentation -- This is upstream code + # rubocop:disable Layout/LineEndStringConcatenationIndentation -- This is upstream code + # rubocop:disable Layout/MultilineOperationIndentation -- This is upstream code + # rubocop:disable Layout/SpaceInsideBlockBraces -- This is upstream code + # rubocop:disable Lint/UnusedBlockArgument -- This is upstream code + # rubocop:disable Metrics/AbcSize -- This is upstream code + # rubocop:disable Metrics/CyclomaticComplexity -- This is upstream code + # rubocop:disable Metrics/PerceivedComplexity -- This is upstream code + # rubocop:disable Naming/RescuedExceptionsVariableName -- This is upstream code + # rubocop:disable Style/AndOr -- This is upstream code + # rubocop:disable Style/BlockDelimiters -- This is upstream code + # rubocop:disable Style/EmptyLiteral -- This is upstream code + # rubocop:disable Style/IfUnlessModifier -- This is upstream code + # rubocop:disable Style/LineEndConcatenation -- This is upstream code + # rubocop:disable Style/MultilineIfThen -- This is upstream code + # rubocop:disable Style/Next -- This is upstream code + # rubocop:disable Style/RescueStandardError -- This is upstream code + # rubocop:disable Style/StringConcatenation -- This is upstream code + def connect + if use_ssl? + # reference early to load OpenSSL before connecting, + # as OpenSSL may take time to load. + @ssl_context = OpenSSL::SSL::SSLContext.new + end + + if proxy? then + conn_addr = proxy_address + conn_port = proxy_port + else + conn_addr = conn_address + conn_port = port + end + + debug "opening connection to #{conn_addr}:#{conn_port}..." + s = Timeout.timeout(@open_timeout, Net::OpenTimeout) { + begin + TCPSocket.open(conn_addr, conn_port, @local_host, @local_port) + rescue => e + raise e, "Failed to open TCP connection to " + + "#{conn_addr}:#{conn_port} (#{e.message})" + end + } + s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + debug "opened" + if use_ssl? + if proxy? + plain_sock = BufferedIO.new(s, read_timeout: @read_timeout, + write_timeout: @write_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + buf = +"CONNECT #{conn_address}:#{@port} HTTP/#{HTTPVersion}\r\n" \ + "Host: #{@address}:#{@port}\r\n" + if proxy_user + credential = ["#{proxy_user}:#{proxy_pass}"].pack('m0') + buf << "Proxy-Authorization: Basic #{credential}\r\n" + end + buf << "\r\n" + plain_sock.write(buf) + HTTPResponse.read_new(plain_sock).value + # assuming nothing left in buffers after successful CONNECT response + end + + ssl_parameters = Hash.new + iv_list = instance_variables + SSL_IVNAMES.each_with_index do |ivname, i| + if iv_list.include?(ivname) + value = instance_variable_get(ivname) + unless value.nil? + ssl_parameters[SSL_ATTRIBUTES[i]] = value + end + end + end + @ssl_context.set_params(ssl_parameters) + unless @ssl_context.session_cache_mode.nil? # a dummy method on JRuby + @ssl_context.session_cache_mode = + OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | + OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE + end + if @ssl_context.respond_to?(:session_new_cb) # not implemented under JRuby + @ssl_context.session_new_cb = proc {|sock, sess| @ssl_session = sess } + end + + # Still do the post_connection_check below even if connecting + # to IP address + verify_hostname = @ssl_context.verify_hostname + + # This hack would not be needed with https://github.com/ruby/net-http/issues/141 + address_to_verify = hostname_override || @address + + # Server Name Indication (SNI) RFC 3546/6066 + case address_to_verify + when Resolv::IPv4::Regex, Resolv::IPv6::Regex + # don't set SNI, as IP addresses in SNI is not valid + # per RFC 6066, section 3. + + # Avoid openssl warning + @ssl_context.verify_hostname = false + else + ssl_host_address = address_to_verify + end + + debug "starting SSL for #{conn_addr}:#{conn_port}..." + s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) + s.sync_close = true + s.hostname = ssl_host_address if s.respond_to?(:hostname=) && ssl_host_address + + if @ssl_session and + Process.clock_gettime(Process::CLOCK_REALTIME) < @ssl_session.time.to_f + @ssl_session.timeout + s.session = @ssl_session + end + ssl_socket_connect(s, @open_timeout) + if (@ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE) && verify_hostname + s.post_connection_check(@address) + end + debug "SSL established, protocol: #{s.ssl_version}, cipher: #{s.cipher[0]}" + end + @socket = BufferedIO.new(s, read_timeout: @read_timeout, + write_timeout: @write_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + @last_communicated = nil + on_connect + rescue => exception + if s + debug "Conn close because of connect error #{exception}" + s.close + end + raise + end + private :connect + # rubocop:enable Cop/LineBreakAroundConditionalBlock + # rubocop:enable Layout/ArgumentAlignment + # rubocop:enable Layout/AssignmentIndentation + # rubocop:enable Layout/LineEndStringConcatenationIndentation + # rubocop:enable Layout/MultilineOperationIndentation + # rubocop:enable Layout/SpaceInsideBlockBraces + # rubocop:enable Lint/UnusedBlockArgument + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Naming/RescuedExceptionsVariableName + # rubocop:enable Style/AndOr + # rubocop:enable Style/BlockDelimiters + # rubocop:enable Style/EmptyLiteral + # rubocop:enable Style/IfUnlessModifier + # rubocop:enable Style/LineEndConcatenation + # rubocop:enable Style/MultilineIfThen + # rubocop:enable Style/Next + # rubocop:enable Style/RescueStandardError + # rubocop:enable Style/StringConcatenation + end +end diff --git a/gems/gitlab-http/lib/net_http/protocol_patch.rb b/gems/gitlab-http/lib/net_http/protocol_patch.rb deleted file mode 100644 index 8231423e1a518d8041bb0833a93c6c5876529393..0000000000000000000000000000000000000000 --- a/gems/gitlab-http/lib/net_http/protocol_patch.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# Monkey patch Net::HTTP to fix missing URL decoding for username and password in proxy settings -# -# See proposed upstream fix https://github.com/ruby/net-http/pull/5 -# See Ruby-lang issue https://bugs.ruby-lang.org/issues/17542 -# See issue on GitLab https://gitlab.com/gitlab-org/gitlab/-/issues/289836 - -require 'net/http' - -# This file can be removed once Ruby 3.0 is no longer supported: -# https://gitlab.com/gitlab-org/gitlab/-/issues/396223 -return if Gem::Version.new(Net::HTTP::VERSION) >= Gem::Version.new('0.2.0') - -module Net - class HTTP < Protocol - def proxy_user - if environment_variable_is_multiuser_safe? && @proxy_from_env - user = proxy_uri&.user - CGI.unescape(user) unless user.nil? - else - @proxy_user - end - end - - def proxy_pass - if environment_variable_is_multiuser_safe? && @proxy_from_env - pass = proxy_uri&.password - CGI.unescape(pass) unless pass.nil? - else - @proxy_pass - end - end - - def environment_variable_is_multiuser_safe? - ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE - end - end -end diff --git a/gems/gitlab-http/spec/gitlab/http_v2/net_http_connect_patch_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/net_http_connect_patch_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..548c33058bed03328cdafa630ad09172d2121abf --- /dev/null +++ b/gems/gitlab-http/spec/gitlab/http_v2/net_http_connect_patch_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webrick' +require 'webrick/https' + +RSpec.describe 'Net::HTTP#connect DNS rebinding tests', feature_category: :shared do + let(:host) { 'localhost' } + let(:host_ip) { '127.0.0.1' } + let(:rack_app) do + proc do |_env| + ['200', { 'Content-Type' => 'text/plain' }, ['Hello, world!']] + end + end + + let!(:http_server) do + Class.new do + attr_accessor :sni_hostname + + def initialize + @server = WEBrick::HTTPServer.new( + Port: 0, + SSLEnable: true, + SSLCertName: [%w[CN localhost]], + SSLServerNameCallback: proc { |args| sni_callback(*args) }, + Logger: WEBrick::Log.new('/dev/null'), + AccessLog: [] + ) + + @server.mount_proc '/' do |_req, res| + res.body = 'Hello, world!' + end + + Thread.new { @server.start } + end + + def port + @server.config[:Port] + end + + def shutdown + @server.shutdown + end + + def sni_callback(sslsocket, hostname = nil) + @sni_hostname = hostname + @server.ssl_servername_callback(sslsocket, hostname) + end + end.new + end + + describe '#connect' do + before do + WebMock.allow_net_connect! + end + + after do + WebMock.disable_net_connect! # rubocop:disable RSpec/WebMockEnable -- method not available in gem + http_server.shutdown + end + + shared_examples 'GET request' do + it 'makes a successful HTTPS connection' do + http = Net::HTTP.new(http_host, http_server.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.hostname_override = hostname_override if hostname_override + + request = Net::HTTP::Get.new('/') + + response = http.start { http.request(request) } + expect(response.code).to eq('200') + expect(response.body).to include('Hello, world!') + expect(http_server.sni_hostname).to eq(expected_sni) + end + end + + context 'with hostname' do + let(:http_host) { host } + let(:expected_sni) { host } + let(:hostname_override) { nil } + + it_behaves_like 'GET request' + end + + context 'with IP address' do + let(:http_host) { host_ip } + let(:expected_sni) { nil } + let(:hostname_override) { nil } + + it_behaves_like 'GET request' + end + + context 'with hostname override' do + let(:http_host) { host_ip } + let(:hostname_override) { host } + let(:expected_sni) { host } + + it_behaves_like 'GET request' + end + end +end diff --git a/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb deleted file mode 100644 index f34b0d9840359cd73ecb08f0d2be8d33e786fe15..0000000000000000000000000000000000000000 --- a/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'net/http' - -RSpec.describe 'Net::HTTP patch proxy user and password encoding', feature_category: :shared do - let(:net_http) { Net::HTTP.new('hostname.example') } - - before do - # This file can be removed once Ruby 3.0 is no longer supported: - # https://gitlab.com/gitlab-org/gitlab/-/issues/396223 - skip if Gem::Version.new(Net::HTTP::VERSION) >= Gem::Version.new('0.2.0') - end - - describe '#proxy_user' do - subject { net_http.proxy_user } - - it { is_expected.to eq(nil) } - - context 'with http_proxy env' do - let(:http_proxy) { 'http://proxy.example:8000' } - - before do - stub_env('http_proxy', http_proxy) - end - - it { is_expected.to eq(nil) } - - context 'and user:password authentication' do - let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' } - - context 'when on multiuser safe platform' do - # linux, freebsd, darwin are considered multi user safe platforms - # See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178 - - before do - allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true) - end - - it { is_expected.to eq 'Y\\X' } - end - - context 'when not on multiuser safe platform' do - before do - allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false) - end - - it { is_expected.to be_nil } - end - end - end - end - - describe '#proxy_pass' do - subject { net_http.proxy_pass } - - it { is_expected.to eq(nil) } - - context 'with http_proxy env' do - let(:http_proxy) { 'http://proxy.example:8000' } - - before do - stub_env('http_proxy', http_proxy) - end - - it { is_expected.to eq(nil) } - - context 'and user:password authentication' do - let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' } - - context 'when on multiuser safe platform' do - # linux, freebsd, darwin are considered multi user safe platforms - # See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178 - - before do - allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true) - end - - it { is_expected.to eq 'R%S] ?X' } - end - - context 'when not on multiuser safe platform' do - before do - allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false) - end - - it { is_expected.to be_nil } - end - end - end - end -end