From 36bd4052be0ff07742c5d8bca68b58c9ce69628c Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Fri, 30 Oct 2026 11:25:46 +0900 Subject: [PATCH 1/3] Use OpenSSL trust store by default (#480) --- lib/httpclient/ssl_config.rb | 14 ++++++++++-- test/test_ssl.rb | 4 ++++ test/test_ssl_config.rb | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 29944427..5b206b4d 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -116,6 +116,7 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default + BUNDLED_CACERT_FILE = File.join(File.dirname(__FILE__), 'cacert.pem').freeze # Public keys whose SPKI SHA-256 hashes are listed below are considered blocked. # The list covers eight eMudhra intermediate CA public keys whose corresponding @@ -200,6 +201,7 @@ def self.blocked_public_key?(public_key_der) # OpenSSL::X509::X509::Store used for verification. You can reset the # store with clear_cert_store and set the new store with cert_store=. attr_reader :cert_store # don't use if you don't know what it is. + attr_reader :cacert_file # For server side configuration. Ignore this. attr_config :client_ca # :nodoc: @@ -214,6 +216,7 @@ def cert_store_items; @cert_store._httpclient_cert_store_items; end def initialize(client) return unless SSLEnabled @client = client + @cacert_file = X509::DEFAULT_CERT_FILE @cert_store = X509::Store.new @cert_store_crl_items = [] @client_cert = @client_key = @client_key_pass = @client_ca = nil @@ -309,6 +312,14 @@ def add_trust_ca(trust_ca_file_or_hashed_dir) end alias set_trust_ca add_trust_ca + def cacert_file=(file) + store = X509::Store.new + store._httpclient_cert_store_items.clear if defined?(JRUBY_VERSION) + add_trust_ca_to_store(store, file) + @cacert_file = file + self.cert_store = store + end + def add_trust_ca_to_store(cert_store, trust_ca_file_or_hashed_dir) if FileTest.directory?(trust_ca_file_or_hashed_dir) cert_store.add_path(trust_ca_file_or_hashed_dir) @@ -523,8 +534,7 @@ def change_notify # Use 2048 bit certs trust anchor def load_cacerts(cert_store) - file = File.join(File.dirname(__FILE__), 'cacert.pem') - add_trust_ca_to_store(cert_store, file) + add_trust_ca_to_store(cert_store, @cacert_file) end end diff --git a/test/test_ssl.rb b/test/test_ssl.rb index 35cd23d2..71af804c 100644 --- a/test/test_ssl.rb +++ b/test/test_ssl.rb @@ -106,6 +106,8 @@ def test_blocked_peer_certificate_before_request def test_blocked_intermediate_with_default_paths escape_env do + # Nix's OpenSSL can prioritize this CA bundle path over SSL_CERT_FILE. + ENV.delete('NIX_SSL_CERT_FILE') ENV['SSL_CERT_FILE'] = path('ca-chain.pem') @client.ssl_config.set_default_paths @server.ssl_context.extra_chain_cert = [cert('subca.cert')] @@ -370,6 +372,8 @@ def test_set_default_paths @client.get(@url) end escape_env do + # Nix's OpenSSL can prioritize this CA bundle path over SSL_CERT_FILE. + ENV.delete('NIX_SSL_CERT_FILE') ENV['SSL_CERT_FILE'] = File.join(DIR, 'ca-chain.pem') @client.ssl_config.set_default_paths @client.get(@url) diff --git a/test/test_ssl_config.rb b/test/test_ssl_config.rb index 67b82d2c..94621b88 100644 --- a/test/test_ssl_config.rb +++ b/test/test_ssl_config.rb @@ -49,3 +49,46 @@ def test_blocked_cross_signed_keys_without_der_export end end end + +# JRuby uses its JVM trust manager instead of SSLConfig#set_context. +unless defined?(JRUBY_VERSION) + class TestOpenSSLTrustStore < Test::Unit::TestCase + def setup + @client = HTTPClient.new + @config = @client.ssl_config + @loaded_files = [] + loaded_files = @loaded_files + @config.define_singleton_method(:add_trust_ca_to_store) do |_store, file| + loaded_files << file + end + end + + def teardown + @client.reset_all if @client + end + + def test_default_cert_file_is_loaded_once + configure_context + configure_context + assert_equal([OpenSSL::X509::DEFAULT_CERT_FILE], @loaded_files) + end + + def test_bundled_ca_can_be_selected_with_cacert_file + @config.cacert_file = bundled_ca_file + configure_context + assert_equal([bundled_ca_file], @loaded_files) + end + + private + + def configure_context + context = OpenSSL::SSL::SSLContext.new + @config.set_context(context) + assert_same(@config.cert_store, context.cert_store) + end + + def bundled_ca_file + HTTPClient::SSLConfig::BUNDLED_CACERT_FILE + end + end +end From 57ba09535d8debe8fa653fd24f7b49888a8e6925 Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Fri, 30 Oct 2026 11:31:34 +0900 Subject: [PATCH 2/3] Document OpenSSL trust store defaults (#480) --- CHANGELOG.md | 2 ++ README.md | 28 +++++++++++++++++++----- lib/httpclient/ssl_config.rb | 41 +++++++++++++++++++----------------- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c44a50c..503b3404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ * Refresh the bundled Mozilla CA certificates to the October 20, 2026 snapshot, building on [#444](https://github.com/nahi/httpclient/pull/444). +* Use `OpenSSL::X509::DEFAULT_CERT_FILE` by default on MRI. `SSLConfig#cacert_file=` can select another CA file, including the bundled certificates via `SSLConfig::BUNDLED_CACERT_FILE` ([#480](https://github.com/nahi/httpclient/issues/480)). + * With peer verification enabled and no custom verification callback, reject TLS connections when a server supplies certificates containing blocked eMudhra intermediate CA public keys, including cross-signed variants ([#480](https://github.com/nahi/httpclient/issues/480)). Connections are also rejected when a server supplies an unused certificate containing a blocked public key. `VERIFY_NONE`/`nil` disables this check; a custom verification callback replaces the default policy and is responsible for certificate validation. diff --git a/README.md b/README.md index 80e695b6..0f15f5d6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,25 @@ You can install httpclient via rubygems: `gem install httpclient` See [HTTPClient](http://www.rubydoc.info/gems/httpclient/frames) for documentation. You can also check sample/howto.rb how to use APIs. +### TLS trust store + +On MRI, HTTPClient uses `OpenSSL::X509::DEFAULT_CERT_FILE` by default. To +preserve compatibility with previous releases' default CA selection, select the +bundled Mozilla CA certificates explicitly: + +```ruby +client = HTTPClient.new +client.ssl_config.cacert_file = HTTPClient::SSLConfig::BUNDLED_CACERT_FILE +``` + +Assigning `cacert_file` loads that file into a new certificate store, replacing +the current store and resetting existing sessions. You can assign it after a +connection has been made. `load_trust_ca` adds the selected file to the current +store and resets existing sessions. On JRuby, the default trust store remains +the JVM's; assigning `cacert_file` explicitly selects a certificate file. Use +`client.ssl_config.set_default_paths` on MRI to load OpenSSL's configured +default certificate file and directory. + ### TLS certificate restrictions Blocked public keys are identified by their SPKI SHA-256 hashes in @@ -91,11 +110,10 @@ context. With peer verification enabled, its return value controls certificate verification. The caller is responsible for the custom callback's validation policy. Hostname verification is unchanged. -Note: MRI uses HTTPClient's bundled `cacert.pem` by default, while JRuby uses -the JVM's CA certificates and certificate validation. Secure operation on JRuby -depends on keeping the JVM and its CA certificates up to date. HTTPClient's -additional check may not detect blocked intermediates found only in a local -trust store or obtained through JVM issuer retrieval (such as AIA when enabled). +On JRuby, HTTPClient uses the JVM's CA certificates and validation by default. +Keep the JVM and its CA certificates up to date. The additional blocked-key +check may not detect intermediates found only in a local trust store or obtained +through JVM issuer retrieval (such as AIA when enabled). Remove unused certificates containing blocked public keys from the server's certificate chain configuration. If a certificate containing a blocked public key diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 5b206b4d..829b772d 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -20,23 +20,20 @@ class HTTPClient # # == Trust Anchor Control # - # SSLConfig loads 'httpclient/cacert.pem' as a trust anchor - # (trusted certificate(s)) with add_trust_ca in initialization time. - # This means that HTTPClient instance trusts some CA certificates by default, - # like Web browsers. 'httpclient/cacert.pem' is downloaded from curl web - # site by the author and included in released package. + # On MRI, HTTPClient uses OpenSSL::X509::DEFAULT_CERT_FILE by default. To + # preserve compatibility with previous releases' default CA selection, + # explicitly select the bundled Mozilla CA certificates: # - # On JRuby, HTTPClient uses Java runtime's trusted CA certificates, not - # cacert.pem by default. You can load cacert.pem by calling - # SSLConfig#load_trust_ca manually like: + # client = HTTPClient.new + # client.ssl_config.cacert_file = HTTPClient::SSLConfig::BUNDLED_CACERT_FILE # - # HTTPClient.new { self.ssl_config.load_trust_ca }.get("https://...") + # Assigning cacert_file loads the selected file into a new certificate store, + # replacing the current store and resetting existing sessions. It can be + # changed after connections have been made. load_trust_ca loads the selected + # file into the current store, adding to its existing certificates. # - # Secure operation on JRuby depends on keeping the JVM and its CA certificates - # up to date. - # - # You may want to change trust anchor by yourself. Call clear_cert_store - # then add_trust_ca for that purpose. + # On JRuby, HTTPClient uses the JVM's trusted CA certificates by default. + # Assign cacert_file explicitly to use a certificate file instead. # # == TLS certificate restrictions # @@ -116,6 +113,7 @@ def attr_config(symbol) CIPHERS_DEFAULT = "ALL:!aNULL:!eNULL:!SSLv2" # OpenSSL >1.0.0 default + # Bundled Mozilla CA certificates, available for explicit selection. BUNDLED_CACERT_FILE = File.join(File.dirname(__FILE__), 'cacert.pem').freeze # Public keys whose SPKI SHA-256 hashes are listed below are considered blocked. @@ -198,9 +196,11 @@ def self.blocked_public_key?(public_key_der) # See ciphers(1) man in OpenSSL for more detail. attr_config :ciphers - # OpenSSL::X509::X509::Store used for verification. You can reset the - # store with clear_cert_store and set the new store with cert_store=. + # OpenSSL::X509::Store used for verification. You can reset the store with + # clear_cert_store and set the new store with cert_store=. attr_reader :cert_store # don't use if you don't know what it is. + # Selected CA certificate file. Defaults to OpenSSL::X509::DEFAULT_CERT_FILE + # on MRI. attr_reader :cacert_file # For server side configuration. Ignore this. @@ -312,6 +312,9 @@ def add_trust_ca(trust_ca_file_or_hashed_dir) end alias set_trust_ca add_trust_ca + # Selects a CA certificate file. Replaces the current certificate store + # with a new store loaded from file and resets existing sessions. If loading + # fails, the current store and selected file are unchanged. def cacert_file=(file) store = X509::Store.new store._httpclient_cert_store_items.clear if defined?(JRUBY_VERSION) @@ -328,8 +331,9 @@ def add_trust_ca_to_store(cert_store, trust_ca_file_or_hashed_dir) end end - # Loads default trust anchors. - # Calling this method resets all existing sessions. + # Loads the selected cacert_file into the current certificate store. + # Existing certificates in the store are kept. Calling this method resets + # all existing sessions. def load_trust_ca load_cacerts(@cert_store) change_notify @@ -532,7 +536,6 @@ def change_notify nil end - # Use 2048 bit certs trust anchor def load_cacerts(cert_store) add_trust_ca_to_store(cert_store, @cacert_file) end From 7062fd252c7db0c66b76ba8662043864dbdb52bb Mon Sep 17 00:00:00 2001 From: "Yamashita, Yuu" Date: Mon, 2 Nov 2026 12:02:50 +0900 Subject: [PATCH 3/3] Preserve JRuby default CA selection --- README.md | 7 +++--- lib/httpclient/ssl_config.rb | 13 +++++------ test/test_ssl_config.rb | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0f15f5d6..60d7989c 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,10 @@ client.ssl_config.cacert_file = HTTPClient::SSLConfig::BUNDLED_CACERT_FILE Assigning `cacert_file` loads that file into a new certificate store, replacing the current store and resetting existing sessions. You can assign it after a -connection has been made. `load_trust_ca` adds the selected file to the current -store and resets existing sessions. On JRuby, the default trust store remains -the JVM's; assigning `cacert_file` explicitly selects a certificate file. Use +connection has been made. `load_trust_ca` adds the selected file, if any, to the +current store and resets existing sessions. On JRuby, CA selection defaults to +the JVM trust store and can be overridden with `SSL_CERT_FILE`; assigning +`cacert_file` explicitly selects a certificate file. Use `client.ssl_config.set_default_paths` on MRI to load OpenSSL's configured default certificate file and directory. diff --git a/lib/httpclient/ssl_config.rb b/lib/httpclient/ssl_config.rb index 829b772d..5fd55895 100644 --- a/lib/httpclient/ssl_config.rb +++ b/lib/httpclient/ssl_config.rb @@ -30,10 +30,10 @@ class HTTPClient # Assigning cacert_file loads the selected file into a new certificate store, # replacing the current store and resetting existing sessions. It can be # changed after connections have been made. load_trust_ca loads the selected - # file into the current store, adding to its existing certificates. + # file, if any, into the current store, adding to its existing certificates. # - # On JRuby, HTTPClient uses the JVM's trusted CA certificates by default. - # Assign cacert_file explicitly to use a certificate file instead. + # On JRuby, CA selection defaults to the JVM's trust store and can be + # overridden with SSL_CERT_FILE. Assign cacert_file to select a file directly. # # == TLS certificate restrictions # @@ -200,7 +200,7 @@ def self.blocked_public_key?(public_key_der) # clear_cert_store and set the new store with cert_store=. attr_reader :cert_store # don't use if you don't know what it is. # Selected CA certificate file. Defaults to OpenSSL::X509::DEFAULT_CERT_FILE - # on MRI. + # on MRI and nil on JRuby, where CA selection is left to the runtime. attr_reader :cacert_file # For server side configuration. Ignore this. @@ -216,7 +216,8 @@ def cert_store_items; @cert_store._httpclient_cert_store_items; end def initialize(client) return unless SSLEnabled @client = client - @cacert_file = X509::DEFAULT_CERT_FILE + # nil leaves JRuby's default CA selection (JVM store or SSL_CERT_FILE) intact. + @cacert_file = defined?(JRUBY_VERSION) ? nil : X509::DEFAULT_CERT_FILE @cert_store = X509::Store.new @cert_store_crl_items = [] @client_cert = @client_key = @client_key_pass = @client_ca = nil @@ -537,7 +538,7 @@ def change_notify end def load_cacerts(cert_store) - add_trust_ca_to_store(cert_store, @cacert_file) + add_trust_ca_to_store(cert_store, @cacert_file) unless @cacert_file.nil? end end diff --git a/test/test_ssl_config.rb b/test/test_ssl_config.rb index 94621b88..c013f5ab 100644 --- a/test/test_ssl_config.rb +++ b/test/test_ssl_config.rb @@ -50,6 +50,48 @@ def test_blocked_cross_signed_keys_without_der_export end end +class TestCACertFile < Test::Unit::TestCase + def setup + @client = HTTPClient.new + @config = @client.ssl_config + @ca_file = File.join(__dir__, 'ca.cert') + end + + def teardown + @client.reset_all + end + + if defined?(JRUBY_VERSION) + def test_load_trust_ca_keeps_default_selection_when_no_file_is_selected + default_items = @config.cert_store_items.dup + @config.load_trust_ca + + assert_nil(@config.cacert_file) + assert_equal(default_items, @config.cert_store_items) + end + end + + def test_cacert_file_replaces_store + @config.add_trust_ca(File.join(__dir__, 'ca-chain.pem')) + original_store = @config.cert_store + @config.cacert_file = @ca_file + + assert_not_same(original_store, @config.cert_store) + assert_equal(@ca_file, @config.cacert_file) + assert_equal([@ca_file], @config.cert_store_items) if defined?(JRUBY_VERSION) + end + + def test_load_trust_ca_uses_selected_file + @config.cacert_file = @ca_file + @config.clear_cert_store + @config.load_trust_ca + + certificate = OpenSSL::X509::Certificate.new(File.read(@ca_file)) + @config.cert_store.time = certificate.not_before + 1 + assert(@config.cert_store.verify(certificate), @config.cert_store.error_string) + end +end + # JRuby uses its JVM trust manager instead of SSLConfig#set_context. unless defined?(JRUBY_VERSION) class TestOpenSSLTrustStore < Test::Unit::TestCase