diff --git a/README.md b/README.md index 160fcca..324b93e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This gem is a [Faraday][faraday] adapter for the [HTTPClient][httpclient] librar Faraday is an HTTP client library that provides a common interface over many adapters. Every adapter is defined into its own gem. This gem defines the adapter for HTTPClient. +> **Note**: Faraday 2.11.0 introduces a new SSL option: `ciphers`, allowing you to specify the SSL/TLS cipher suites. This adapter supports this option when using Faraday 2.11.0 or later. + ## Installation Add these lines to your application's Gemfile: @@ -25,6 +27,7 @@ Or install them yourself as: ```ruby require 'faraday/httpclient' +# Basic configuration conn = Faraday.new(...) do |f| f.adapter :httpclient do |client| # yields HTTPClient @@ -32,8 +35,43 @@ conn = Faraday.new(...) do |f| client.ssl_config.timeout = 25 end end + +# With SSL configuration (including ciphers) +conn = Faraday.new( + url: 'https://example.com', + ssl: { + verify: true, # enable/disable SSL verification + ca_file: '/path/to/ca.pem', # custom CA file + client_cert: client_cert, # client certificate + client_key: client_key, # client private key + verify_depth: 5, # verification depth + ciphers: ['TLS_AES_256_GCM_SHA384'] # supported in Faraday 2.11.0+ + } +) do |f| + f.adapter :httpclient +end ``` +## SSL Configuration + +The adapter supports various SSL configuration options through Faraday's SSL options hash: + +### Standard SSL Options (All Versions) + +- `verify`: Enable/disable SSL verification (default: `true`) +- `ca_file`: Path to CA certificate file +- `ca_path`: Path to CA certificate directory +- `cert_store`: Custom certificate store (instance of `OpenSSL::X509::Store`) +- `client_cert`: Client certificate for authentication +- `client_key`: Client private key for authentication +- `verify_depth`: Maximum depth for certificate chain verification + +### Faraday 2.11.0+ SSL Options + +- `ciphers`: Array of cipher suite names to configure allowed SSL/TLS cipher suites + +When using SSL verification (the default), the adapter will use system CA certificates. You can customize this by providing a `ca_file`, `ca_path`, or `cert_store`. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/lib/faraday/adapter/httpclient.rb b/lib/faraday/adapter/httpclient.rb index d73395f..935abce 100644 --- a/lib/faraday/adapter/httpclient.rb +++ b/lib/faraday/adapter/httpclient.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'faraday/httpclient/ssl_configurator' + module Faraday class Adapter # This class provides the main implementation for your adapter. @@ -26,7 +28,7 @@ def build_connection(env) end if env[:url].scheme == 'https' && (ssl = env[:ssl]) - configure_ssl @client, ssl + ::Faraday::HTTPClient::SSLConfigurator.configure @client, ssl end configure_client @client @@ -91,19 +93,6 @@ def configure_proxy(client, proxy) client.set_proxy_auth(proxy[:user], proxy[:password]) end - # @param ssl [Hash] - def configure_ssl(client, ssl) - ssl_config = client.ssl_config - ssl_config.verify_mode = ssl_verify_mode(ssl) - ssl_config.cert_store = ssl_cert_store(ssl) - - ssl_config.add_trust_ca ssl[:ca_file] if ssl[:ca_file] - ssl_config.add_trust_ca ssl[:ca_path] if ssl[:ca_path] - ssl_config.client_cert = ssl[:client_cert] if ssl[:client_cert] - ssl_config.client_key = ssl[:client_key] if ssl[:client_key] - ssl_config.verify_depth = ssl[:verify_depth] if ssl[:verify_depth] - end - # @param req [Hash] def configure_timeouts(client, req) if (sec = request_timeout(:open, req)) @@ -122,31 +111,6 @@ def configure_timeouts(client, req) def configure_client(client) @config_block&.call(client) end - - # @param ssl [Hash] - # @return [OpenSSL::X509::Store] - def ssl_cert_store(ssl) - return ssl[:cert_store] if ssl[:cert_store] - - # Memoize the cert store so that the same one is passed to - # HTTPClient each time, to avoid resyncing SSL sessions when - # it's changed - - # Use the default cert store by default, i.e. system ca certs - @ssl_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths) - end - - # @param ssl [Hash] - def ssl_verify_mode(ssl) - ssl[:verify_mode] || begin - if ssl.fetch(:verify, true) - OpenSSL::SSL::VERIFY_PEER | - OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT - else - OpenSSL::SSL::VERIFY_NONE - end - end - end end end end diff --git a/lib/faraday/httpclient/ssl_configurator.rb b/lib/faraday/httpclient/ssl_configurator.rb new file mode 100644 index 0000000..2688b4e --- /dev/null +++ b/lib/faraday/httpclient/ssl_configurator.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Faraday + module HTTPClient + # Configures SSL options for HTTPClient + class SSLConfigurator + def self.configure(client, ssl) + new(client, ssl).configure + end + + def initialize(client, ssl) + @client = client + @ssl = ssl + end + + def configure + ssl_config = @client.ssl_config + ssl_config.verify_mode = ssl_verify_mode + ssl_config.cert_store = ssl_cert_store + + configure_ssl_options(ssl_config) + configure_ciphers(ssl_config) + end + + private + + attr_reader :ssl + + def configure_ssl_options(ssl_config) + ssl_config.add_trust_ca ssl[:ca_file] if ssl[:ca_file] + ssl_config.add_trust_ca ssl[:ca_path] if ssl[:ca_path] + ssl_config.client_cert = ssl[:client_cert] if ssl[:client_cert] + ssl_config.client_key = ssl[:client_key] if ssl[:client_key] + ssl_config.verify_depth = ssl[:verify_depth] if ssl[:verify_depth] + end + + def configure_ciphers(ssl_config) + if Gem::Version.new(Faraday::VERSION) >= Gem::Version.new('2.11.0') && + ssl_config.respond_to?(:ciphers=) + ssl_config.ciphers = ssl[:ciphers] + end + end + + # @param ssl [Hash] + # @return [OpenSSL::X509::Store] + def ssl_cert_store + return ssl[:cert_store] if ssl[:cert_store] + + # Memoize the cert store so that the same one is passed to + # HTTPClient each time, to avoid resyncing SSL sessions when + # it's changed + + # Use the default cert store by default, i.e. system ca certs + @ssl_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths) + end + + # @param ssl [Hash] + def ssl_verify_mode + ssl[:verify_mode] || begin + if ssl.fetch(:verify, true) + OpenSSL::SSL::VERIFY_PEER | + OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + else + OpenSSL::SSL::VERIFY_NONE + end + end + end + end + end +end diff --git a/spec/faraday/adapter/http_client_spec.rb b/spec/faraday/adapter/http_client_spec.rb index 51fb1ef..7858956 100644 --- a/spec/faraday/adapter/http_client_spec.rb +++ b/spec/faraday/adapter/http_client_spec.rb @@ -22,6 +22,47 @@ expect(client.ssl_config.timeout).to eq(25) end + context 'SSL Configuration' do + let(:adapter) { described_class.new } + let(:ssl_options) { {} } + let(:env) { { url: URI.parse('https://example.com'), ssl: ssl_options } } + + it 'configures SSL when URL scheme is https' do + expect(Faraday::HTTPClient::SSLConfigurator).to receive(:configure) + adapter.build_connection(env) + end + + it 'skips SSL configuration when URL scheme is not https' do + env[:url] = URI.parse('http://example.com') + expect(Faraday::HTTPClient::SSLConfigurator).not_to receive(:configure) + adapter.build_connection(env) + end + + it 'skips SSL configuration when ssl options are not present' do + env.delete(:ssl) + expect(Faraday::HTTPClient::SSLConfigurator).not_to receive(:configure) + adapter.build_connection(env) + end + + it 'passes SSL options to configurator' do + ssl_options.merge!( + verify: true, + ca_file: '/path/to/ca.pem', + client_cert: 'cert', + client_key: 'key', + verify_depth: 5, + ciphers: ['TLS_AES_256_GCM_SHA384'] + ) + + expect(Faraday::HTTPClient::SSLConfigurator).to receive(:configure) do |client, ssl| + expect(client).to be_a(HTTPClient) + expect(ssl).to eq(ssl_options) + end + + adapter.build_connection(env) + end + end + context 'Options' do let(:request) { Faraday::RequestOptions.new } let(:env) { { request: request } } diff --git a/spec/faraday/httpclient/ssl_configurator_spec.rb b/spec/faraday/httpclient/ssl_configurator_spec.rb new file mode 100644 index 0000000..5356750 --- /dev/null +++ b/spec/faraday/httpclient/ssl_configurator_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +RSpec.describe Faraday::HTTPClient::SSLConfigurator do + let(:client) { HTTPClient.new } + let(:ssl) { {} } + let(:configurator) { described_class.new(client, ssl) } + + describe '.configure' do + it 'creates a new instance and configures it' do + expect(described_class).to receive(:new).with(client, ssl).and_return(configurator) + expect(configurator).to receive(:configure) + described_class.configure(client, ssl) + end + end + + describe '#configure' do + let(:ssl_config) { client.ssl_config } + + context 'with default settings' do + before { configurator.configure } + + it 'sets verify mode to VERIFY_PEER with fail if no peer cert' do + expected_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + expect(ssl_config.verify_mode).to eq(expected_mode) + end + + it 'sets a default cert store' do + expect(ssl_config.cert_store).to be_a(OpenSSL::X509::Store) + end + end + + context 'with verify: false' do + let(:ssl) { { verify: false } } + + it 'sets verify mode to VERIFY_NONE' do + configurator.configure + expect(ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE) + end + end + + context 'with explicit verify_mode' do + let(:ssl) { { verify_mode: OpenSSL::SSL::VERIFY_NONE } } + + it 'uses the provided verify mode' do + configurator.configure + expect(ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE) + end + end + + context 'with custom cert store' do + let(:cert_store) { OpenSSL::X509::Store.new } + let(:ssl) { { cert_store: cert_store } } + + it 'uses the provided cert store' do + configurator.configure + expect(ssl_config.cert_store).to eq(cert_store) + end + end + + context 'with SSL options' do + require 'tempfile' + + let(:client_cert) { OpenSSL::X509::Certificate.new } + let(:client_key) { OpenSSL::PKey::RSA.new } + let(:verify_depth) { 5 } + let(:ca_file) do + file = Tempfile.new(['ca', '.pem']) + file.write('dummy CA content') + file.close + file.path + end + let(:ca_path) do + Dir.mktmpdir('ca_certs') + end + let(:ssl) do + { + ca_file: ca_file, + ca_path: ca_path, + client_cert: client_cert, + client_key: client_key, + verify_depth: verify_depth + } + end + + before do + allow(ssl_config).to receive(:add_trust_ca) + configurator.configure + end + + after do + FileUtils.rm_f(ca_file) + FileUtils.rm_rf(ca_path) + end + + it 'configures all SSL options' do + expect(ssl_config.cert_store).to be_a(OpenSSL::X509::Store) + expect(ssl_config.client_cert).to eq(client_cert) + expect(ssl_config.client_key).to eq(client_key) + expect(ssl_config.verify_depth).to eq(verify_depth) + end + + it 'adds trusted CA file and path' do + expect(ssl_config).to have_received(:add_trust_ca).with(ca_file) + expect(ssl_config).to have_received(:add_trust_ca).with(ca_path) + end + end + + context 'with cipher configuration' do + let(:ciphers) { ['TLS_AES_256_GCM_SHA384'] } + let(:ssl) { { ciphers: ciphers } } + + before do + stub_const('Faraday::VERSION', '2.11.0') + configurator.configure + end + + it 'configures ciphers when supported' do + expect(ssl_config).to respond_to(:ciphers=) + expect(ssl_config.ciphers).to eq(ciphers) + end + + context 'with older Faraday version' do + before do + stub_const('Faraday::VERSION', '2.10.0') + allow(ssl_config).to receive(:respond_to?).with(:ciphers=).and_return(false) + configurator.configure + end + + it 'does not configure ciphers' do + expect(ssl_config).not_to receive(:ciphers=) + end + end + end + end +end