diff --git a/lib/net/http.rb b/lib/net/http.rb index 7fd4c3ed..5041181f 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1192,10 +1192,8 @@ def initialize(address, port = nil) # :nodoc: @use_ssl = false @ssl_context = nil @ssl_session = nil + @verify_hostname = true @sspi_enabled = false - SSL_IVNAMES.each do |ivname| - instance_variable_set ivname, nil - end end # Returns a string representation of +self+: @@ -1515,81 +1513,155 @@ def use_ssl=(flag) @use_ssl = flag end - SSL_ATTRIBUTES = [ - :ca_file, - :ca_path, - :cert, - :cert_store, - :ciphers, - :extra_chain_cert, - :key, - :ssl_timeout, - :ssl_version, - :min_version, - :max_version, - :verify_callback, - :verify_depth, - :verify_mode, - :verify_hostname, - ].freeze # :nodoc: - - SSL_IVNAMES = SSL_ATTRIBUTES.map { |a| "@#{a}".to_sym }.freeze # :nodoc: + def ssl_sni_enabled? + case @address + when Resolv::IPv4::Regex, Resolv::IPv6::Regex + return false + end + true + end + private :ssl_sni_enabled? + + # Returns the OpenSSL::SSL::SSLContext object used for SSL/TLS sessions. + # + # The object is frozen after a session is started for the first time and + # cannot be modified afterwards. + def ssl_context + @ssl_context ||= ( + ctx = OpenSSL::SSL::SSLContext.new + ctx.set_params + + # See the note on #verify_hostname + ctx.verify_hostname = verify_hostname && ssl_sni_enabled? + + unless ctx.session_cache_mode.nil? # a dummy method on JRuby + ctx.session_cache_mode = + OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | + OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE + end + if ctx.respond_to?(:session_new_cb) # not implemented under JRuby + ctx.session_new_cb = proc {|sock, sess| @ssl_session = sess } + end + + ctx + ) + end + + def_ssl_attr = proc do |attr| + class_eval <<~EOS, __FILE__, __LINE__ + 1 + def #{attr} + ssl_context.#{attr} + end + def #{attr}=(value) + ssl_context.#{attr} = value + end + EOS + end + + ## + # :attr_accessor: ca_file # Sets or returns the path to a CA certification file in PEM format. - attr_accessor :ca_file + def_ssl_attr.call(:ca_file) + ## + # :attr_accessor: ca_path # Sets or returns the path of to CA directory # containing certification files in PEM format. - attr_accessor :ca_path + def_ssl_attr.call(:ca_path) + ## + # :attr_accessor: cert # Sets or returns the OpenSSL::X509::Certificate object # to be used for client certification. - attr_accessor :cert + def_ssl_attr.call(:cert) + ## + # :attr_accessor: cert_store # Sets or returns the X509::Store to be used for verifying peer certificate. - attr_accessor :cert_store + def_ssl_attr.call(:cert_store) + ## + # :attr_accessor: ciphers # Sets or returns the available SSL ciphers. # See {OpenSSL::SSL::SSLContext#ciphers=}[OpenSSL::SSL::SSL::Context#ciphers=]. - attr_accessor :ciphers + def_ssl_attr.call(:ciphers) + ## + # :attr_accessor: extra_chain_cert # Sets or returns the extra X509 certificates to be added to the certificate chain. # See {OpenSSL::SSL::SSLContext#add_certificate}[OpenSSL::SSL::SSL::Context#add_certificate]. - attr_accessor :extra_chain_cert + def_ssl_attr.call(:extra_chain_cert) + ## + # :attr_accessor: key # Sets or returns the OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. - attr_accessor :key + def_ssl_attr.call(:key) + ## + # :attr_accessor: ssl_timeout # Sets or returns the SSL timeout seconds. - attr_accessor :ssl_timeout + def_ssl_attr.call(:ssl_timeout) + ## + # :attr_accessor: ssl_version # Sets or returns the SSL version. # See {OpenSSL::SSL::SSLContext#ssl_version=}[OpenSSL::SSL::SSL::Context#ssl_version=]. - attr_accessor :ssl_version + def_ssl_attr.call(:ssl_version) + ## + # :attr_accessor: min_version # Sets or returns the minimum SSL version. # See {OpenSSL::SSL::SSLContext#min_version=}[OpenSSL::SSL::SSL::Context#min_version=]. - attr_accessor :min_version + def_ssl_attr.call(:min_version) + ## + # :attr_accessor: max_version # Sets or returns the maximum SSL version. # See {OpenSSL::SSL::SSLContext#max_version=}[OpenSSL::SSL::SSL::Context#max_version=]. - attr_accessor :max_version + def_ssl_attr.call(:max_version) + ## + # :attr_accessor: verify_callback # Sets or returns the callback for the server certification verification. - attr_accessor :verify_callback + def_ssl_attr.call(:verify_callback) + ## + # :attr_accessor: verify_depth # Sets or returns the maximum depth for the certificate chain verification. - attr_accessor :verify_depth + def_ssl_attr.call(:verify_depth) + ## + # :attr_accessor: verify_mode # Sets or returns the flags for server the certification verification # at the beginning of the SSL/TLS session. # OpenSSL::SSL::VERIFY_NONE or OpenSSL::SSL::VERIFY_PEER are acceptable. - attr_accessor :verify_mode + def_ssl_attr.call(:verify_mode) + ## + # :attr_accessor: verify_hostname # Sets or returns whether to verify that the server certificate is valid - # for the hostname. + # for the hostname or the IP address of the server. # See {OpenSSL::SSL::SSLContext#verify_hostname=}[OpenSSL::SSL::SSL::Context#verify_hostname=]. - attr_accessor :verify_hostname + #-- + # This attribute is not forwarded directly to SSLContext because the + # semantics are slightly different. + # - SSLContext#verify_hostname must be used together with SNI, which + # is unavailable when connecting to an IP address. + # - Net::HTTP#verify_hostname is expected to verify the certificate + # identity regardless of whether SNI is used. + #++ + + # :stopdoc: + def verify_hostname + @verify_hostname + end + + def verify_hostname=(value) + @verify_hostname = value + ssl_context.verify_hostname = value && ssl_sni_enabled? + end + # :startdoc: # Returns the X509 certificate chain (an array of strings) # for the session's socket peer, @@ -1661,7 +1733,7 @@ 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 + ssl_context.setup end if proxy? then @@ -1708,53 +1780,21 @@ def connect # 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 - - # Server Name Indication (SNI) RFC 3546/6066 - case @address - 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 - end - debug "starting SSL for #{conn_addr}:#{conn_port}..." - s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) + 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 + # Server Name Indication (SNI) RFC 3546/6066 + s.hostname = @address if ssl_sni_enabled? 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 + # See the note on #verify_hostname + if !ssl_sni_enabled? && + 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]}" diff --git a/test/net/fixtures/Makefile b/test/net/fixtures/Makefile index 88c232e3..2b37c0bb 100644 --- a/test/net/fixtures/Makefile +++ b/test/net/fixtures/Makefile @@ -7,9 +7,12 @@ regen_certs: cacert.pem: server.key openssl req -new -x509 -days 3650 -key server.key -out cacert.pem -subj "/C=JP/ST=Shimane/L=Matz-e city/O=Ruby Core Team/CN=Ruby Test CA/emailAddress=security@ruby-lang.org" -server.csr: - openssl req -new -key server.key -out server.csr -subj "/C=JP/ST=Shimane/O=Ruby Core Team/OU=Ruby Test/CN=localhost" +server.crt: cacert.pem + openssl req -new -key server.key -out server.csr -subj "/C=JP/ST=Shimane/O=Ruby Core Team/OU=Ruby Test/CN=Server 1" -addext "subjectAltName = DNS:localhost" + openssl x509 -days 3650 -CA cacert.pem -CAkey server.key -set_serial 00 -in server.csr -req -copy_extensions copy -out server.crt + rm server.csr -server.crt: server.csr cacert.pem - openssl x509 -days 3650 -CA cacert.pem -CAkey server.key -set_serial 00 -in server.csr -req -out server.crt +server_ip_san.crt: cacert.pem + openssl req -new -key server.key -out server.csr -subj "/C=JP/ST=Shimane/O=Ruby Core Team/OU=Ruby Test/CN=Server 2" -addext "subjectAltName = IP:127.0.0.1,IP:::1" + openssl x509 -days 3650 -CA cacert.pem -CAkey server.key -set_serial 01 -in server.csr -req -copy_extensions copy -out server_ip_san.crt rm server.csr diff --git a/test/net/fixtures/server.crt b/test/net/fixtures/server.crt index 5d292379..23d5efd5 100644 --- a/test/net/fixtures/server.crt +++ b/test/net/fixtures/server.crt @@ -1,21 +1,22 @@ -----BEGIN CERTIFICATE----- -MIIDYTCCAkkCAQAwDQYJKoZIhvcNAQELBQAwgYwxCzAJBgNVBAYTAkpQMRAwDgYD -VQQIDAdTaGltYW5lMRQwEgYDVQQHDAtNYXR6LWUgY2l0eTEXMBUGA1UECgwOUnVi -eSBDb3JlIFRlYW0xFTATBgNVBAMMDFJ1YnkgVGVzdCBDQTElMCMGCSqGSIb3DQEJ -ARYWc2VjdXJpdHlAcnVieS1sYW5nLm9yZzAeFw0yNDAxMDExMTQ3MjNaFw0zMzEy -MjkxMTQ3MjNaMGAxCzAJBgNVBAYTAkpQMRAwDgYDVQQIDAdTaGltYW5lMRcwFQYD -VQQKDA5SdWJ5IENvcmUgVGVhbTESMBAGA1UECwwJUnVieSBUZXN0MRIwEAYDVQQD -DAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCw+egZ -Q6eumJKq3hfKfED4dE/tL4FI5sjqont9ABVI+1GSqyi1bFBgsRjM0THllIdMbKmJ -tWwnKW8J+5OgNN8y6Xxv8JmM/Y5vQt2lis0fqXmG8UTz0VTWdlAXXmhUs6lSADvA -aIe4RVrCsZ97L3ZQTryY7JRVcbB4khUN3Gp0yg+801SXzoFTTa+UGIRLE66jH51a -a5VXu99hnv1OiH8tQrjdi8mH6uG/icq4XuIeNWMF32wHqIOOPvQcWV3M5D2vxJEj -702Ku6k9OQXkAo17qRSEonWW4HtLbtmS8He1JNPc/n3dVUm+fM6NoDXPoLP7j55G -9zKyqGtGAWXAj1MTAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACtGNdj5TEtnJBYp -M+LhBeU3oNteldfycEm993gJp6ghWZFg23oX8fVmyEeJr/3Ca9bAgDqg0t9a0npN -oWKEY6wVKqcHgu3gSvThF5c9KhGbeDDmlTSVVNQmXWX0K2d4lS2cwZHH8mCm2mrY -PDqlEkSc7k4qSiqigdS8i80Yk+lDXWsm8CjsiC93qaRM7DnS0WPQR0c16S95oM6G -VklFKUSDAuFjw9aVWA/nahOucjn0w5fVW6lyIlkBslC1ChlaDgJmvhz+Ol3iMsE0 -kAmFNu2KKPVrpMWaBID49QwQTDyhetNLaVVFM88iUdA9JDoVMEuP1mm39JqyzHTu -uBrdP4Q= +MIIDnjCCAoagAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCSlAx +EDAOBgNVBAgMB1NoaW1hbmUxFDASBgNVBAcMC01hdHotZSBjaXR5MRcwFQYDVQQK +DA5SdWJ5IENvcmUgVGVhbTEVMBMGA1UEAwwMUnVieSBUZXN0IENBMSUwIwYJKoZI +hvcNAQkBFhZzZWN1cml0eUBydWJ5LWxhbmcub3JnMB4XDTI2MDYyMTEzMTQ0M1oX +DTM2MDYxODEzMTQ0M1owXzELMAkGA1UEBhMCSlAxEDAOBgNVBAgMB1NoaW1hbmUx +FzAVBgNVBAoMDlJ1YnkgQ29yZSBUZWFtMRIwEAYDVQQLDAlSdWJ5IFRlc3QxETAP +BgNVBAMMCFNlcnZlciAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +sPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqsotWxQYLEYzNEx5ZSH +TGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE89FU1nZQF15oVLOp +UgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNUl86BU02vlBiESxOu +ox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9sB6iDjj70HFldzOQ9 +r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P593VVJvnzOjaA1z6Cz ++4+eRvcysqhrRgFlwI9TEwIDAQABozcwNTAUBgNVHREEDTALgglsb2NhbGhvc3Qw +HQYDVR0OBBYEFIkZWV4O8Wn1y71H4TT84pjMaTCRMA0GCSqGSIb3DQEBCwUAA4IB +AQCUav+Av881U4D5HXYaU4JH3AYLP3pmaCAnaASbkCV5WjjIo8A4jLC5FrgibaQc +4vw4JdHaO8JK05qb61rtzQLPyk8fIrV8LAcisBMBRnawgo98EB6OVXjpgKSRm4YU +7SlYpnO+D9jMc1ECl8K1ILQyuT4+zgEFREZLNGjKuaJXDExD8JPhT7DumJ5XMGoI +2r2YWCfnsef1RbGtze30nAJqcf6XrHyn95VU70AV45yXuE/FOZDb8dX90n1K3Frm +ebqT0Q8smwLB+R9+BhWjhlW3oj8hLtsLBPz5U7Fp4rg2nMoOSvrrqgkRyhoxzJie +R+WNFodMHu0PxkmSGD37G76S -----END CERTIFICATE----- diff --git a/test/net/fixtures/server_ip_san.crt b/test/net/fixtures/server_ip_san.crt new file mode 100644 index 00000000..505a08cd --- /dev/null +++ b/test/net/fixtures/server_ip_san.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDqzCCApOgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCSlAx +EDAOBgNVBAgMB1NoaW1hbmUxFDASBgNVBAcMC01hdHotZSBjaXR5MRcwFQYDVQQK +DA5SdWJ5IENvcmUgVGVhbTEVMBMGA1UEAwwMUnVieSBUZXN0IENBMSUwIwYJKoZI +hvcNAQkBFhZzZWN1cml0eUBydWJ5LWxhbmcub3JnMB4XDTI2MDYyMTEzMTQ0M1oX +DTM2MDYxODEzMTQ0M1owXzELMAkGA1UEBhMCSlAxEDAOBgNVBAgMB1NoaW1hbmUx +FzAVBgNVBAoMDlJ1YnkgQ29yZSBUZWFtMRIwEAYDVQQLDAlSdWJ5IFRlc3QxETAP +BgNVBAMMCFNlcnZlciAyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +sPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqsotWxQYLEYzNEx5ZSH +TGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE89FU1nZQF15oVLOp +UgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNUl86BU02vlBiESxOu +ox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9sB6iDjj70HFldzOQ9 +r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P593VVJvnzOjaA1z6Cz ++4+eRvcysqhrRgFlwI9TEwIDAQABo0QwQjAhBgNVHREEGjAYhwR/AAABhxAAAAAA +AAAAAAAAAAAAAAABMB0GA1UdDgQWBBSJGVleDvFp9cu9R+E0/OKYzGkwkTANBgkq +hkiG9w0BAQsFAAOCAQEAi8zbc22L5+8UGjXftk2Dfg814rsholLK4h8eXHptRh1k +cqRJ0DrXn1SbiavBAkRUBeh07CtSOaxq/LP9ZiJHAcZ99+ZDVG0BRmg4esh9LEiV +FuM7piCfdsDRBfzk25Ko8lQEq1l3aIUSzMpaQV8OvZC51JM8EBGxj3r6n5iZf0b9 +3yKVZme1l2mnMSvCeJ9VY1FCnIEwF1WTzBgOQLQOu4zRlR1O0xtn4+KIL79F7XSE +HB0Sni8krQA7tDprMQsKLoLmvPpFrkTHyR3D7/BTvsE3N0k6cPV9WJTg4wOyT4JC +7Gm1sdFNKo2MhdE/YXKqb5+rv2ILfezwtdcVlB23wQ== +-----END CERTIFICATE----- diff --git a/test/net/http/test_https.rb b/test/net/http/test_https.rb index f5b21b90..eb5f2727 100644 --- a/test/net/http/test_https.rb +++ b/test/net/http/test_https.rb @@ -256,16 +256,37 @@ def test_min_version def test_max_version http = Net::HTTP.new(HOST, config("port")) http.use_ssl = true - http.max_version = :SSL2 + begin + http.max_version = :SSL2 + rescue OpenSSL::SSL::SSLError => e + # May fail early if SSLv2 is not supported at all by the OpenSSL release + return if /SSL_CTX_set_max_proto_version/ =~ e.message + raise + end http.cert_store = TEST_STORE @log_tester = lambda {|_| } ex = assert_raise(OpenSSL::SSL::SSLError){ http.request_get("/") {|res| } } - re_msg = /\ASSL_connect returned=1 errno=0 |SSL_CTX_set_max_proto_version|No appropriate protocol/ + re_msg = /\ASSL_connect returned=1 errno=0 |No appropriate protocol/ assert_match(re_msg, ex.message) end + def test_ssl_context + http = Net::HTTP.new(HOST, config("port")) + http.use_ssl = true + http.cert_store = TEST_STORE + assert_same(TEST_STORE, http.ssl_context.cert_store) + http.ssl_context.cert_store = OpenSSL::X509::Store.new # empty + assert_raise(OpenSSL::SSL::SSLError) { + http.request_get("/") {|res| } + } + assert_predicate(http.ssl_context, :frozen?) + assert_raise(FrozenError) { + http.cert_store = OpenSSL::X509::Store.new + } + end + def test_ractor assert_ractor(<<~RUBY, require: 'net/https') expected = #{$test_net_http_data.dump}.b @@ -286,18 +307,19 @@ def test_ractor end if defined?(Ractor) && Ractor.method_defined?(:value) end -class TestNetHTTPSIdentityVerifyFailure < Test::Unit::TestCase +class TestNetHTTPSIPAddressCertificate < Test::Unit::TestCase include TestNetHTTPUtils def self.read_fixture(key) File.read(File.expand_path("../fixtures/#{key}", __dir__)) end + # Certificate subjectAltName contains IP addresses 127.0.0.1 but no DNS names HOST = 'localhost' HOST_IP = '127.0.0.1' CA_CERT = OpenSSL::X509::Certificate.new(read_fixture("cacert.pem")) SERVER_KEY = OpenSSL::PKey.read(read_fixture("server.key")) - SERVER_CERT = OpenSSL::X509::Certificate.new(read_fixture("server.crt")) + SERVER_CERT = OpenSSL::X509::Certificate.new(read_fixture("server_ip_san.crt")) TEST_STORE = OpenSSL::X509::Store.new.tap {|s| s.add_cert(CA_CERT) } CONFIG = { @@ -309,17 +331,36 @@ def self.read_fixture(key) 'ssl_private_key' => SERVER_KEY, } - def test_identity_verify_failure - # the certificate's subject has CN=localhost + def test_identity_verify_success http = Net::HTTP.new(HOST_IP, config("port")) http.use_ssl = true http.cert_store = TEST_STORE + http.request_get("/") {|res| + assert_equal($test_net_http_data, res.body) + } + end + + def test_identity_verify_failure + http = Net::HTTP.new("172.0.0.2", config("port")) + http.ipaddr = HOST_IP + http.use_ssl = true + http.cert_store = TEST_STORE @log_tester = lambda {|_| } ex = assert_raise(OpenSSL::SSL::SSLError){ http.request_get("/") {|res| } - sleep 0.5 } - re_msg = /certificate verify failed|hostname \"#{HOST_IP}\" does not match/ + re_msg = /certificate verify failed|hostname .* does not match/ assert_match(re_msg, ex.message) end + + def test_identity_verify_disabled + http = Net::HTTP.new("172.0.0.2", config("port")) + http.ipaddr = HOST_IP + http.use_ssl = true + http.cert_store = TEST_STORE + http.verify_hostname = false + http.request_get("/") {|res| + assert_equal($test_net_http_data, res.body) + } + end end