Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,15 +27,51 @@ Or install them yourself as:
```ruby
require 'faraday/httpclient'

# Basic configuration
conn = Faraday.new(...) do |f|
f.adapter :httpclient do |client|
# yields HTTPClient
client.keep_alive_timeout = 20
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.
Expand Down
42 changes: 3 additions & 39 deletions lib/faraday/adapter/httpclient.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
70 changes: 70 additions & 0 deletions lib/faraday/httpclient/ssl_configurator.rb
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions spec/faraday/adapter/http_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down
135 changes: 135 additions & 0 deletions spec/faraday/httpclient/ssl_configurator_spec.rb
Original file line number Diff line number Diff line change
@@ -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