elastic-transport 8.5.2 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec964e43347f5b7ae8ab38e9f2403aa0f6bcd352c9169a2bcd4d54a1fa995957
4
- data.tar.gz: cdbd0d5ce004ec76ba29af96ae02a0e084a926dd6bf3808d507b8a7f80764b5e
3
+ metadata.gz: 1d020bb42e417e2c6ac4a2f45502217df668c4d8d22d41ff2e420a3e4e816b95
4
+ data.tar.gz: 2944d994083d8d36b6b8f212c05516b34bf5bd6c985402496af9c2200a0f9f6f
5
5
  SHA512:
6
- metadata.gz: 2809af5a1b46710a9b812aa11496266a83920e6efac87f1a30ed3e621963107b6bcc5d8362b297a07c9de4f3b030ecb0da34771ea27939513a5dc00b038503bd
7
- data.tar.gz: 8b43dc2f6a63901eb5566b1eeb8a0a9a8ac4e189981cdab1afc9a24e4e6a3e8f7a7a325d137d6e218390c79c8532d0092a803e751fa078f170fe6506c5f31c77
6
+ metadata.gz: 166ba90d59e3239807a0f3ba9329ab1d0f844a59a17aac1607c5541789dbb9760367191f0c2ab0e716cb9abc9386284b32a713fd5255af847fb65e7653545368
7
+ data.tar.gz: 8fb41bdd95ca8163c0e6f736105b91d1f484706c19caa682b8caefdfa97ad37c7190e640907fca0a7eae9c4278450895d5c82d38154515167f3609f8ad75b51b
data/CHANGELOG.md CHANGED
@@ -1,10 +1,10 @@
1
- ## 8.5.2
1
+ ## 8.6.0
2
2
 
3
- - Minor refactor to `curb` implementation.
4
- - Updated `yard` development dependency to `> 0.9.42`.
5
- - Fix `multi_json` deprecation warnings. Some deprecations [were introduced in `multi_json` 1.21.0](https://github.com/sferik/multi_json/blob/main/CHANGELOG.md#1210). Pull Request: [#112](https://github.com/elastic/elastic-transport-ruby/pull/112).
6
- - Reduced gem size. Pull Request: [#114](https://github.com/elastic/elastic-transport-ruby/pull/114).
7
- - Addresses duplicate `content-type` header in Manticore. Pull Request: [#115](https://github.com/elastic/elastic-transport-ruby/pull/115).
3
+ - **Breaking:** fixes `ca_fingerprint` so it secures the connections used for requests. The certificate matching the fingerprint is fetched from the server's chain and used as the only trusted certificate on every connection, with chain and expiry verification left on. Host names aren't checked (a pinned intermediate can't be used on JRuby, and the certificate is fetched without a proxy), so a cluster reached by a name or IP that isn't in its certificate works (Faraday 1 and Manticore still check them). Previously the fingerprint was checked on a separate socket and matched any certificate the server sent, without checking that the server's certificate was signed by it.
4
+ - `ca_fingerprint` raises an `ArgumentError` when combined with `verify: false` (or Manticore's `:none`/`:disable`), `ca_file`, `ca_path`, `cert_store` or `truststore`, in `transport_options[:ssl]` or `:ssl`. Remove `verify: false` if you followed the previous docs.
5
+ - The Curb transport now reads `ca_file` and `verify: false` from `transport_options[:ssl]`.
6
+ - `ca_fingerprint` raises an `ArgumentError` when combined with a custom `:transport` instance.
7
+ - Removes `Connection#verified`.
8
8
 
9
9
  ## 8.5.1
10
10
 
@@ -117,7 +117,11 @@ module Elastic
117
117
  #
118
118
  # @option enable_meta_header [Boolean] :enable_meta_header Enable sending the meta data header to Cloud.
119
119
  # (Default: true)
120
- # @option ca_fingerprint [String] :ca_fingerprint provide this value to only trust certificates that are signed by a specific CA certificate
120
+ # @option arguments [String] :ca_fingerprint SHA256 fingerprint of a certificate in the server's chain (usually
121
+ # the CA). Before the first request, the matching certificate is fetched from the server and becomes the only
122
+ # trusted certificate for every connection. Chain and expiry are verified, host names aren't (except on
123
+ # Faraday 1 and Manticore). Requires https
124
+ # hosts and the client-built transport (can't be combined with `:transport`).
121
125
  #
122
126
  # @yield [faraday] Access and configure the `Faraday::Connection` instance directly with a block
123
127
  #
@@ -136,13 +140,14 @@ module Elastic
136
140
 
137
141
  @hosts ||= extract_hosts
138
142
  @send_get_body_as = @arguments[:send_get_body_as] || 'GET'
139
- @ca_fingerprint = @arguments.delete(:ca_fingerprint)
140
143
 
141
144
  if @arguments[:request_timeout]
142
145
  @arguments[:transport_options][:request] = { timeout: @arguments[:request_timeout] }
143
146
  end
144
147
 
145
148
  if @arguments[:transport]
149
+ raise ArgumentError, 'ca_fingerprint can\'t be used with a custom :transport instance' if @arguments[:ca_fingerprint]
150
+
146
151
  @transport = @arguments[:transport]
147
152
  else
148
153
  @transport_class = @arguments[:transport_class] || DEFAULT_TRANSPORT_CLASS
@@ -168,8 +173,7 @@ module Elastic
168
173
  #
169
174
  def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {})
170
175
  method = @send_get_body_as if method == 'GET' && body
171
- validate_ca_fingerprints if @ca_fingerprint
172
- if @otel
176
+ if defined?(@otel) && !@otel.nil?
173
177
  # If no endpoint is specified in the opts, use the HTTP method name
174
178
  span_name = opts[:endpoint] || method
175
179
  @otel.tracer.in_span(span_name) do |span|
@@ -196,31 +200,6 @@ module Elastic
196
200
 
197
201
  private
198
202
 
199
- def validate_ca_fingerprints
200
- transport.connections.connections.each do |connection|
201
- unless connection.host[:scheme] == 'https'
202
- raise Elastic::Transport::Transport::Error, 'CA fingerprinting can\'t be configured over http'
203
- end
204
-
205
- next if connection.verified
206
-
207
- ctx = OpenSSL::SSL::SSLContext.new
208
- socket = TCPSocket.new(connection.host[:host], connection.host[:port])
209
- ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
210
- ssl.connect
211
- cert_store = ssl.peer_cert_chain
212
- matching_certs = cert_store.select do |cert|
213
- OpenSSL::Digest::SHA256.hexdigest(cert.to_der).upcase == @ca_fingerprint.gsub(':', '').upcase
214
- end
215
- if matching_certs.empty?
216
- raise Elastic::Transport::Transport::Error,
217
- 'Server certificate CA fingerprint does not match the value configured in ca_fingerprint'
218
- end
219
-
220
- connection.verified = true
221
- end
222
- end
223
-
224
203
  def add_header(header)
225
204
  headers = @arguments[:transport_options]&.[](:headers) || {}
226
205
  headers.merge!(header)
@@ -22,6 +22,7 @@ module Elastic
22
22
  #
23
23
  module Base
24
24
  include Loggable
25
+ include CaFingerprint
25
26
 
26
27
  DEFAULT_PORT = 9200
27
28
  DEFAULT_PROTOCOL = 'http'.freeze
@@ -48,6 +49,7 @@ module Elastic
48
49
  #
49
50
  def initialize(arguments = {}, &block)
50
51
  @state_mutex = Mutex.new
52
+ @ca_pin_mutex = Mutex.new
51
53
 
52
54
  @hosts = arguments[:hosts] || []
53
55
  @options = arguments[:options] || {}
@@ -55,6 +57,7 @@ module Elastic
55
57
  @options[:retry_on_status] ||= []
56
58
  @options[:delay_on_retry] ||= 0
57
59
 
60
+ validate_ca_fingerprint_options! if @options[:ca_fingerprint]
58
61
  @block = block
59
62
  @compression = !!@options[:compression]
60
63
  @connections = __build_connections
@@ -166,6 +169,9 @@ module Elastic
166
169
  host[:password] ||= options[:password] || options[:http][:password]
167
170
  end
168
171
 
172
+ # Hosts added after the pin (reload, sniffing) would otherwise get no protection
173
+ raise Error, 'CA fingerprinting can\'t be configured over http' if @ca_file && host[:protocol] != 'https'
174
+
169
175
  __build_connection(host, (options[:transport_options] || {}), @block)
170
176
  end
171
177
  end
@@ -273,6 +279,7 @@ module Elastic
273
279
  def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {}, &block)
274
280
  raise NoMethodError, 'Implement this method in your transport class' unless block_given?
275
281
 
282
+ pin_ca_certificate! if options[:ca_fingerprint]
276
283
  start = Time.now
277
284
  tries = 0
278
285
  reload_on_failure = opts.fetch(:reload_on_failure, @options[:reload_on_failure])
@@ -356,15 +363,10 @@ module Elastic
356
363
  end
357
364
 
358
365
  if response.body &&
359
- !response.body.empty? &&
360
- response.headers &&
361
- response.headers["content-type"] =~ /json/
362
-
363
- # Prevent Float value from automatically becoming BigDecimal when using Oj
364
- load_options = {}
365
- load_options[:mode] = :compat if ::MultiJson.adapter.to_s == "MultiJson::Adapters::Oj"
366
-
367
- json = serializer.load(response.body, load_options)
366
+ !response.body.empty? &&
367
+ response.headers &&
368
+ response.headers['content-type'] =~ /json/
369
+ json = serializer.load(response.body)
368
370
  end
369
371
  took = (json['took'] ? sprintf('%.3fs', json['took'] / 1000.0) : 'n/a') rescue 'n/a'
370
372
  __log_response(method, path, params, body, url, response, json, took, duration) unless ignore.include?(response.status.to_i)
@@ -0,0 +1,131 @@
1
+ # Licensed to Elasticsearch B.V. under one or more contributor
2
+ # license agreements. See the NOTICE file distributed with
3
+ # this work for additional information regarding copyright
4
+ # ownership. Elasticsearch B.V. licenses this file to you under
5
+ # the Apache License, Version 2.0 (the "License"); you may
6
+ # not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+
18
+ require 'openssl'
19
+ require 'socket'
20
+ require 'tempfile'
21
+ require 'timeout'
22
+
23
+ module Elastic
24
+ module Transport
25
+ module Transport
26
+ # Makes the certificate matching the `ca_fingerprint` option the only trust anchor of every connection.
27
+ #
28
+ # Before the first request, the server's certificate chain is fetched once without verification. Only the
29
+ # certificate whose fingerprint matches is kept, so the fetch itself needs no trust. The connections are then
30
+ # rebuilt with that certificate as their only trusted certificate, and the HTTP library verifies the chain and
31
+ # expiry on every connection. Host names aren't checked where the adapter allows that.
32
+ #
33
+ module CaFingerprint
34
+ TRUST_SETTINGS = %i[ca_file ca_path cert_store truststore].freeze
35
+ VERIFY_OFF = [false, :none, :disable].freeze
36
+ FETCH_TIMEOUT = 10
37
+
38
+ private
39
+
40
+ # The pinned certificate must be the only trust anchor. Other trust settings would be trusted next to it,
41
+ # or silently replaced by it, and turning verification off would turn the pin off too.
42
+ def validate_ca_fingerprint_options!
43
+ [options[:ssl], options.dig(:transport_options, :ssl)].compact.each do |ssl|
44
+ ssl = ssl.to_h
45
+ if VERIFY_OFF.include?(ssl[:verify]) || TRUST_SETTINGS.any? { |key| ssl[key] }
46
+ raise ArgumentError,
47
+ "ca_fingerprint can't be combined with :verify off or with #{TRUST_SETTINGS.join(', ')}"
48
+ end
49
+ end
50
+ end
51
+
52
+ def pin_ca_certificate!
53
+ return if @ca_file
54
+
55
+ @ca_pin_mutex.synchronize do
56
+ return if @ca_file
57
+
58
+ ca = fetch_pinned_certificate
59
+ store = OpenSSL::X509::Store.new
60
+ # Without PARTIAL_CHAIN, OpenSSL only trusts a chain that ends in a self-signed root in the store. With it,
61
+ # the pinned certificate is a trust anchor even when it is an intermediate. ponytail: jruby-openssl lacks
62
+ # the flag, so on JRuby the fingerprint has to be the root CA's.
63
+ store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN if defined?(OpenSSL::X509::V_FLAG_PARTIAL_CHAIN)
64
+ store.add_cert(ca)
65
+ # The Tempfile is deleted when garbage collected, so @ca_file keeps a reference to it.
66
+ # Curb, Manticore and libcurl-based Faraday adapters only accept a CA file
67
+ ca_file = Tempfile.new(['ca_fingerprint', '.pem'])
68
+ ca_file.write(ca.to_pem)
69
+ ca_file.flush
70
+
71
+ pin = { cert_store: store, ca_file: ca_file.path }
72
+ # The pin is the trust decision, host names add nothing (elastic/security#6842), and clusters reached by
73
+ # a container service name or IP must work. ponytail: Faraday 1 and Manticore have no host-name-only
74
+ # switch, so they still check host names.
75
+ pin[:verify_hostname] = false if !is_a?(HTTP::Faraday) || ::Faraday::SSLOptions.members.include?(:verify_hostname)
76
+ # New hashes, so the caller's option hashes aren't changed
77
+ transport_options = options[:transport_options] || {}
78
+ options[:transport_options] = transport_options.merge(ssl: (transport_options[:ssl] || {}).merge(pin))
79
+ options[:ssl] = (options[:ssl] || {}).merge(ca_file: ca_file.path) # Manticore reads top-level :ssl
80
+ __rebuild_pinned_connections
81
+ @ca_file = ca_file
82
+ end
83
+ end
84
+
85
+ # Replaces every connection, so none built before the pin stays in use.
86
+ def __rebuild_pinned_connections
87
+ @state_mutex.synchronize do
88
+ __close_connections
89
+ @connections = __build_connections
90
+ end
91
+ end
92
+
93
+ def fetch_pinned_certificate
94
+ unless hosts.all? { |host| host[:protocol] == 'https' }
95
+ raise Error, 'CA fingerprinting can\'t be configured over http'
96
+ end
97
+
98
+ fingerprint = options[:ca_fingerprint].delete(':').upcase
99
+ chain = reachable_peer_cert_chain
100
+ ca = chain.find { |cert| OpenSSL::Digest::SHA256.hexdigest(cert.to_der).upcase == fingerprint }
101
+ ca or raise Error, 'Server certificate CA fingerprint does not match the value configured in ca_fingerprint'
102
+ end
103
+
104
+ # Takes the chain of the first host that answers. Every other host must chain to the same certificate, or
105
+ # its connections fail verification.
106
+ def reachable_peer_cert_chain
107
+ error = nil
108
+ hosts.each do |host|
109
+ return peer_cert_chain(host)
110
+ rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError, Timeout::Error => e
111
+ error = e
112
+ end
113
+ raise Error, "Couldn't fetch the server certificate for ca_fingerprint: #{error.message}"
114
+ end
115
+
116
+ def peer_cert_chain(host)
117
+ context = OpenSSL::SSL::SSLContext.new
118
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
119
+ socket = Socket.tcp(host[:host], host[:port], connect_timeout: FETCH_TIMEOUT)
120
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, context)
121
+ ssl.hostname = host[:host]
122
+ Timeout.timeout(FETCH_TIMEOUT) { ssl.connect }
123
+ ssl.peer_cert_chain
124
+ ensure
125
+ ssl&.close
126
+ socket&.close
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -33,7 +33,6 @@ module Elastic
33
33
  DEFAULT_RESURRECT_TIMEOUT = 60
34
34
 
35
35
  attr_reader :host, :connection, :options, :failures, :dead_since
36
- attr_accessor :verified
37
36
 
38
37
  # @option arguments [Hash] :host Host information (example: `{host: 'localhost', port: 9200}`)
39
38
  # @option arguments [Object] :connection The transport-specific physical connection or "session"
@@ -43,7 +42,6 @@ module Elastic
43
42
  @host = arguments[:host].is_a?(Hash) ? Redacted.new(arguments[:host]) : arguments[:host]
44
43
  @connection = arguments[:connection]
45
44
  @options = arguments[:options] || {}
46
- @verified = false
47
45
  @state_mutex = Mutex.new
48
46
 
49
47
  @options[:resurrect_timeout] ||= DEFAULT_RESURRECT_TIMEOUT
@@ -83,6 +83,9 @@ module Elastic
83
83
 
84
84
  apply_headers(client, options)
85
85
  client.url = __full_url(host)
86
+ client.cacert = options.dig(:ssl, :ca_file) if options.dig(:ssl, :ca_file)
87
+ client.ssl_verify_peer = false if options.dig(:ssl, :verify) == false
88
+ client.ssl_verify_host = 0 if options.dig(:ssl, :verify) == false || options.dig(:ssl, :verify_hostname) == false
86
89
 
87
90
  if host[:user]
88
91
  client.http_auth_types = host[:auth_type] || :basic
@@ -158,6 +158,14 @@ module Elastic
158
158
 
159
159
  private
160
160
 
161
+ # Faraday and Curb read the pinned CA when a connection is built, so rebuilding connections is enough.
162
+ # Manticore reads it once, when the Manticore::Client is built in initialize, so the client is built again.
163
+ def __rebuild_pinned_connections
164
+ @manticore.close
165
+ build_client(options)
166
+ super
167
+ end
168
+
161
169
  def parse_headers(headers)
162
170
  request_headers = @request_options.fetch(:headers, {})
163
171
  headers = request_headers.merge(headers || {})
@@ -17,6 +17,6 @@
17
17
 
18
18
  module Elastic
19
19
  module Transport
20
- VERSION = '8.5.2'.freeze
20
+ VERSION = '8.6.0'.freeze
21
21
  end
22
22
  end
@@ -26,6 +26,7 @@ require 'elastic/transport/transport/serializer/multi_json'
26
26
  require 'elastic/transport/transport/sniffer'
27
27
  require 'elastic/transport/transport/response'
28
28
  require 'elastic/transport/transport/errors'
29
+ require 'elastic/transport/transport/ca_fingerprint'
29
30
  require 'elastic/transport/transport/base'
30
31
  require 'elastic/transport/transport/connections/selector'
31
32
  require 'elastic/transport/transport/connections/connection'
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elastic-transport
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.5.2
4
+ version: 8.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elastic Client Library Maintainers
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-11-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -242,8 +243,8 @@ email:
242
243
  executables: []
243
244
  extensions: []
244
245
  extra_rdoc_files:
245
- - LICENSE
246
246
  - README.md
247
+ - LICENSE
247
248
  files:
248
249
  - CHANGELOG.md
249
250
  - CONTRIBUTING.md
@@ -256,6 +257,7 @@ files:
256
257
  - lib/elastic/transport/opentelemetry.rb
257
258
  - lib/elastic/transport/redacted.rb
258
259
  - lib/elastic/transport/transport/base.rb
260
+ - lib/elastic/transport/transport/ca_fingerprint.rb
259
261
  - lib/elastic/transport/transport/connections/collection.rb
260
262
  - lib/elastic/transport/transport/connections/connection.rb
261
263
  - lib/elastic/transport/transport/connections/selector.rb
@@ -276,6 +278,7 @@ metadata:
276
278
  changelog_uri: https://github.com/elastic/elastic-transport-ruby/blob/master/CHANGELOG.md
277
279
  source_code_uri: https://github.com/elastic/elastic-transport-ruby
278
280
  bug_tracker_uri: https://github.com/elastic/elastic-transport-ruby/issues
281
+ post_install_message:
279
282
  rdoc_options:
280
283
  - "--charset=UTF-8"
281
284
  require_paths:
@@ -291,7 +294,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
291
294
  - !ruby/object:Gem::Version
292
295
  version: '0'
293
296
  requirements: []
294
- rubygems_version: 4.0.12
297
+ rubygems_version: 3.5.22
298
+ signing_key:
295
299
  specification_version: 4
296
300
  summary: Low level Ruby client for Elastic services.
297
301
  test_files: []