dalli 5.0.8 → 5.0.9

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: da626a6bfcabdac3d5c8db15d06e6c567e7a302e6b479c2db3989d40a8e32d88
4
- data.tar.gz: 1cdffab8886b14031f193af6e9dcd3cf26f463fc1612fb77c3ef2a8106ae41bf
3
+ metadata.gz: 0af4a94a5a73bfe31fff14832d012d06f80ce8284f32f5b4d821af6aa3e3f451
4
+ data.tar.gz: 6f431586953122b678e26c6147d4fd695005e9ffe6a6ddf6b083322fe43d983d
5
5
  SHA512:
6
- metadata.gz: 871db8e41047a834823c5c2c1caf9c4bc47265ea8b37e48f54673c7350b719e74c34d70ce27103cef4bf6acd6fba85786052ce05a9234ae6f3e98e1d30725f70
7
- data.tar.gz: 02c5b3085ca382fd612629af79a2d06f4dc9d960f579623a8af57f2a8e4332343467e886a12e73abe3b6c52a38504896e88e2d133eace4ec6f07e0ae68af8727
6
+ metadata.gz: d81ee013caea8988112d94ab60721fc331a82f8371bfbb00cc171f0c25d202eea25e1a9aa6523e02ef4da0d885963f800488fcbef2d1a934e6e174d9df673331
7
+ data.tar.gz: c7a0a2b2c58a8c8362660853c43274629b106296f758824328b5eaa804d23d66ee1f585580af593553d230345b23bff341e6f3ad0888a442e59ac0997ffcb4e3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.0.9
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 key needs it. A key under 250 characters but over 250 bytes on the wire (for example, one with many non-ASCII characters) was the easiest way to cause that error reply. Such keys are now truncated like other long keys; keys that worked before are unchanged
15
+ - Affects the meta protocol since 3.2.0 (the default since 5.0.0); fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
16
+ - Stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
17
+ - 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`
18
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
19
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
20
+ - 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
21
+ - The value size in a reply 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
22
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
23
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
24
+ - `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
25
+ - The `raw` part affects `get_with_metadata` since 4.2.0
26
+ - `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
27
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
28
+ - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
29
+ - 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
30
+ - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
31
+ - 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
32
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
33
+
34
+ Bug fixes:
35
+
36
+ - 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
37
+
38
+ Development:
39
+
40
+ - Add property tests for request building and fuzz tests for the reply parsers
41
+ - 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, #1181)
42
+ - 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)
43
+
7
44
  5.0.8
8
45
  ==========
9
46
 
data/README.md CHANGED
@@ -49,9 +49,13 @@ The separator must be a single non-alphanumeric character. Valid examples: `:`,
49
49
  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:
50
50
 
51
51
  ```ruby
52
- Dalli::Client.new('localhost:11211', serializer: JSON)
52
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
53
53
  ```
54
54
 
55
+ `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.
56
+
57
+ 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`).
58
+
55
59
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
56
60
 
57
61
  ## 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
@@ -58,7 +58,7 @@ module Dalli
58
58
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
59
59
 
60
60
  key = key_with_namespace(key)
61
- key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
62
62
  end
63
63
 
64
64
  ##
@@ -122,7 +122,25 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
125
+ prefix = key[0, prefix_length(digest)]
126
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
127
+ # A prefix with whitespace or non-ASCII characters sends the whole key
128
+ # base64-encoded, which can still be too long on the wire; shorten it.
129
+ while wire_length(truncated) > MAX_KEY_LENGTH
130
+ prefix = prefix[0...-1]
131
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
+ end
133
+ truncated
134
+ end
135
+
136
+ # memcached's key limit applies to the key as sent: in bytes, and after
137
+ # base64 encoding when the key needs it. A key can be within the limit in
138
+ # characters and still be rejected, and memcached's CLIENT_ERROR for it
139
+ # must never reach a pipelined get.
140
+ def wire_length(key)
141
+ return key.bytesize unless Dalli::Protocol::Meta::KeyRegularizer.required?(key)
142
+
143
+ ((key.bytesize + 2) / 3) * 4
126
144
  end
127
145
 
128
146
  def prefix_length(digest)
@@ -35,6 +35,14 @@ module Dalli
35
35
  @raw_mode
36
36
  end
37
37
 
38
+ # True when values should come back as stored: the client is in raw mode,
39
+ # or this request passed raw: true. Retrieval paths use it to skip
40
+ # requesting flags, so a stored value is never deserialized or
41
+ # decompressed for a caller who asked for raw bytes.
42
+ def raw_request?(options)
43
+ raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
44
+ end
45
+
38
46
  # Chokepoint method for error handling and ensuring liveness
39
47
  def request(opkey, *args)
40
48
  verify_state(opkey)
@@ -113,7 +121,15 @@ module Dalli
113
121
  reconnect_on_pipeline_complete!
114
122
  values = nil
115
123
 
116
- response_buffer.read
124
+ # Only the read is socket I/O. The caller's block runs below, outside
125
+ # this rescue, so an Errno or Timeout::Error it raises reaches the
126
+ # caller instead of being mistaken for a network failure (which closed
127
+ # the connection and retried the whole get_multi, yielding keys twice).
128
+ begin
129
+ response_buffer.read
130
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
131
+ @connection_manager.error_on_request!(e)
132
+ end
117
133
 
118
134
  status, cas, key, value = response_buffer.process_single_getk_response
119
135
  # status is not nil only if we have a full response to parse
@@ -139,8 +155,6 @@ module Dalli
139
155
  end
140
156
 
141
157
  values || {}
142
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
143
- @connection_manager.error_on_request!(e)
144
158
  end
145
159
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
146
160
 
@@ -35,7 +35,10 @@ module Dalli
35
35
  @request_in_progress = false
36
36
  @sock = nil
37
37
  @pid = nil
38
+ @write_buffer = []
39
+ @write_buffer_bytes = 0
38
40
 
41
+ @fail_count = 0
39
42
  reset_down_info
40
43
  end
41
44
 
@@ -51,7 +54,8 @@ module Dalli
51
54
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
52
55
 
53
56
  @sock = memcached_socket
54
- @sock.sync = false # Enable buffered I/O for better performance
57
+ # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
58
+ @sock.sync = true
55
59
  @pid = Process.pid
56
60
  @request_in_progress = false
57
61
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
@@ -83,6 +87,8 @@ module Dalli
83
87
  def down!
84
88
  close
85
89
  log_down_detected
90
+ # Once down_retry_delay passes, the server gets a full set of attempts
91
+ @fail_count = 0
86
92
 
87
93
  @error = $ERROR_INFO&.class&.name
88
94
  @msg ||= $ERROR_INFO&.message
@@ -112,7 +118,7 @@ module Dalli
112
118
  return unless @sock
113
119
 
114
120
  begin
115
- @sock.close
121
+ close_socket
116
122
  rescue StandardError
117
123
  nil
118
124
  ensure
@@ -123,10 +129,18 @@ module Dalli
123
129
  # @request_in_progress == true.
124
130
  @sock = nil
125
131
  @pid = nil
132
+ discard_write_buffer
126
133
  abort_request!
127
134
  end
128
135
  end
129
136
 
137
+ # A forked child shares the parent's connection, so it closes only its
138
+ # own file descriptor. Closing the TLS socket itself would send
139
+ # close_notify and end the parent's TLS session.
140
+ def close_socket
141
+ fork_detected? ? @sock.to_io.close : @sock.close
142
+ end
143
+
130
144
  def connected?
131
145
  !@sock.nil?
132
146
  end
@@ -145,6 +159,11 @@ module Dalli
145
159
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
146
160
 
147
161
  @request_in_progress = false
162
+ # A completed request is what proves the server healthy again, so the
163
+ # failure count resets here rather than on reconnect: a server that
164
+ # accepts connections but never answers would otherwise reset it on
165
+ # every retry and never reach socket_max_failures.
166
+ @fail_count = 0
148
167
  end
149
168
 
150
169
  def abort_request!
@@ -152,6 +171,7 @@ module Dalli
152
171
  end
153
172
 
154
173
  def read_line
174
+ flush_write_buffer
155
175
  data = @sock.gets("\r\n")
156
176
  error_on_request!('EOF in read_line') if data.nil?
157
177
  data
@@ -161,14 +181,29 @@ module Dalli
161
181
 
162
182
  # JRuby doesn't support IO#timeout=, so use custom readfull implementation
163
183
  # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
184
+ # memcached can't store an item larger than 1 GiB (its -I maximum), so a
185
+ # reply claiming more (or a negative size) is malformed or hostile.
186
+ # IO#read allocates the full count up front, so check before reading.
187
+ MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
188
+
189
+ def check_read_size!(count)
190
+ return if count.between?(0, MAX_READ_BYTES)
191
+
192
+ raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
193
+ end
194
+
164
195
  if RUBY_ENGINE == 'jruby'
165
196
  def read(count)
197
+ check_read_size!(count)
198
+ flush_write_buffer
166
199
  @sock.readfull(count)
167
200
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
168
201
  error_on_request!(e)
169
202
  end
170
203
  else
171
204
  def read(count)
205
+ check_read_size!(count)
206
+ flush_write_buffer
172
207
  read_bytes(count)
173
208
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
174
209
  error_on_request!(e)
@@ -179,22 +214,34 @@ module Dalli
179
214
  # at the call site.
180
215
  alias read_exact read
181
216
 
217
+ # Requests are buffered here rather than in the socket's own IO buffer
218
+ # (the socket is sync). Ruby flushes an IO's write buffer when any process
219
+ # holding it closes or finalizes the IO, so after a fork a child could
220
+ # send bytes the parent had buffered but not sent yet: a quiet write would
221
+ # run twice, or the parent's replies would shift onto the wrong requests.
222
+ # A forked child discards this buffer instead. Like IO's own buffer, it
223
+ # is sent once it grows past this size, or when a reply is read.
224
+ WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
225
+
182
226
  def write(bytes)
183
- @sock.write(bytes)
227
+ @write_buffer << bytes
228
+ @write_buffer_bytes += bytes.bytesize
229
+ flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
230
+ bytes.bytesize
184
231
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
185
232
  error_on_request!(e)
186
233
  end
187
234
 
188
235
  def flush
189
- @sock.flush
236
+ flush_write_buffer
190
237
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
191
238
  error_on_request!(e)
192
239
  end
193
240
 
194
241
  def flushed_write(bytes)
195
- written = @sock.write(bytes)
196
- @sock.flush
197
- written
242
+ @write_buffer << bytes
243
+ flush_write_buffer
244
+ bytes.bytesize
198
245
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
199
246
  error_on_request!(e)
200
247
  end
@@ -202,6 +249,7 @@ module Dalli
202
249
  # Non-blocking read. Here to support the operation
203
250
  # of the get_multi operation
204
251
  def read_available(...)
252
+ flush_write_buffer
205
253
  @sock.read_available(...)
206
254
  end
207
255
 
@@ -228,8 +276,9 @@ module Dalli
228
276
  raise Dalli::RetryableNetworkError, message
229
277
  end
230
278
 
279
+ # Called on connect. Deliberately leaves @fail_count alone; see
280
+ # finish_request!.
231
281
  def reset_down_info
232
- @fail_count = 0
233
282
  @down_at = nil
234
283
  @last_down_at = nil
235
284
  @msg = nil
@@ -254,7 +303,8 @@ module Dalli
254
303
  def reconnect_on_fork
255
304
  message = 'Fork detected, re-connecting child process...'
256
305
  Dalli.logger.info { message }
257
- # Close socket on a fork and reconnect immediately
306
+ # Drops anything the parent had buffered, closes the inherited socket
307
+ # without touching the parent's connection, and reconnects immediately
258
308
  close
259
309
  establish_connection
260
310
  end
@@ -284,6 +334,18 @@ module Dalli
284
334
 
285
335
  private
286
336
 
337
+ def flush_write_buffer
338
+ return if @write_buffer.empty?
339
+
340
+ @sock.write(*@write_buffer)
341
+ discard_write_buffer
342
+ end
343
+
344
+ def discard_write_buffer
345
+ @write_buffer.clear
346
+ @write_buffer_bytes = 0
347
+ end
348
+
287
349
  # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
288
350
  # until it has `count` bytes, accumulating across TCP chunks internally,
289
351
  # and only hands back a shorter (or nil) buffer when the stream hits EOF.
@@ -85,7 +85,7 @@ module Dalli
85
85
  # - :last_access - seconds since last access (only if return_last_access: true)
86
86
  def meta_get(key, options = {})
87
87
  req = RequestFormatter.meta_get(
88
- key: key, value: true, return_cas: true,
88
+ key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
89
89
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
90
90
  return_hit_status: options[:return_hit_status],
91
91
  return_last_access: options[:return_last_access], skip_lru_bump: options[:skip_lru_bump]
@@ -23,6 +23,9 @@ module Dalli
23
23
  VERSION = 'VERSION'
24
24
  SERVER_ERROR = 'SERVER_ERROR'
25
25
 
26
+ # memcached can't store an item larger than 1 GiB (its -I maximum)
27
+ MAX_VALUE_BYTES = 1024 * 1024 * 1024
28
+
26
29
  T_OK = [OK].freeze
27
30
  T_RESET = [RESET].freeze
28
31
  T_EN_HD = [EN, HD].freeze
@@ -201,14 +204,16 @@ module Dalli
201
204
  # The body len is removed from the tokens array
202
205
  body_len = body_len_from_tokens(tokens)
203
206
 
204
- # We have a complete response that has no body.
205
- # This is either the response to the terminating
206
- # noop or, if the status is not MN, an intermediate
207
- # error response that needs to be discarded. A hit
208
- # on an empty value (VA 0) still has a body -- just
209
- # its terminator -- so it's parsed below as a value.
210
- return [true, header_len] if body_len.zero? && tokens.first != VA
207
+ # A complete response with no body. Only the terminating noop's MN
208
+ # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
209
+ # SERVER_ERROR, ...) answers one key's request: report it with a
210
+ # false status so the caller skips it and keeps reading. Treating it
211
+ # as the end would leave the remaining replies on the connection for
212
+ # later commands to read as their own. A hit on an empty value (VA 0)
213
+ # still has a body -- just its terminator -- so it's parsed below.
214
+ return [tokens.first == MN, header_len] if body_len.zero? && tokens.first != VA
211
215
 
216
+ check_value_size!(body_len)
212
217
  resp_size = header_len + body_len + TERMINATOR.length
213
218
  # The header is in the buffer, but the body is not. As we don't have
214
219
  # a complete response, don't advance the buffer
@@ -277,6 +282,14 @@ module Dalli
277
282
  end
278
283
  end
279
284
 
285
+ # A pipelined reply claiming an impossible size would otherwise have the
286
+ # buffer wait for (and accumulate) that many bytes
287
+ def check_value_size!(size)
288
+ return if size.between?(0, MAX_VALUE_BYTES)
289
+
290
+ raise Dalli::DalliError, "Reply value size #{size} is out of range"
291
+ end
292
+
280
293
  def read_line
281
294
  @io_source.read_line&.chomp!(TERMINATOR)
282
295
  end
@@ -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
  def initialize(client_options)
24
30
  @compression_options =
25
31
  DEFAULTS.merge(client_options.slice(*OPTIONS))
@@ -34,8 +40,16 @@ module Dalli
34
40
  end
35
41
 
36
42
  def retrieve(value, bitflags)
37
- compressed = bitflags.anybits?(Flags::COMPRESSED)
38
- compressed ? compressor.decompress(value) : value
43
+ return value unless bitflags.anybits?(Flags::COMPRESSED)
44
+
45
+ max_bytes = @compression_options[:decompressed_max_bytes]
46
+ # Custom compressors that only define decompress(data) keep working,
47
+ # without the limit
48
+ if max_bytes && compressor_accepts_max_bytes?
49
+ compressor.decompress(value, max_bytes: max_bytes)
50
+ else
51
+ compressor.decompress(value)
52
+ end
39
53
 
40
54
  # TODO: We likely want to move this rescue into the Dalli::Compressor / Dalli::GzipCompressor
41
55
  # itself, since not all compressors necessarily use Zlib. For now keep it here, so the behavior
@@ -44,6 +58,19 @@ module Dalli
44
58
  raise UnmarshalError, "Unable to uncompress value: #{$ERROR_INFO.message}"
45
59
  end
46
60
 
61
+ def compressor_accepts_max_bytes?
62
+ return @compressor_accepts_max_bytes if defined?(@compressor_accepts_max_bytes)
63
+
64
+ @compressor_accepts_max_bytes =
65
+ begin
66
+ compressor.method(:decompress).parameters.any? do |type, name|
67
+ name == :max_bytes && KEYWORD_PARAMETER_TYPES.include?(type)
68
+ end
69
+ rescue NameError # an object without a reflectable decompress method
70
+ false
71
+ end
72
+ end
73
+
47
74
  def compress_by_default?
48
75
  @compression_options[:compress]
49
76
  end
@@ -100,7 +100,7 @@ module Dalli
100
100
  Dalli.logger.warn 'SECURITY WARNING: Dalli is using Marshal for serialization. ' \
101
101
  'Marshal can execute arbitrary code during deserialization. ' \
102
102
  'If your memcached server could be compromised, consider using ' \
103
- 'a safer serializer like JSON: Dalli::Client.new(servers, serializer: JSON)'
103
+ 'a safer serializer: Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)'
104
104
  @@marshal_warning_logged = true # rubocop:disable Style/ClassVars
105
105
  end
106
106
  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 = '5.0.8'
4
+ VERSION = '5.0.9'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
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/flags'
65
+ require_relative 'dalli/json_serializer'
65
66
  require_relative 'dalli/compressor'
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: 5.0.8
4
+ version: 5.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -42,6 +42,7 @@ files:
42
42
  - lib/dalli/compressor.rb
43
43
  - lib/dalli/flags.rb
44
44
  - lib/dalli/instrumentation.rb
45
+ - lib/dalli/json_serializer.rb
45
46
  - lib/dalli/key_manager.rb
46
47
  - lib/dalli/options.rb
47
48
  - lib/dalli/pipelined_deleter.rb