dalli 4.3.5 → 4.3.6

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: 96ac26bd7ef748d2407efaa5d1bcfe8f762c2eb47d67f85fb6e19f789926abae
4
- data.tar.gz: 617744189a342d6b39bd741a685b997bb0fbbae82afb3aeaafbf87ac79f6fe29
3
+ metadata.gz: dd77fe4e8ef65f531c5d9eb4a8e638feb6e443493cd57125bad104867399cd19
4
+ data.tar.gz: 814cc63401565f306a2aa6045476579f93b028fff8802ea443cde65375328636
5
5
  SHA512:
6
- metadata.gz: 7c0f37ce570d445484045722da9d5463879d825cb0ff2c8436e59852e0a996b44be61ebf942eb7bfc14846fb64991edf4cbf75c5bfa5871d8f3409b8a3fae25d
7
- data.tar.gz: 3a96488a8bb4da71afbb724745da958bec01ce5e9e229d7b5f0be0f5bd56513a0de7acefba6feab012efab5cd8d9971a5956a4aa5f1c9b154f503721bd703b72
6
+ metadata.gz: c983d9e656540f313aaea157b8d0290c3610b7c03575625a6e8cfba4ec5be99a893955506b409867689de1a5f4acc7715eee07928df8ef5c8724141ffae243b2
7
+ data.tar.gz: 62d38b83c8ba58ec871613b25e5f197d14c768436cde9f3aa382189dd6af19802832c545474add6a8b8b9edb373c948cde2db15599b5bba1b15720db71d1cfed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 4.3.6
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
13
+ - The pipelined reply parser took any reply without a body for the end of the batch. An error reply for one key (`CLIENT_ERROR`, `SERVER_ERROR`, or `EN` from a proxy) ended it early, and the replies still on the connection were read as the replies to later commands, so a `get` could return another key's value
14
+ - Keys are now measured in bytes as sent (after base64 encoding when the meta protocol needs it). A key under 250 characters but over 250 bytes, for example one with many non-ASCII characters, passed validation:
15
+ - with the meta protocol, memcached rejected it with the error reply above
16
+ - with the binary protocol, memcached dropped the connection, and `get` or `get_multi` with that key retried forever
17
+ - Such keys are now truncated like other long keys; keys that worked before are unchanged
18
+ - The reply parser affects `protocol: :meta` since 3.2.0; the key length affects both protocols; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
19
+ - Stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
20
+ - A server that accepted connections but never answered (or dropped the connection on a request) was retried forever, hanging the caller, because each successful reconnect reset the failure count. Requests now fail after `socket_max_failures` attempts and the server is marked down, as when it can't be reached at all; it gets a full set of attempts again after `down_retry_delay`
21
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
22
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
23
+ - Values flagged as compressed were inflated without limit, so a small stored value could expand to gigabytes on read, whatever the client's `compress` or `serializer` settings. The new `decompressed_max_bytes` option (default 128 MiB; `nil` disables it) makes a read that would pass it raise `Dalli::UnmarshalError`. Custom compressors whose `decompress` takes only the data keep working, without the limit
24
+ - The value size in a reply (a meta `VA` size or a binary body length) was used to read or buffer that many bytes, so a malicious server could make the client allocate gigabytes. Sizes over 1 GiB, memcached's largest item, now raise `Dalli::DalliError` before reading
25
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
26
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
27
+ - With the binary protocol, `get`, `gat` and `fetch`, and with the meta protocol, `get_with_metadata`, ignored a per-request `raw: true` and deserialized the value according to its stored flags, so a caller who asked for raw bytes could still have `Marshal.load` run on data someone else wrote to memcached. They now return the stored bytes
28
+ - The `raw` part affects binary `get` and `gat` in all versions, and `get_with_metadata` since 4.2.0
29
+ - `serializer: JSON` reads values with `JSON.load`, which on json gem versions before 3.0 creates an object of the class named in a stored `json_class` key when the json additions are loaded. The new `Dalli::JSONSerializer` reads with `JSON.parse` and only returns plain JSON types. The README and the Marshal security warning now recommend it; `serializer: JSON` itself is unchanged
30
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
31
+ - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
32
+ - A child forked while the parent had requests buffered but not yet sent, such as quiet writes inside a `quiet` block, sent them again when it closed the client or reconnected: quiet writes ran twice, and the parent's later replies could be read as the replies to other requests, returning one key's value for another. Dalli now buffers requests itself and a forked child discards them
33
+ - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
34
+ - With TLS, a forked child that closed the client (or reconnected after detecting the fork) sent a TLS close on the connection the parent was still using, ending the parent's session. A forked child now closes only its own copy of the socket
35
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
36
+
37
+ Bug fixes:
38
+
39
+ - An exception raised by a `get_multi` block (such as `Timeout::Error`) was treated as a network failure: swallowed, with the whole `get_multi` retried and keys yielded twice. It now reaches the caller
40
+ - Fix `set_multi` with the binary protocol
41
+ - Each quiet `setq` waited for a reply memcached never sends for a successful write, so `set_multi` timed out on its first key without storing anything (and, with retries, hung or marked the server down). Affects 4.2.0 and later
42
+
43
+ Notes:
44
+
45
+ - Document [Ruby bug 21195](https://bugs.ruby-lang.org/issues/21195), a VM crash (`[BUG] rb_sys_fail_path_in(io_fillbuf, ...) - errno == 0`) when a socket read using `IO#timeout` is interrupted by a signal, in the README (#1188)
46
+ - Affects Ruby 3.2.x, 3.3.0–3.3.7 and 3.4.0–3.4.2. Fixed in 3.3.8 and 3.4.3; Ruby 3.2 reached end of life without the fix
47
+ - Ruby 3.1 doesn't use `IO#timeout` and isn't affected
48
+
49
+ Development:
50
+
51
+ - Drop Ruby 3.2 from the CI matrix, since that bug intermittently crashed its test runs (#1188)
52
+ - Fix three flaky tests: the `KeyManager` namespace key-length test, the Rack session freshness test, and `MemcachedManager`'s start and stop handling (backport of #1141, #1182)
53
+ - Fix flaky failover tests: move their ports out of Linux's ephemeral port range, and wait for memcached to accept connections after starting it (backport of #1184)
54
+
7
55
  4.3.5
8
56
  ==========
9
57
 
data/README.md CHANGED
@@ -20,6 +20,18 @@ The name is a variant of Salvador Dali for his famous painting [The Persistence
20
20
  * Ruby 3.1 or later
21
21
  * memcached 1.4 or later (1.6+ recommended for meta protocol support)
22
22
 
23
+ ### Known Ruby issue: crash on an interrupted socket read
24
+
25
+ On Ruby 3.2 and later, Dalli sets socket timeouts with `IO#timeout`. Ruby 3.2.x, 3.3.0–3.3.7 and 3.4.0–3.4.2 have a bug ([Ruby #21195](https://bugs.ruby-lang.org/issues/21195)) where a timed read interrupted by a signal aborts the whole process:
26
+
27
+ ```
28
+ [BUG] rb_sys_fail_path_in(io_fillbuf, fd:N ) - errno == 0
29
+ ```
30
+
31
+ The crash is inside Ruby itself, so it can't be rescued. It was fixed in Ruby 3.3.8 and 3.4.3. Ruby 3.2 reached end of life without the fix. If you see this crash, upgrade to Ruby 3.3.8+ or 3.4.3+. Ruby 3.1 doesn't use `IO#timeout` and isn't affected.
32
+
33
+ Ruby 3.2 is no longer part of this branch's CI matrix for the same reason.
34
+
23
35
  ## Protocol Options
24
36
 
25
37
  Dalli supports two protocols for communicating with memcached:
@@ -61,9 +73,13 @@ The separator must be a single non-alphanumeric character. Valid examples: `:`,
61
73
  By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
62
74
 
63
75
  ```ruby
64
- Dalli::Client.new('localhost:11211', serializer: JSON)
76
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
65
77
  ```
66
78
 
79
+ `Dalli::JSONSerializer` reads values with `JSON.parse`, so it only ever returns plain hashes, arrays, strings, numbers, booleans and `nil`. Passing the `JSON` module itself (`serializer: JSON`) reads values with `JSON.load`, which can create objects of other classes from a stored `json_class` key and is not recommended for data an attacker might write.
80
+
81
+ Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method that accepts it (`get`, `get_with_metadata` and `fetch`).
82
+
67
83
  See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
68
84
 
69
85
  ## OpenTelemetry Tracing
@@ -13,8 +13,49 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- def self.decompress(data)
17
- Zlib::Inflate.inflate(data)
16
+ # DEFLATE can't expand data by more than about 1032:1
17
+ MAX_EXPANSION = 1032
18
+ # Compressed input is fed to zlib this many bytes at a time. MRI's zlib
19
+ # yields the output in small chunks; one that returns it instead still
20
+ # produces at most MAX_EXPANSION times this per step.
21
+ INFLATE_SLICE_BYTES = 64 * 1024
22
+
23
+ # max_bytes caps the decompressed size; inflating stops and raises as soon
24
+ # as the output passes it, so a small compressed value can't expand into
25
+ # gigabytes of memory.
26
+ def self.decompress(data, max_bytes: nil)
27
+ return Zlib::Inflate.inflate(data) unless may_exceed?(data, max_bytes)
28
+
29
+ inflate_within_limit(Zlib::Inflate.new, data, max_bytes)
30
+ end
31
+
32
+ # False when even the largest possible expansion stays within max_bytes,
33
+ # so the value can be inflated in one step
34
+ def self.may_exceed?(data, max_bytes)
35
+ max_bytes ? data.bytesize * MAX_EXPANSION > max_bytes : false
36
+ end
37
+
38
+ def self.inflate_within_limit(inflater, data, max_bytes)
39
+ out = String.new(encoding: Encoding::BINARY)
40
+ 0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
+ inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
+ end
43
+ append_within_limit(out, inflater.finish, max_bytes)
44
+ ensure
45
+ inflater.close
46
+ end
47
+
48
+ # Both yielded and returned output count toward the limit
49
+ def self.inflate_slice(inflater, slice, out, max_bytes)
50
+ rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
51
+ append_within_limit(out, rest, max_bytes)
52
+ end
53
+
54
+ def self.append_within_limit(out, chunk, max_bytes)
55
+ out << chunk if chunk
56
+ return out if out.bytesize <= max_bytes
57
+
58
+ raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
18
59
  end
19
60
  end
20
61
 
@@ -32,9 +73,11 @@ module Dalli
32
73
  io.string
33
74
  end
34
75
 
35
- def self.decompress(data)
36
- io = StringIO.new(data, 'rb')
37
- Zlib::GzipReader.new(io).read
76
+ def self.decompress(data, max_bytes: nil)
77
+ return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
78
+
79
+ # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
80
+ Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
38
81
  end
39
82
  end
40
83
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Dalli
6
+ ##
7
+ # A serializer that stores values as JSON and never creates Ruby objects
8
+ # from what it reads.
9
+ #
10
+ # Passing the JSON module itself as the serializer (serializer: JSON) reads
11
+ # values back with JSON.load, which on json gem versions before 3.0 honors
12
+ # "json_class" and instantiates other classes when the json gem's additions
13
+ # are loaded. This serializer reads with JSON.parse, so a value written to
14
+ # memcached by someone else can only come back as plain hashes, arrays,
15
+ # strings, numbers, booleans and nil.
16
+ #
17
+ # Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)
18
+ ##
19
+ module JSONSerializer
20
+ def self.dump(value)
21
+ JSON.generate(value)
22
+ end
23
+
24
+ def self.load(data)
25
+ JSON.parse(data)
26
+ end
27
+ end
28
+ end
@@ -41,6 +41,9 @@ module Dalli
41
41
 
42
42
  @namespace = namespace_from_options
43
43
  @namespace_separator = @key_options[:namespace_separator]
44
+ # The meta protocol base64-encodes keys with whitespace or non-ASCII
45
+ # characters, so their length on the wire differs from their length here
46
+ @meta_protocol = client_options[:protocol]&.to_s == 'meta'
44
47
  end
45
48
 
46
49
  ##
@@ -58,7 +61,7 @@ module Dalli
58
61
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
59
62
 
60
63
  key = key_with_namespace(key)
61
- key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
64
+ wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
62
65
  end
63
66
 
64
67
  ##
@@ -122,7 +125,27 @@ module Dalli
122
125
  ##
123
126
  def truncated_key(key)
124
127
  digest = digest_class.hexdigest(key)
125
- "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
128
+ prefix = key[0, prefix_length(digest)]
129
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
130
+ # A prefix with whitespace or non-ASCII characters sends the whole key
131
+ # base64-encoded, which can still be too long on the wire; shorten it.
132
+ while wire_length(truncated) > MAX_KEY_LENGTH
133
+ prefix = prefix[0...-1]
134
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
135
+ end
136
+ truncated
137
+ end
138
+
139
+ # memcached's key limit applies to the key as sent: in bytes, and, with
140
+ # the meta protocol, after base64 encoding when the key needs it. A key
141
+ # within the limit in characters can still be too long. With the meta
142
+ # protocol memcached rejects it with a CLIENT_ERROR that must never reach
143
+ # a pipelined get; with the binary protocol it drops the connection.
144
+ def wire_length(key)
145
+ return key.bytesize unless @meta_protocol
146
+
147
+ encoded, = Dalli::Protocol::Meta::KeyRegularizer.encode(key)
148
+ encoded.bytesize
126
149
  end
127
150
 
128
151
  def prefix_length(digest)
@@ -34,6 +34,13 @@ module Dalli
34
34
  @raw_mode
35
35
  end
36
36
 
37
+ # True when values should come back as stored: the client is in raw mode,
38
+ # or this request passed raw: true. A stored value is then never
39
+ # deserialized or decompressed for a caller who asked for raw bytes.
40
+ def raw_request?(options)
41
+ raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
42
+ end
43
+
37
44
  # Chokepoint method for error handling and ensuring liveness
38
45
  def request(opkey, *args)
39
46
  verify_state(opkey)
@@ -99,7 +106,15 @@ module Dalli
99
106
  reconnect_on_pipeline_complete!
100
107
  values = nil
101
108
 
102
- response_buffer.read
109
+ # Only the read is socket I/O. The caller's block runs below, outside
110
+ # this rescue, so an Errno or Timeout::Error it raises reaches the
111
+ # caller instead of being mistaken for a network failure (which closed
112
+ # the connection and retried the whole get_multi, yielding keys twice).
113
+ begin
114
+ response_buffer.read
115
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
116
+ @connection_manager.error_on_request!(e)
117
+ end
103
118
 
104
119
  status, cas, key, value = response_buffer.process_single_getk_response
105
120
  # status is not nil only if we have a full response to parse
@@ -125,8 +140,6 @@ module Dalli
125
140
  end
126
141
 
127
142
  values || {}
128
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
129
- @connection_manager.error_on_request!(e)
130
143
  end
131
144
 
132
145
  # Abort current pipelined get. Generally used to signal an external
@@ -66,7 +66,9 @@ module Dalli
66
66
  raise Dalli::DalliError, "Response error #{resp_header.status}: #{RESPONSE_CODES[resp_header.status]}"
67
67
  end
68
68
 
69
- def get(cache_nils: false)
69
+ # raw: true returns the value as stored, without deserializing or
70
+ # decompressing it, whatever flags it was stored with
71
+ def get(cache_nils: false, raw: false)
70
72
  resp_header, body = read_response
71
73
 
72
74
  return false if resp_header.not_stored? # Not stored, normal status for add operation
@@ -75,7 +77,7 @@ module Dalli
75
77
  raise_on_not_ok!(resp_header)
76
78
  return true unless body
77
79
 
78
- unpack_response_body(resp_header, body, true).last
80
+ unpack_response_body(resp_header, body, !raw).last
79
81
  end
80
82
 
81
83
  ##
@@ -199,6 +201,17 @@ module Dalli
199
201
  # The remaining three values in the array are the ResponseHeader,
200
202
  # key, and value.
201
203
  ##
204
+ # memcached can't store an item larger than 1 GiB (its -I maximum). A
205
+ # pipelined reply claiming an impossible size would otherwise have the
206
+ # buffer wait for (and accumulate) that many bytes.
207
+ MAX_BODY_BYTES = 1024 * 1024 * 1024
208
+
209
+ def check_body_size!(size)
210
+ return if size <= MAX_BODY_BYTES
211
+
212
+ raise Dalli::DalliError, "Reply body size #{size} is out of range"
213
+ end
214
+
202
215
  def getk_response_from_buffer(buf, offset = 0)
203
216
  # There's no header in the buffer, so don't advance
204
217
  return [0, nil, nil, nil, nil] unless buf && buf.bytesize >= offset + ResponseHeader::SIZE
@@ -212,6 +225,8 @@ module Dalli
212
225
  # error response that needs to be discarded.
213
226
  return [ResponseHeader::SIZE, resp_header.ok?, resp_header.cas, nil, nil] if body_len.zero?
214
227
 
228
+ check_body_size!(body_len)
229
+
215
230
  resp_size = ResponseHeader::SIZE + body_len
216
231
  # The header is in the buffer, but the body is not. As we don't have
217
232
  # a complete response, don't advance the buffer
@@ -23,7 +23,7 @@ module Dalli
23
23
  req = RequestFormatter.standard_request(opkey: :get, key: key)
24
24
  write(req)
25
25
  @connection_manager.flush
26
- response_processor.get(cache_nils: cache_nils?(options))
26
+ response_processor.get(cache_nils: cache_nils?(options), raw: raw_request?(options))
27
27
  end
28
28
 
29
29
  def quiet_get_request(key)
@@ -35,7 +35,7 @@ module Dalli
35
35
  req = RequestFormatter.standard_request(opkey: :gat, key: key, ttl: ttl)
36
36
  write(req)
37
37
  @connection_manager.flush
38
- response_processor.get(cache_nils: cache_nils?(options))
38
+ response_processor.get(cache_nils: cache_nils?(options), raw: raw_request?(options))
39
39
  end
40
40
 
41
41
  def touch(key, ttl)
@@ -63,7 +63,7 @@ module Dalli
63
63
  # Pipelined set - writes a quiet set request without reading response.
64
64
  # Used by PipelinedSetter for bulk operations.
65
65
  def pipelined_set(key, value, ttl, options)
66
- storage_req(:setq, key, value, ttl, 0, options)
66
+ storage_req(:setq, key, value, ttl, 0, options, quiet: true)
67
67
  end
68
68
 
69
69
  def add(key, value, ttl, options)
@@ -77,7 +77,7 @@ module Dalli
77
77
  end
78
78
 
79
79
  # rubocop:disable-next Metrics/ParameterLists
80
- def storage_req(opkey, key, value, ttl, cas, options)
80
+ def storage_req(opkey, key, value, ttl, cas, options, quiet: quiet?)
81
81
  (value, bitflags) = @value_marshaller.store(key, value, options)
82
82
  ttl = TtlSanitizer.sanitize(ttl)
83
83
 
@@ -85,8 +85,8 @@ module Dalli
85
85
  value: value, bitflags: bitflags,
86
86
  ttl: ttl, cas: cas)
87
87
  write(req)
88
- @connection_manager.flush unless quiet?
89
- response_processor.storage_response unless quiet?
88
+ @connection_manager.flush unless quiet
89
+ response_processor.storage_response unless quiet
90
90
  end
91
91
 
92
92
  def append(key, value)
@@ -37,7 +37,10 @@ module Dalli
37
37
  @request_in_progress = false
38
38
  @sock = nil
39
39
  @pid = nil
40
+ @write_buffer = []
41
+ @write_buffer_bytes = 0
40
42
 
43
+ @fail_count = 0
41
44
  reset_down_info
42
45
  end
43
46
 
@@ -53,7 +56,8 @@ module Dalli
53
56
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
54
57
 
55
58
  @sock = memcached_socket
56
- @sock.sync = false # Enable buffered I/O for better performance
59
+ # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
60
+ @sock.sync = true
57
61
  @pid = PIDCache.pid
58
62
  @request_in_progress = false
59
63
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
@@ -85,6 +89,8 @@ module Dalli
85
89
  def down!
86
90
  close
87
91
  log_down_detected
92
+ # Once down_retry_delay passes, the server gets a full set of attempts
93
+ @fail_count = 0
88
94
 
89
95
  @error = $ERROR_INFO&.class&.name
90
96
  @msg ||= $ERROR_INFO&.message
@@ -114,15 +120,23 @@ module Dalli
114
120
  return unless @sock
115
121
 
116
122
  begin
117
- @sock.close
123
+ close_socket
118
124
  rescue StandardError
119
125
  nil
120
126
  end
121
127
  @sock = nil
122
128
  @pid = nil
129
+ discard_write_buffer
123
130
  abort_request!
124
131
  end
125
132
 
133
+ # A forked child shares the parent's connection, so it closes only its
134
+ # own file descriptor. Closing the TLS socket itself would send
135
+ # close_notify and end the parent's TLS session.
136
+ def close_socket
137
+ fork_detected? ? @sock.to_io.close : @sock.close
138
+ end
139
+
126
140
  def connected?
127
141
  !@sock.nil?
128
142
  end
@@ -141,6 +155,11 @@ module Dalli
141
155
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
142
156
 
143
157
  @request_in_progress = false
158
+ # A completed request is what proves the server healthy again, so the
159
+ # failure count resets here rather than on reconnect: a server that
160
+ # accepts connections but never answers would otherwise reset it on
161
+ # every retry and never reach socket_max_failures.
162
+ @fail_count = 0
144
163
  end
145
164
 
146
165
  def abort_request!
@@ -148,6 +167,7 @@ module Dalli
148
167
  end
149
168
 
150
169
  def read_line
170
+ flush_write_buffer
151
171
  data = @sock.gets("\r\n")
152
172
  error_on_request!('EOF in read_line') if data.nil?
153
173
  data
@@ -155,27 +175,53 @@ module Dalli
155
175
  error_on_request!(e)
156
176
  end
157
177
 
178
+ # memcached can't store an item larger than 1 GiB (its -I maximum), so a
179
+ # reply claiming more (or a negative size) is malformed or hostile.
180
+ # Reading allocates the full count up front, so check before reading.
181
+ MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
182
+
183
+ def check_read_size!(count)
184
+ return if count.between?(0, MAX_READ_BYTES)
185
+
186
+ raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
187
+ end
188
+
158
189
  def read(count)
190
+ check_read_size!(count)
191
+ flush_write_buffer
159
192
  @sock.readfull(count)
160
193
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
161
194
  error_on_request!(e)
162
195
  end
163
196
 
197
+ # Requests are buffered here rather than in the socket's own IO buffer
198
+ # (the socket is sync). Ruby flushes an IO's write buffer when any process
199
+ # holding it closes or finalizes the IO, so after a fork a child could
200
+ # send bytes the parent had buffered but not sent yet: a quiet write would
201
+ # run twice, or the parent's replies would shift onto the wrong requests.
202
+ # A forked child discards this buffer instead. Like IO's own buffer, it
203
+ # is sent once it grows past this size, or when a reply is read.
204
+ WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
205
+
164
206
  def write(bytes)
165
- @sock.write(bytes)
166
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
207
+ @write_buffer << bytes
208
+ @write_buffer_bytes += bytes.bytesize
209
+ flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
210
+ bytes.bytesize
211
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
167
212
  error_on_request!(e)
168
213
  end
169
214
 
170
215
  def flush
171
- @sock.flush
172
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS => e
216
+ flush_write_buffer
217
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
173
218
  error_on_request!(e)
174
219
  end
175
220
 
176
221
  # Non-blocking read. Here to support the operation
177
222
  # of the get_multi operation
178
223
  def read_nonblock
224
+ flush_write_buffer
179
225
  @sock.read_available
180
226
  end
181
227
 
@@ -202,8 +248,9 @@ module Dalli
202
248
  raise Dalli::RetryableNetworkError, message
203
249
  end
204
250
 
251
+ # Called on connect. Deliberately leaves @fail_count alone; see
252
+ # finish_request!.
205
253
  def reset_down_info
206
- @fail_count = 0
207
254
  @down_at = nil
208
255
  @last_down_at = nil
209
256
  @msg = nil
@@ -228,7 +275,8 @@ module Dalli
228
275
  def reconnect_on_fork
229
276
  message = 'Fork detected, re-connecting child process...'
230
277
  Dalli.logger.info { message }
231
- # Close socket on a fork and reconnect immediately
278
+ # Drops anything the parent had buffered, closes the inherited socket
279
+ # without touching the parent's connection, and reconnects immediately
232
280
  close
233
281
  establish_connection
234
282
  end
@@ -255,6 +303,20 @@ module Dalli
255
303
  time = Time.now - @down_at
256
304
  Dalli.logger.warn { format('%<name>s is back (downtime was %<time>.3f seconds)', name: name, time: time) }
257
305
  end
306
+
307
+ private
308
+
309
+ def flush_write_buffer
310
+ return if @write_buffer.empty?
311
+
312
+ @sock.write(*@write_buffer)
313
+ discard_write_buffer
314
+ end
315
+
316
+ def discard_write_buffer
317
+ @write_buffer.clear
318
+ @write_buffer_bytes = 0
319
+ end
258
320
  end
259
321
  end
260
322
  end
@@ -172,13 +172,14 @@ module Dalli
172
172
  header_len = header.bytesize + TERMINATOR.length
173
173
  body_len = body_len_from_tokens(tokens)
174
174
 
175
- # We have a complete response that has no body.
176
- # This is either the response to the terminating
177
- # noop or, if the status is not MN, an intermediate
178
- # error response that needs to be discarded. A hit
179
- # on an empty value (VA 0) still has a body -- just
180
- # its terminator -- so it's parsed below as a value.
181
- return [header_len, true, nil, nil, nil] if no_body?(tokens, body_len)
175
+ # A complete response with no body. Only the terminating noop's MN
176
+ # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
177
+ # SERVER_ERROR, ...) answers one key's request: report it with a
178
+ # false status so the caller skips it and keeps reading. Treating it
179
+ # as the end would leave the remaining replies on the connection for
180
+ # later commands to read as their own. A hit on an empty value (VA 0)
181
+ # still has a body -- just its terminator -- so it's parsed below.
182
+ return [header_len, tokens.first == MN, nil, nil, nil] if no_body?(tokens, body_len)
182
183
 
183
184
  resp_size = header_len + body_len + TERMINATOR.length
184
185
  # The header is in the buffer, but the body is not. As we don't have
@@ -191,6 +192,17 @@ module Dalli
191
192
  full_response_from_buffer(tokens, body, resp_size)
192
193
  end
193
194
 
195
+ # memcached can't store an item larger than 1 GiB (its -I maximum). A
196
+ # pipelined reply claiming an impossible size would otherwise have the
197
+ # buffer wait for (and accumulate) that many bytes.
198
+ MAX_VALUE_BYTES = 1024 * 1024 * 1024
199
+
200
+ def check_value_size!(size)
201
+ return if size.between?(0, MAX_VALUE_BYTES)
202
+
203
+ raise Dalli::DalliError, "Reply value size #{size} is out of range"
204
+ end
205
+
194
206
  # A zero-size reply has no body unless it's a VA (a hit on an empty value)
195
207
  def no_body?(tokens, body_len)
196
208
  body_len.zero? && tokens.first != VA
@@ -236,7 +248,9 @@ module Dalli
236
248
  end
237
249
 
238
250
  def body_len_from_tokens(tokens)
239
- value_from_tokens(tokens, 's')&.to_i
251
+ size = value_from_tokens(tokens, 's')&.to_i
252
+ check_value_size!(size) if size
253
+ size
240
254
  end
241
255
 
242
256
  def value_from_tokens(tokens, flag)
@@ -96,7 +96,7 @@ module Dalli
96
96
  def meta_get(key, options = {})
97
97
  encoded_key, base64 = KeyRegularizer.encode(key)
98
98
  req = RequestFormatter.meta_get(
99
- key: encoded_key, value: true, return_cas: true, base64: base64,
99
+ key: encoded_key, value: true, return_cas: true, base64: base64, skip_flags: raw_request?(options),
100
100
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
101
101
  return_hit_status: options[:return_hit_status],
102
102
  return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
@@ -15,11 +15,17 @@ module Dalli
15
15
  compress: true,
16
16
  compressor: ::Dalli::Compressor,
17
17
  # min byte size to attempt compression
18
- compression_min_size: 4 * 1024 # 4K
18
+ compression_min_size: 4 * 1024, # 4K
19
+ # max size a stored value may decompress to (nil: no limit). Guards
20
+ # against a small compressed value expanding into gigabytes on read.
21
+ decompressed_max_bytes: 128 * 1024 * 1024 # 128 MiB
19
22
  }.freeze
20
23
 
21
24
  OPTIONS = DEFAULTS.keys.freeze
22
25
 
26
+ KEYWORD_PARAMETER_TYPES = %i[key keyreq].freeze
27
+ private_constant :KEYWORD_PARAMETER_TYPES
28
+
23
29
  # https://www.hjp.at/zettel/m/memcached_flags.rxml
24
30
  # Looks like most clients use bit 1 to indicate gzip compression.
25
31
  FLAG_COMPRESSED = 0x2
@@ -38,8 +44,16 @@ module Dalli
38
44
  end
39
45
 
40
46
  def retrieve(value, bitflags)
41
- compressed = bitflags.anybits?(FLAG_COMPRESSED)
42
- compressed ? compressor.decompress(value) : value
47
+ return value unless bitflags.anybits?(FLAG_COMPRESSED)
48
+
49
+ max_bytes = @compression_options[:decompressed_max_bytes]
50
+ # Custom compressors that only define decompress(data) keep working,
51
+ # without the limit
52
+ if max_bytes && compressor_accepts_max_bytes?
53
+ compressor.decompress(value, max_bytes: max_bytes)
54
+ else
55
+ compressor.decompress(value)
56
+ end
43
57
 
44
58
  # TODO: We likely want to move this rescue into the Dalli::Compressor / Dalli::GzipCompressor
45
59
  # itself, since not all compressors necessarily use Zlib. For now keep it here, so the behavior
@@ -48,6 +62,19 @@ module Dalli
48
62
  raise UnmarshalError, "Unable to uncompress value: #{$ERROR_INFO.message}"
49
63
  end
50
64
 
65
+ def compressor_accepts_max_bytes?
66
+ return @compressor_accepts_max_bytes if defined?(@compressor_accepts_max_bytes)
67
+
68
+ @compressor_accepts_max_bytes =
69
+ begin
70
+ compressor.method(:decompress).parameters.any? do |type, name|
71
+ name == :max_bytes && KEYWORD_PARAMETER_TYPES.include?(type)
72
+ end
73
+ rescue NameError # an object without a reflectable decompress method
74
+ false
75
+ end
76
+ end
77
+
51
78
  def compress_by_default?
52
79
  @compression_options[:compress]
53
80
  end
@@ -102,7 +102,7 @@ module Dalli
102
102
  Dalli.logger.warn 'SECURITY WARNING: Dalli is using Marshal for serialization. ' \
103
103
  'Marshal can execute arbitrary code during deserialization. ' \
104
104
  'If your memcached server could be compromised, consider using ' \
105
- 'a safer serializer like JSON: Dalli::Client.new(servers, serializer: JSON)'
105
+ 'a safer serializer: Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)'
106
106
  @@marshal_warning_logged = true # rubocop:disable Style/ClassVars
107
107
  end
108
108
  end
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.3.5'
4
+ VERSION = '4.3.6'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -62,6 +62,7 @@ require_relative 'dalli/version'
62
62
  require_relative 'dalli/instrumentation'
63
63
 
64
64
  require_relative 'dalli/compressor'
65
+ require_relative 'dalli/json_serializer'
65
66
  require_relative 'dalli/protocol_deprecations'
66
67
  require_relative 'dalli/client'
67
68
  require_relative 'dalli/key_manager'
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: 4.3.5
4
+ version: 4.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -41,6 +41,7 @@ files:
41
41
  - lib/dalli/client.rb
42
42
  - lib/dalli/compressor.rb
43
43
  - lib/dalli/instrumentation.rb
44
+ - lib/dalli/json_serializer.rb
44
45
  - lib/dalli/key_manager.rb
45
46
  - lib/dalli/options.rb
46
47
  - lib/dalli/pid_cache.rb