dalli 5.1.4 → 5.1.5

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: 70f17667b9c623f9233be342d920de385bab797b44a4bc9f1e85c233f766404f
4
- data.tar.gz: 527b9ff3dd4699ad5d307107668b31d3a739675825ad4d0b2cfb35ac09581a81
3
+ metadata.gz: fd6ca01dc58cadc97706b152f76a3bff1bb94a4a4727743cddac5870b6120332
4
+ data.tar.gz: ef6123de8d8267c5ded812f05fda7d4a84cb1b56c116afd3fe8bd00365881175
5
5
  SHA512:
6
- metadata.gz: 9e54f454a1bca3b79f229a219b458533d2778c1521043c1ee7c2d203ae0d25e1449fc375b59754aaadac79de6ea484170ba52c10008da6abd40451821300a60c
7
- data.tar.gz: 1dea61992e48d8786dbb3ef929e30c53d3544bafed895334cf4316ad0710791c4ebb0887fb4fa63b79358c0029be11b3495a2875a24956a296246fe9d5e4eaa0
6
+ metadata.gz: 16082586633b6f84d1486e82a603b4d7fdc1afb586bf67c0a9f6ede7d9b426a87ec0b2b59eef019c471568ad5f0a8bd081ddb164c08d608387c9093d3dba25d7
7
+ data.tar.gz: 15788269cf8910b1bee979b7859f57279f9723f57407c6c6b4dac79a60fe454d00e896e7514dc93de7a90366ce9b071d4308fabbad7e41f3f1b71593e376a6b2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.1.5
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
13
+ - memcached stores an item made by auto-create (`ma` with `N`/`J`, or `mg` with `N`) for a base64-encoded key without its binary-key flag, and later replies name the item by its raw key bytes. A key containing spaces or CR/LF could then make a multi-get read the replies after it as different ones
14
+ - `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
15
+ - The new `verify_reply_keys` option makes `get_multi`, `get_multi_cas` and `get_multi_with_metadata` ignore replies for keys they didn't request, which guards against items created by older Dalli versions or other clients until memcached is upgraded. It's off by default, since it costs a multi-get about 15%
16
+ - Affects the meta protocol since 3.2.0; fixed in 5.2.3, 5.1.5, 5.0.11, 4.3.8 and 3.2.14. memcached 1.6.45 and earlier still name these items by their raw key bytes
17
+
7
18
  5.1.4
8
19
  ==========
9
20
 
data/README.md CHANGED
@@ -63,6 +63,10 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
63
63
 
64
64
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
65
65
 
66
+ ### Counters and verify_reply_keys
67
+
68
+ memcached names an item made by auto-create for a base64-encoded key (one with whitespace, control or non-ASCII characters) by its raw key bytes in later replies. Since 5.1.5, Dalli creates counters for such keys with an add rather than auto-create. Items created before then, or by other clients, can still be named that way until memcached releases a fix (1.6.45 and earlier behave this way). If keys passed to `incr` or `decr` with an initial value can contain untrusted input, set `verify_reply_keys: true` until then: multi-gets then ignore replies for keys they didn't request, at a cost of about 15% per multi-get.
69
+
66
70
  ## OpenTelemetry Tracing
67
71
 
68
72
  Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
data/lib/dalli/client.rb CHANGED
@@ -50,6 +50,11 @@ module Dalli
50
50
  # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
51
51
  # +nil+ (default) omits the attribute entirely.
52
52
  # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
53
+ # - :verify_reply_keys - defaults to false. When true, get_multi, get_multi_cas and get_multi_with_metadata only
54
+ # accept replies for the keys they requested; after a reply naming another key, the rest of
55
+ # that server's replies are treated as misses. memcached can name an item by its raw key
56
+ # bytes when the item was created by auto-create from a base64-encoded key, which Dalli no
57
+ # longer does. Costs a multi-get about 15%.
53
58
  #
54
59
  def initialize(servers = nil, options = {})
55
60
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -745,6 +750,11 @@ module Dalli
745
750
  Dalli.logger.debug { e.inspect }
746
751
  Dalli.logger.debug { 'retrying single-server get_multi because of network error' }
747
752
  retry
753
+ rescue Dalli::UnexpectedReplyKeyError => e
754
+ # The connection is closed and the keys are treated as missing, as the
755
+ # pipelined get_multi does for a server whose replies are out of step
756
+ Dalli.logger.warn { e.message }
757
+ {}
748
758
  end
749
759
 
750
760
  def single_server_get_multi_with_metadata(keys, req_options = nil)
@@ -756,6 +766,9 @@ module Dalli
756
766
  result
757
767
  rescue Dalli::NetworkError
758
768
  {}
769
+ rescue Dalli::UnexpectedReplyKeyError => e
770
+ Dalli.logger.warn { e.message }
771
+ {}
759
772
  end
760
773
 
761
774
  def single_server_set_multi(hash, ttl, req_options)
@@ -41,6 +41,7 @@ module Dalli
41
41
  warn_uri_credentials(user_creds)
42
42
  @options = client_options.merge(user_creds)
43
43
  @raw_mode = client_options[:raw]
44
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
44
45
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
45
46
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
46
47
  end
@@ -147,6 +148,8 @@ module Dalli
147
148
  # avoiding intermediate Hash allocation. Returns nil.
148
149
  # Without a block, returns a Hash of { key => [value, cas] }.
149
150
  def pipeline_next_responses(&block)
151
+ return abandon_pipeline if @pipeline_out_of_step
152
+
150
153
  reconnect_on_pipeline_complete!
151
154
  values = nil
152
155
 
@@ -168,6 +171,13 @@ module Dalli
168
171
  # to the noop at the end of the pipeline
169
172
  finish_pipeline && break if status && key.nil?
170
173
 
174
+ # A key that wasn't requested means the replies are out of step
175
+ # (see expect_reply_keys): keep what was read so far, drop the rest
176
+ if unexpected_reply_key?(key)
177
+ abandon_pipeline
178
+ break
179
+ end
180
+
171
181
  # If the status is ok and the key is not nil, then this is a
172
182
  # getkq response with a value that we want to set in the response hash
173
183
  unless key.nil?
@@ -332,6 +342,7 @@ module Dalli
332
342
  # the expected state before pipeline_response_setup is called.
333
343
  response_buffer.clear
334
344
  response_buffer.raw = raw_request?(options)
345
+ expect_reply_keys(keys)
335
346
 
336
347
  # The terminating noop is sent by pipeline_response_setup
337
348
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -344,6 +355,7 @@ module Dalli
344
355
  # Initialize the response buffer for draining during send phase
345
356
  response_buffer.ensure_ready
346
357
  response_buffer.raw = raw_request?(options)
358
+ expect_reply_keys(keys)
347
359
 
348
360
  keys.each_slice(chunk_size) do |chunk|
349
361
  # Build and write this chunk of requests
@@ -376,6 +388,12 @@ module Dalli
376
388
  status, cas, key, value = response_buffer.process_single_getk_response
377
389
  break if status.nil? # No complete response available
378
390
 
391
+ # Handled by the next pipeline_next_responses, once sending is done
392
+ if unexpected_reply_key?(key)
393
+ @pipeline_out_of_step = true
394
+ break
395
+ end
396
+
379
397
  results[key] = [value, cas] unless key.nil?
380
398
  end
381
399
  rescue SystemCallError, Dalli::NetworkError
@@ -387,6 +405,42 @@ module Dalli
387
405
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
388
406
  end
389
407
 
408
+ # A multi-get only accepts replies for the keys it asked for. memcached
409
+ # names an item created by auto-create (ma or mg with N) for a
410
+ # base64-encoded key by its raw bytes, without the b flag; such a key
411
+ # can contain spaces or CR/LF, which split the reply header and make the
412
+ # replies after it parse as different ones. A key that wasn't requested
413
+ # is how that shows up.
414
+ # Only with the verify_reply_keys option, since building the set costs a
415
+ # multi-get about 15%. Dalli itself no longer creates such items (see
416
+ # Meta#decr_incr); the check guards against ones that older versions or
417
+ # other clients created, on memcached versions with the behavior.
418
+ def expect_reply_keys(keys)
419
+ @pipeline_out_of_step = false
420
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
421
+ end
422
+
423
+ def unexpected_reply_key?(key)
424
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
425
+ end
426
+
427
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
428
+ def reply_key_form(key)
429
+ key.ascii_only? ? key : key.b
430
+ end
431
+
432
+ # Keeps what was read, drops the rest of this server's replies, and
433
+ # closes the connection so nothing unread is left on it. Not a socket
434
+ # failure, so it doesn't count toward socket_max_failures; the keys not
435
+ # read are simply missing from the result.
436
+ def abandon_pipeline
437
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
438
+ @pipeline_out_of_step = false
439
+ response_buffer.clear
440
+ @connection_manager.close
441
+ {}
442
+ end
443
+
390
444
  # Called after the noop response is received at the end of a set
391
445
  # of pipelined gets
392
446
  def finish_pipeline
@@ -224,12 +224,45 @@ module Dalli
224
224
  # rubocop:disable-next Metrics/ParameterLists
225
225
  def decr_incr(incr, key, delta, ttl, initial, options = nil)
226
226
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
227
+ # memcached stores an item created by ma's auto-create (N/J) for a
228
+ # base64-encoded key without its binary-key flag, and later replies
229
+ # name it with the raw key bytes. Create such counters with an add.
230
+ return decr_incr_with_add(incr, key, delta, ttl, initial, options) if initial && KeyRegularizer.required?(key)
231
+
227
232
  write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: initial, incr: incr, ttl: ttl,
228
233
  quiet: quiet?, **routing_token_kwargs(options)))
229
234
  @connection_manager.flush unless quiet?
230
235
  response_processor.decr_incr unless quiet?
231
236
  end
232
237
 
238
+ # Like decr_incr with an initial value, without memcached's auto-create:
239
+ # on a miss the counter is added with the initial value (which is
240
+ # returned, as auto-create would), and the add losing a race to another
241
+ # client means the counter now exists, so the incr is tried again. In a
242
+ # quiet block both requests are sent and whichever applies takes effect.
243
+ # rubocop:disable-next Metrics/ParameterLists
244
+ def decr_incr_with_add(incr, key, delta, ttl, initial, options)
245
+ add_options = (options || {}).merge(raw: true) # counters are stored raw
246
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
247
+
248
+ 2.times do
249
+ value = decr_incr(incr, key, delta, nil, nil, options)
250
+ return value unless value.nil?
251
+
252
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
253
+ return initial if response_processor.meta_set_with_cas
254
+ end
255
+ decr_incr(incr, key, delta, nil, nil, options)
256
+ end
257
+
258
+ # rubocop:disable-next Metrics/ParameterLists
259
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
260
+ write(RequestFormatter.meta_arithmetic(key: key, delta: delta, initial: nil, incr: incr, ttl: nil,
261
+ quiet: true, **routing_token_kwargs(add_options)))
262
+ # Sent with the rest of the quiet block, by its closing noop
263
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
264
+ end
265
+
233
266
  # Other Commands
234
267
  def flush(delay = 0)
235
268
  write(RequestFormatter.flush(delay: delay))
@@ -271,7 +304,7 @@ module Dalli
271
304
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
272
305
  flushed_write(buffer)
273
306
  buffer.clear
274
- read_multi_get_responses(is_raw)
307
+ read_multi_get_responses(is_raw, keys)
275
308
  end
276
309
 
277
310
  # Stale-aware bulk get. Returns { key => { value:, cas:, stale:, miss: } }
@@ -288,12 +321,13 @@ module Dalli
288
321
  **routing_token_kwargs(options))
289
322
  flushed_write(buffer)
290
323
  buffer.clear
291
- read_multi_metadata_responses(is_raw)
324
+ read_multi_metadata_responses(is_raw, keys)
292
325
  end
293
326
 
294
327
  # Unlike read_multi_get_responses this locates tokens by flag rather than
295
328
  # by position, because the c flag shifts the key's index.
296
- def read_multi_metadata_responses(is_raw)
329
+ def read_multi_metadata_responses(is_raw, keys)
330
+ expect_reply_keys(keys)
297
331
  hash = {}
298
332
  while (line = @connection_manager.read_line)
299
333
  break if line.start_with?('MN')
@@ -309,12 +343,15 @@ module Dalli
309
343
  key = response_processor.key_from_tokens(tokens)
310
344
  next if key.nil?
311
345
 
346
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
347
+
312
348
  hash[key] = { value: @value_marshaller.retrieve(value, bitflags), cas: cas, stale: stale, miss: false }
313
349
  end
314
350
  hash
315
351
  end
316
352
 
317
- def read_multi_get_responses(is_raw)
353
+ def read_multi_get_responses(is_raw, keys)
354
+ expect_reply_keys(keys)
318
355
  hash = {}
319
356
  key_index = is_raw ? 2 : 3
320
357
  while (line = @connection_manager.read_line)
@@ -322,11 +359,19 @@ module Dalli
322
359
  next unless line.start_with?('VA ')
323
360
 
324
361
  key, value = parse_multi_get_value(line, key_index, is_raw)
362
+ raise_unexpected_reply_key(key) if unexpected_reply_key?(key)
325
363
  hash[key] = value if key
326
364
  end
327
365
  hash
328
366
  end
329
367
 
368
+ # The replies after a key that wasn't requested can't be trusted (see
369
+ # Base#expect_reply_keys). Raising ends the request, which closes the
370
+ # connection; the caller treats this server's keys as missing.
371
+ def raise_unexpected_reply_key(key)
372
+ raise Dalli::UnexpectedReplyKeyError, "#{name} replied with a key that wasn't requested: #{key.inspect}"
373
+ end
374
+
330
375
  def parse_multi_get_value(line, key_index, is_raw)
331
376
  tokens = line.chomp!(TERMINATOR).split
332
377
  size = tokens[1].to_i
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.1.4'
4
+ VERSION = '5.1.5'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -31,6 +31,10 @@ module Dalli
31
31
  # socket/server communication error that can be retried
32
32
  class RetryableNetworkError < NetworkError; end
33
33
 
34
+ # a multi-get reply named a key that wasn't requested, so the rest of that
35
+ # server's replies can't be trusted
36
+ class UnexpectedReplyKeyError < DalliError; end
37
+
34
38
  # Implements the NullObject pattern to store an application-defined value for 'Key not found' responses.
35
39
  class NilObject; end # rubocop:disable Lint/EmptyClass
36
40
  NOT_FOUND = NilObject.new
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.4
4
+ version: 5.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -88,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 4.0.21
91
+ rubygems_version: 4.0.23
92
92
  specification_version: 4
93
93
  summary: High performance memcached client for Ruby
94
94
  test_files: []