Skip to content

Commit 9a21b79

Browse files
committed
[Client] Implements ca_fingerprinting
1 parent f3d0214 commit 9a21b79

File tree

4 files changed

+99
-5
lines changed

4 files changed

+99
-5
lines changed

elasticsearch-transport/lib/elasticsearch/transport/client.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class Client
126126
# if you're using X-Opaque-Id
127127
# @option enable_meta_header [Boolean] :enable_meta_header Enable sending the meta data header to Cloud.
128128
# (Default: true)
129+
# @option ca_fingerprint [String] :ca_fingerprint provide this value to only trust certificates that are signed by a specific CA certificate
129130
#
130131
# @yield [faraday] Access and configure the `Faraday::Connection` instance directly with a block
131132
#
@@ -156,6 +157,7 @@ def initialize(arguments={}, &block)
156157

157158
@send_get_body_as = @arguments[:send_get_body_as] || 'GET'
158159
@opaque_id_prefix = @arguments[:opaque_id_prefix] || nil
160+
@ca_fingerprint = @arguments.delete(:ca_fingerprint)
159161

160162
if @arguments[:request_timeout]
161163
@arguments[:transport_options][:request] = { timeout: @arguments[:request_timeout] }
@@ -188,6 +190,7 @@ def perform_request(method, path, params = {}, body = nil, headers = nil)
188190
opaque_id = @opaque_id_prefix ? "#{@opaque_id_prefix}#{opaque_id}" : opaque_id
189191
headers.merge!('X-Opaque-Id' => opaque_id)
190192
end
193+
validate_ca_fingerprints if @ca_fingerprint
191194
transport.perform_request(method, path, params, body, headers)
192195
end
193196

@@ -211,6 +214,31 @@ def set_compatibility_header
211214
)
212215
end
213216

217+
def validate_ca_fingerprints
218+
transport.connections.connections.each do |connection|
219+
unless connection.host[:scheme] == 'https'
220+
raise Elasticsearch::Transport::Transport::Error, 'CA fingerprinting can\'t be configured over http'
221+
end
222+
223+
next if connection.verified
224+
225+
ctx = OpenSSL::SSL::SSLContext.new
226+
socket = TCPSocket.new(connection.host[:host], connection.host[:port])
227+
ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
228+
ssl.connect
229+
cert_store = ssl.peer_cert_chain
230+
matching_certs = cert_store.chain.select do |cert|
231+
OpenSSL::Digest::SHA256.hexdigest(cert.to_der).upcase == @ca_fingerprint.upcase
232+
end
233+
if matching_certs.empty?
234+
raise Elasticsearch::Transport::Transport::Error,
235+
'Server certificate CA fingerprint does not match the value configured in ca_fingerprint'
236+
end
237+
238+
connection.verified = true
239+
end
240+
end
241+
214242
def add_header(header)
215243
headers = @arguments[:transport_options]&.[](:headers) || {}
216244
headers.merge!(header)

elasticsearch-transport/lib/elasticsearch/transport/transport/connections/connection.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Connection
3333
DEFAULT_RESURRECT_TIMEOUT = 60
3434

3535
attr_reader :host, :connection, :options, :failures, :dead_since
36+
attr_accessor :verified
3637

3738
# @option arguments [Hash] :host Host information (example: `{host: 'localhost', port: 9200}`)
3839
# @option arguments [Object] :connection The transport-specific physical connection or "session"
@@ -42,6 +43,7 @@ def initialize(arguments={})
4243
@host = arguments[:host].is_a?(Hash) ? Redacted.new(arguments[:host]) : arguments[:host]
4344
@connection = arguments[:connection]
4445
@options = arguments[:options] || {}
46+
@verified = false
4547
@state_mutex = Mutex.new
4648

4749
@options[:resurrect_timeout] ||= DEFAULT_RESURRECT_TIMEOUT
@@ -153,7 +155,6 @@ def to_s
153155
"<#{self.class.name} host: #{host} (#{dead? ? 'dead since ' + dead_since.to_s : 'alive'})>"
154156
end
155157
end
156-
157158
end
158159
end
159160
end

elasticsearch-transport/lib/elasticsearch/transport/transport/http/faraday.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def perform_request(method, path, params = {}, body = nil, headers = nil, opts =
6262
def __build_connection(host, options={}, block=nil)
6363
client = ::Faraday.new(__full_url(host), options, &block)
6464
apply_headers(client, options)
65-
Connections::Connection.new :host => host, :connection => client
65+
Connections::Connection.new(host: host, connection: client)
6666
end
6767

6868
# Returns an array of implementation specific connection errors.

elasticsearch-transport/spec/elasticsearch/transport/client_spec.rb

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,7 +1895,6 @@
18951895
end
18961896

18971897
context 'when request headers are specified' do
1898-
18991898
let(:response) do
19001899
client.perform_request('GET', '/', {}, nil, { 'Content-Type' => 'application/yaml' })
19011900
end
@@ -1906,9 +1905,7 @@
19061905
end
19071906

19081907
describe 'selector' do
1909-
19101908
context 'when the round-robin selector is used' do
1911-
19121909
let(:nodes) do
19131910
3.times.collect do
19141911
client.perform_request('GET', '_nodes/_local').body['nodes'].to_a[0][1]['name']
@@ -1989,4 +1986,72 @@
19891986
end
19901987
end
19911988
end
1989+
1990+
context 'CA Fingerprinting' do
1991+
context 'when setting a ca_fingerprint' do
1992+
let(:subject) { "/C=BE/O=Test/OU=Test/CN=Test" }
1993+
1994+
let(:certificate) do
1995+
OpenSSL::X509::Certificate.new.tap do |cert|
1996+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject)
1997+
cert.not_before = Time.now
1998+
cert.not_after = Time.now + 365 * 24 * 60 * 60
1999+
cert.public_key = OpenSSL::PKey::RSA.new(1024).public_key
2000+
cert.serial = 0x0
2001+
cert.version = 2
2002+
end
2003+
end
2004+
2005+
let(:client) do
2006+
Elasticsearch::Transport::Client.new(
2007+
host: 'https://elastic:changeme@localhost:9200',
2008+
ca_fingerprint: OpenSSL::Digest::SHA256.hexdigest(certificate.to_der)
2009+
)
2010+
end
2011+
2012+
it 'validates CA fingerprints on perform request' do
2013+
expect(client.transport.connections.connections.map(&:verified).uniq).to eq [false]
2014+
allow(client.transport).to receive(:perform_request) { 'Hello' }
2015+
2016+
server = double('server').as_null_object
2017+
allow(TCPSocket).to receive(:new) { server }
2018+
allow_any_instance_of(OpenSSL::SSL::SSLSocket).to receive(:connect) { nil }
2019+
allow_any_instance_of(OpenSSL::SSL::SSLSocket).to receive(:peer_cert_chain) { [certificate] }
2020+
response = client.perform_request('GET', '/')
2021+
expect(client.transport.connections.connections.map(&:verified).uniq).to eq [true]
2022+
expect(response).to eq 'Hello'
2023+
end
2024+
end
2025+
2026+
context 'when using an http host' do
2027+
let(:client) do
2028+
Elasticsearch::Transport::Client.new(
2029+
host: 'http://elastic:changeme@localhost:9200',
2030+
ca_fingerprint: 'test'
2031+
)
2032+
end
2033+
2034+
it 'raises an error' do
2035+
expect do
2036+
client.perform_request('GET', '/')
2037+
end.to raise_exception(Elasticsearch::Transport::Transport::Error)
2038+
end
2039+
end
2040+
2041+
context 'when not setting a ca_fingerprint' do
2042+
let(:client) do
2043+
Elasticsearch::Transport::Client.new(
2044+
host: 'http://elastic:changeme@localhost:9200'
2045+
)
2046+
end
2047+
2048+
it 'has unvalidated connections' do
2049+
allow(client).to receive(:validate_ca_fingerprints) { nil }
2050+
allow(client.transport).to receive(:perform_request) { nil }
2051+
2052+
client.perform_request('GET', '/')
2053+
expect(client).to_not have_received(:validate_ca_fingerprints)
2054+
end
2055+
end
2056+
end
19922057
end

0 commit comments

Comments
 (0)