Skip to content

Commit 1648f0c

Browse files
committed
feat: add Ciphers option to SSL Option
1 parent e8c4f82 commit 1648f0c

File tree

5 files changed

+287
-39
lines changed

5 files changed

+287
-39
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This gem is a [Faraday][faraday] adapter for the [HTTPClient][httpclient] librar
44
Faraday is an HTTP client library that provides a common interface over many adapters.
55
Every adapter is defined into its own gem. This gem defines the adapter for HTTPClient.
66

7+
> **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.
8+
79
## Installation
810

911
Add these lines to your application's Gemfile:
@@ -25,15 +27,51 @@ Or install them yourself as:
2527
```ruby
2628
require 'faraday/httpclient'
2729

30+
# Basic configuration
2831
conn = Faraday.new(...) do |f|
2932
f.adapter :httpclient do |client|
3033
# yields HTTPClient
3134
client.keep_alive_timeout = 20
3235
client.ssl_config.timeout = 25
3336
end
3437
end
38+
39+
# With SSL configuration (including ciphers)
40+
conn = Faraday.new(
41+
url: 'https://example.com',
42+
ssl: {
43+
verify: true, # enable/disable SSL verification
44+
ca_file: '/path/to/ca.pem', # custom CA file
45+
client_cert: client_cert, # client certificate
46+
client_key: client_key, # client private key
47+
verify_depth: 5, # verification depth
48+
ciphers: ['TLS_AES_256_GCM_SHA384'] # supported in Faraday 2.11.0+
49+
}
50+
) do |f|
51+
f.adapter :httpclient
52+
end
3553
```
3654

55+
## SSL Configuration
56+
57+
The adapter supports various SSL configuration options through Faraday's SSL options hash:
58+
59+
### Standard SSL Options (All Versions)
60+
61+
- `verify`: Enable/disable SSL verification (default: `true`)
62+
- `ca_file`: Path to CA certificate file
63+
- `ca_path`: Path to CA certificate directory
64+
- `cert_store`: Custom certificate store (instance of `OpenSSL::X509::Store`)
65+
- `client_cert`: Client certificate for authentication
66+
- `client_key`: Client private key for authentication
67+
- `verify_depth`: Maximum depth for certificate chain verification
68+
69+
### Faraday 2.11.0+ SSL Options
70+
71+
- `ciphers`: Array of cipher suite names to configure allowed SSL/TLS cipher suites
72+
73+
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`.
74+
3775
## Development
3876

3977
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.

lib/faraday/adapter/httpclient.rb

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'faraday/httpclient/ssl_configurator'
4+
35
module Faraday
46
class Adapter
57
# This class provides the main implementation for your adapter.
@@ -26,7 +28,7 @@ def build_connection(env)
2628
end
2729

2830
if env[:url].scheme == 'https' && (ssl = env[:ssl])
29-
configure_ssl @client, ssl
31+
::Faraday::HTTPClient::SSLConfigurator.configure @client, ssl
3032
end
3133

3234
configure_client @client
@@ -91,19 +93,6 @@ def configure_proxy(client, proxy)
9193
client.set_proxy_auth(proxy[:user], proxy[:password])
9294
end
9395

94-
# @param ssl [Hash]
95-
def configure_ssl(client, ssl)
96-
ssl_config = client.ssl_config
97-
ssl_config.verify_mode = ssl_verify_mode(ssl)
98-
ssl_config.cert_store = ssl_cert_store(ssl)
99-
100-
ssl_config.add_trust_ca ssl[:ca_file] if ssl[:ca_file]
101-
ssl_config.add_trust_ca ssl[:ca_path] if ssl[:ca_path]
102-
ssl_config.client_cert = ssl[:client_cert] if ssl[:client_cert]
103-
ssl_config.client_key = ssl[:client_key] if ssl[:client_key]
104-
ssl_config.verify_depth = ssl[:verify_depth] if ssl[:verify_depth]
105-
end
106-
10796
# @param req [Hash]
10897
def configure_timeouts(client, req)
10998
if (sec = request_timeout(:open, req))
@@ -122,31 +111,6 @@ def configure_timeouts(client, req)
122111
def configure_client(client)
123112
@config_block&.call(client)
124113
end
125-
126-
# @param ssl [Hash]
127-
# @return [OpenSSL::X509::Store]
128-
def ssl_cert_store(ssl)
129-
return ssl[:cert_store] if ssl[:cert_store]
130-
131-
# Memoize the cert store so that the same one is passed to
132-
# HTTPClient each time, to avoid resyncing SSL sessions when
133-
# it's changed
134-
135-
# Use the default cert store by default, i.e. system ca certs
136-
@ssl_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths)
137-
end
138-
139-
# @param ssl [Hash]
140-
def ssl_verify_mode(ssl)
141-
ssl[:verify_mode] || begin
142-
if ssl.fetch(:verify, true)
143-
OpenSSL::SSL::VERIFY_PEER |
144-
OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
145-
else
146-
OpenSSL::SSL::VERIFY_NONE
147-
end
148-
end
149-
end
150114
end
151115
end
152116
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
module Faraday
4+
module HTTPClient
5+
# Configures SSL options for HTTPClient
6+
class SSLConfigurator
7+
def self.configure(client, ssl)
8+
new(client, ssl).configure
9+
end
10+
11+
def initialize(client, ssl)
12+
@client = client
13+
@ssl = ssl
14+
end
15+
16+
def configure
17+
ssl_config = @client.ssl_config
18+
ssl_config.verify_mode = ssl_verify_mode
19+
ssl_config.cert_store = ssl_cert_store
20+
21+
configure_ssl_options(ssl_config)
22+
configure_ciphers(ssl_config)
23+
end
24+
25+
private
26+
27+
attr_reader :ssl
28+
29+
def configure_ssl_options(ssl_config)
30+
ssl_config.add_trust_ca ssl[:ca_file] if ssl[:ca_file]
31+
ssl_config.add_trust_ca ssl[:ca_path] if ssl[:ca_path]
32+
ssl_config.client_cert = ssl[:client_cert] if ssl[:client_cert]
33+
ssl_config.client_key = ssl[:client_key] if ssl[:client_key]
34+
ssl_config.verify_depth = ssl[:verify_depth] if ssl[:verify_depth]
35+
end
36+
37+
def configure_ciphers(ssl_config)
38+
if Gem::Version.new(Faraday::VERSION) >= Gem::Version.new('2.11.0') &&
39+
ssl_config.respond_to?(:ciphers=)
40+
ssl_config.ciphers = ssl[:ciphers]
41+
end
42+
end
43+
44+
# @param ssl [Hash]
45+
# @return [OpenSSL::X509::Store]
46+
def ssl_cert_store
47+
return ssl[:cert_store] if ssl[:cert_store]
48+
49+
# Memoize the cert store so that the same one is passed to
50+
# HTTPClient each time, to avoid resyncing SSL sessions when
51+
# it's changed
52+
53+
# Use the default cert store by default, i.e. system ca certs
54+
@ssl_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths)
55+
end
56+
57+
# @param ssl [Hash]
58+
def ssl_verify_mode
59+
ssl[:verify_mode] || begin
60+
if ssl.fetch(:verify, true)
61+
OpenSSL::SSL::VERIFY_PEER |
62+
OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
63+
else
64+
OpenSSL::SSL::VERIFY_NONE
65+
end
66+
end
67+
end
68+
end
69+
end
70+
end

spec/faraday/adapter/http_client_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,47 @@
2222
expect(client.ssl_config.timeout).to eq(25)
2323
end
2424

25+
context 'SSL Configuration' do
26+
let(:adapter) { described_class.new }
27+
let(:ssl_options) { {} }
28+
let(:env) { { url: URI.parse('https://example.com'), ssl: ssl_options } }
29+
30+
it 'configures SSL when URL scheme is https' do
31+
expect(Faraday::HTTPClient::SSLConfigurator).to receive(:configure)
32+
adapter.build_connection(env)
33+
end
34+
35+
it 'skips SSL configuration when URL scheme is not https' do
36+
env[:url] = URI.parse('http://example.com')
37+
expect(Faraday::HTTPClient::SSLConfigurator).not_to receive(:configure)
38+
adapter.build_connection(env)
39+
end
40+
41+
it 'skips SSL configuration when ssl options are not present' do
42+
env.delete(:ssl)
43+
expect(Faraday::HTTPClient::SSLConfigurator).not_to receive(:configure)
44+
adapter.build_connection(env)
45+
end
46+
47+
it 'passes SSL options to configurator' do
48+
ssl_options.merge!(
49+
verify: true,
50+
ca_file: '/path/to/ca.pem',
51+
client_cert: 'cert',
52+
client_key: 'key',
53+
verify_depth: 5,
54+
ciphers: ['TLS_AES_256_GCM_SHA384']
55+
)
56+
57+
expect(Faraday::HTTPClient::SSLConfigurator).to receive(:configure) do |client, ssl|
58+
expect(client).to be_a(HTTPClient)
59+
expect(ssl).to eq(ssl_options)
60+
end
61+
62+
adapter.build_connection(env)
63+
end
64+
end
65+
2566
context 'Options' do
2667
let(:request) { Faraday::RequestOptions.new }
2768
let(:env) { { request: request } }
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Faraday::HTTPClient::SSLConfigurator do
4+
let(:client) { HTTPClient.new }
5+
let(:ssl) { {} }
6+
let(:configurator) { described_class.new(client, ssl) }
7+
8+
describe '.configure' do
9+
it 'creates a new instance and configures it' do
10+
expect(described_class).to receive(:new).with(client, ssl).and_return(configurator)
11+
expect(configurator).to receive(:configure)
12+
described_class.configure(client, ssl)
13+
end
14+
end
15+
16+
describe '#configure' do
17+
let(:ssl_config) { client.ssl_config }
18+
19+
context 'with default settings' do
20+
before { configurator.configure }
21+
22+
it 'sets verify mode to VERIFY_PEER with fail if no peer cert' do
23+
expected_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
24+
expect(ssl_config.verify_mode).to eq(expected_mode)
25+
end
26+
27+
it 'sets a default cert store' do
28+
expect(ssl_config.cert_store).to be_a(OpenSSL::X509::Store)
29+
end
30+
end
31+
32+
context 'with verify: false' do
33+
let(:ssl) { { verify: false } }
34+
35+
it 'sets verify mode to VERIFY_NONE' do
36+
configurator.configure
37+
expect(ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE)
38+
end
39+
end
40+
41+
context 'with explicit verify_mode' do
42+
let(:ssl) { { verify_mode: OpenSSL::SSL::VERIFY_NONE } }
43+
44+
it 'uses the provided verify mode' do
45+
configurator.configure
46+
expect(ssl_config.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE)
47+
end
48+
end
49+
50+
context 'with custom cert store' do
51+
let(:cert_store) { OpenSSL::X509::Store.new }
52+
let(:ssl) { { cert_store: cert_store } }
53+
54+
it 'uses the provided cert store' do
55+
configurator.configure
56+
expect(ssl_config.cert_store).to eq(cert_store)
57+
end
58+
end
59+
60+
context 'with SSL options' do
61+
require 'tempfile'
62+
63+
let(:client_cert) { OpenSSL::X509::Certificate.new }
64+
let(:client_key) { OpenSSL::PKey::RSA.new }
65+
let(:verify_depth) { 5 }
66+
let(:ca_file) do
67+
file = Tempfile.new(['ca', '.pem'])
68+
file.write('dummy CA content')
69+
file.close
70+
file.path
71+
end
72+
let(:ca_path) do
73+
Dir.mktmpdir('ca_certs')
74+
end
75+
let(:ssl) do
76+
{
77+
ca_file: ca_file,
78+
ca_path: ca_path,
79+
client_cert: client_cert,
80+
client_key: client_key,
81+
verify_depth: verify_depth
82+
}
83+
end
84+
85+
before do
86+
allow(ssl_config).to receive(:add_trust_ca)
87+
configurator.configure
88+
end
89+
90+
after do
91+
FileUtils.rm_f(ca_file)
92+
FileUtils.rm_rf(ca_path)
93+
end
94+
95+
it 'configures all SSL options' do
96+
expect(ssl_config.cert_store).to be_a(OpenSSL::X509::Store)
97+
expect(ssl_config.client_cert).to eq(client_cert)
98+
expect(ssl_config.client_key).to eq(client_key)
99+
expect(ssl_config.verify_depth).to eq(verify_depth)
100+
end
101+
102+
it 'adds trusted CA file and path' do
103+
expect(ssl_config).to have_received(:add_trust_ca).with(ca_file)
104+
expect(ssl_config).to have_received(:add_trust_ca).with(ca_path)
105+
end
106+
end
107+
108+
context 'with cipher configuration' do
109+
let(:ciphers) { ['TLS_AES_256_GCM_SHA384'] }
110+
let(:ssl) { { ciphers: ciphers } }
111+
112+
before do
113+
stub_const('Faraday::VERSION', '2.11.0')
114+
configurator.configure
115+
end
116+
117+
it 'configures ciphers when supported' do
118+
expect(ssl_config).to respond_to(:ciphers=)
119+
expect(ssl_config.ciphers).to eq(ciphers)
120+
end
121+
122+
context 'with older Faraday version' do
123+
before do
124+
stub_const('Faraday::VERSION', '2.10.0')
125+
allow(ssl_config).to receive(:respond_to?).with(:ciphers=).and_return(false)
126+
configurator.configure
127+
end
128+
129+
it 'does not configure ciphers' do
130+
expect(ssl_config).not_to receive(:ciphers=)
131+
end
132+
end
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)