dalli 3.2.11 → 3.2.13

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: 17d814f4043cf5882166c1519dd436fb4a7d0caef32c86789dd5da3c5e51e52c
4
- data.tar.gz: 27ba5ff384558a7ab08c7dcb3e6f2cf43af4c04dddc1ce2e5ca4ab1d35b01001
3
+ metadata.gz: 7f7bcbe37e631c3c733d1487e519501497702b873e81e6588d6dfad1ab96a65b
4
+ data.tar.gz: d7a953d2c0052334d8edb7fa45306724ffa25cec64436dcceccc9847d057e8bd
5
5
  SHA512:
6
- metadata.gz: 63914cbb89b5a5d517dfd11f72b6bd56b22c9cb0700d9d53a037e12c7d9872150038f5a50b20f959bda47160644a30a433720e99da54ad048b15fee03ddb9baf
7
- data.tar.gz: 11ede1a2e918d269cd084f171c97e5bad156b96fe0384e366cd531cc68a543bd1061b34ac437a7c2794e27ad6f63a3661e035c01bb4e8172d6753cfbca839fd1
6
+ metadata.gz: 8d7b0ed94102d333f611736c48d944b0f1010760ae646003db60cd02c90b1a047a5fd10f7a5ef077527a88594f8844cbe08f9647259749e78ade74d09d2e851b
7
+ data.tar.gz: bf86e4604fc18eaf2bdf57d6abad82258745238112a9866e5da9e05c0f45b44d62f82ebe8b5519ecc83d00ec6ee94e2d11bb931aa14171081a0b731745633e6d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 3.2.13
8
+ ==========
9
+
10
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
11
+ - `cas` and `cas!` accepted `raw: true` but didn't pass it to the read, so the value was still deserialized. They now honor it, in both protocols
12
+ - Affects 3.2.12 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
13
+ - Complete the fork-safety fix (GHSA-w39f-xq2m-4g8x)
14
+ - Fork detection used a pid cache refreshed by a `Process._fork` hook. When another library's fork hook ran first in the child (connection_pool, loaded before Dalli as in Rails, closes its connections there), Dalli didn't know it was in a forked child and ended the parent's TLS session. It now checks `Process.pid`
15
+ - Fixed in 4.3.7 and 3.2.13
16
+
17
+ - Fix a regression in 3.2.12: a `get_multi` that didn't finish within `socket_timeout` counted toward `socket_max_failures`, so two slow `get_multi` calls in a row marked a healthy server down. It now just closes the connection
18
+ - Handle malformed pipelined replies from a broken or hostile server or proxy: a hit with no key, or with no `s` flag, could leave the connection out of step or raise an error
19
+ - With `decompressed_max_bytes` in effect, data after the end of a compressed value's stream was returned as part of the value. It's now ignored, as without the limit
20
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
21
+
22
+ - Run the Tests and RuboCop workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (backport of #1198)
23
+
24
+ 3.2.12
25
+ ==========
26
+
27
+ - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
28
+ - 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
29
+ - 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:
30
+ - with the meta protocol, memcached rejected it with the error reply above
31
+ - with the binary protocol, memcached dropped the connection, and `get` or `get_multi` with that key retried forever
32
+ - Such keys are now truncated like other long keys; keys that worked before are unchanged
33
+ - 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
34
+ - Stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
35
+ - 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`
36
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
37
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
38
+ - 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
39
+ - 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
40
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
41
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
42
+ - `get`, `gat` and `fetch` 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
43
+ - The `raw` part affects all versions
44
+ - `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 now recommends it; `serializer: JSON` itself is unchanged
45
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
46
+ - Keep a forked child from closing the parent's TLS session (GHSA-w39f-xq2m-4g8x)
47
+ - 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
48
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
49
+
50
+ - 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, #1183)
51
+ - 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)
52
+
7
53
  3.2.11
8
54
  ==========
9
55
 
data/README.md CHANGED
@@ -25,6 +25,18 @@ The name is a variant of Salvador Dali for his famous painting [The Persistence
25
25
  * [Forum](https://github.com/petergoldstein/dalli/discussions/categories/q-a) - If you have questions about Dalli, please post them here.
26
26
  * [Client API](https://www.rubydoc.info/gems/dalli) - Ruby documentation for the `Dalli::Client` API
27
27
 
28
+ ## Security Note
29
+
30
+ 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, or your memcached server could be written to by someone else, consider using a safer serializer:
31
+
32
+ ```ruby
33
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
34
+ ```
35
+
36
+ `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.
37
+
38
+ Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method that accepts it (`get` and `fetch`).
39
+
28
40
  ## Development
29
41
 
30
42
  After checking out the repo, run `bin/setup` to install dependencies. You can run `bin/console` for an interactive prompt that will allow you to experiment.
data/lib/dalli/client.rb CHANGED
@@ -388,7 +388,8 @@ module Dalli
388
388
  end
389
389
 
390
390
  def cas_core(key, always_set, ttl = nil, req_options = nil)
391
- (value, cas) = perform(:cas, key)
391
+ # req_options reaches the read too, so raw: true returns the stored bytes
392
+ (value, cas) = perform(:cas, key, req_options)
392
393
  return if value.nil? && !always_set
393
394
 
394
395
  newvalue = yield(value)
@@ -13,8 +13,54 @@ 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
+ # Anything after the end of the stream isn't part of the value, as
43
+ # with Zlib::Inflate.inflate; finish would return it as output
44
+ break if inflater.finished?
45
+ end
46
+ return out if inflater.finished?
47
+
48
+ append_within_limit(out, inflater.finish, max_bytes)
49
+ ensure
50
+ inflater.close
51
+ end
52
+
53
+ # Both yielded and returned output count toward the limit
54
+ def self.inflate_slice(inflater, slice, out, max_bytes)
55
+ rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
56
+ append_within_limit(out, rest, max_bytes)
57
+ end
58
+
59
+ def self.append_within_limit(out, chunk, max_bytes)
60
+ out << chunk if chunk
61
+ return out if out.bytesize <= max_bytes
62
+
63
+ raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
18
64
  end
19
65
  end
20
66
 
@@ -32,9 +78,11 @@ module Dalli
32
78
  io.string
33
79
  end
34
80
 
35
- def self.decompress(data)
36
- io = StringIO.new(data, 'rb')
37
- Zlib::GzipReader.new(io).read
81
+ def self.decompress(data, max_bytes: nil)
82
+ return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
83
+
84
+ # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
85
+ Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
38
86
  end
39
87
  end
40
88
  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
@@ -34,6 +34,9 @@ module Dalli
34
34
  validate_digest_class_option(@key_options)
35
35
 
36
36
  @namespace = namespace_from_options
37
+ # The meta protocol base64-encodes keys with whitespace or non-ASCII
38
+ # characters, so their length on the wire differs from their length here
39
+ @meta_protocol = client_options[:protocol]&.to_s == 'meta'
37
40
  end
38
41
 
39
42
  ##
@@ -51,7 +54,7 @@ module Dalli
51
54
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
52
55
 
53
56
  key = key_with_namespace(key)
54
- key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
57
+ wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
55
58
  end
56
59
 
57
60
  ##
@@ -107,7 +110,30 @@ module Dalli
107
110
  ##
108
111
  def truncated_key(key)
109
112
  digest = digest_class.hexdigest(key)
110
- "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
113
+ prefix = key[0, [prefix_length(digest), 0].max]
114
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
115
+ # A prefix with whitespace or non-ASCII characters sends the whole key
116
+ # base64-encoded, which can still be too long on the wire; shorten it.
117
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
118
+ prefix = prefix[0...-1]
119
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
120
+ end
121
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
122
+
123
+ # Only possible with a digest_class whose digests aren't short hex strings
124
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
125
+ end
126
+
127
+ # memcached's key limit applies to the key as sent: in bytes, and, with
128
+ # the meta protocol, after base64 encoding when the key needs it. A key
129
+ # within the limit in characters can still be too long. With the meta
130
+ # protocol memcached rejects it with a CLIENT_ERROR that must never reach
131
+ # a pipelined get; with the binary protocol it drops the connection.
132
+ def wire_length(key)
133
+ return key.bytesize unless @meta_protocol
134
+
135
+ encoded, = Dalli::Protocol::Meta::KeyRegularizer.encode(key)
136
+ encoded.bytesize
111
137
  end
112
138
 
113
139
  def prefix_length(digest)
@@ -27,6 +27,12 @@ module Dalli
27
27
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
28
28
  end
29
29
 
30
+ # True when this request passed raw: true. A stored value is then
31
+ # returned as stored, never deserialized or decompressed.
32
+ def raw_request?(options)
33
+ options.is_a?(Hash) && options[:raw] ? true : false
34
+ end
35
+
30
36
  # Chokepoint method for error handling and ensuring liveness
31
37
  def request(opkey, *args)
32
38
  verify_state(opkey)
@@ -118,13 +124,16 @@ module Dalli
118
124
  def pipeline_abort
119
125
  response_buffer.clear
120
126
  @connection_manager.abort_request!
121
- return true unless connected?
122
-
123
- # Closes the connection, which ensures that our connection
124
- # is in a clean state for future requests
125
- @connection_manager.error_on_request!('External timeout')
126
- rescue NetworkError
127
- true
127
+ return unless connected?
128
+
129
+ # Closes the connection, which ensures that our connection is in a
130
+ # clean state for future requests. The get_multi ran out of time
131
+ # overall rather than hitting a socket error, so it doesn't count
132
+ # toward socket_max_failures: two slow get_multis in a row would
133
+ # otherwise mark a healthy server down.
134
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
135
+ @connection_manager.close
136
+ nil
128
137
  end
129
138
 
130
139
  # Did the last call to #pipeline_response_setup complete successfully?
@@ -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
  ##
@@ -100,7 +102,8 @@ module Dalli
100
102
  true
101
103
  end
102
104
 
103
- def data_cas_response
105
+ # raw: true returns the value as stored; see #get
106
+ def data_cas_response(raw: false)
104
107
  resp_header, body = read_response
105
108
  return [nil, resp_header.cas] if resp_header.not_found?
106
109
  return [nil, false] if resp_header.not_stored?
@@ -108,7 +111,7 @@ module Dalli
108
111
  raise_on_not_ok!(resp_header)
109
112
  return [nil, resp_header.cas] unless body
110
113
 
111
- [unpack_response_body(resp_header, body, true).last, resp_header.cas]
114
+ [unpack_response_body(resp_header, body, !raw).last, resp_header.cas]
112
115
  end
113
116
 
114
117
  # Returns the new value for the key, if found and updated
@@ -209,6 +212,17 @@ module Dalli
209
212
  # The remaining three values in the array are the ResponseHeader,
210
213
  # key, and value.
211
214
  ##
215
+ # memcached can't store an item larger than 1 GiB (its -I maximum). A
216
+ # pipelined reply claiming an impossible size would otherwise have the
217
+ # buffer wait for (and accumulate) that many bytes.
218
+ MAX_BODY_BYTES = 1024 * 1024 * 1024
219
+
220
+ def check_body_size!(size)
221
+ return if size <= MAX_BODY_BYTES
222
+
223
+ raise Dalli::DalliError, "Reply body size #{size} is out of range"
224
+ end
225
+
212
226
  def getk_response_from_buffer(buf)
213
227
  # There's no header in the buffer, so don't advance
214
228
  return [0, nil, nil, nil, nil] unless contains_header?(buf)
@@ -222,6 +236,8 @@ module Dalli
222
236
  # error response that needs to be discarded.
223
237
  return [ResponseHeader::SIZE, resp_header.ok?, resp_header.cas, nil, nil] if body_len.zero?
224
238
 
239
+ check_body_size!(body_len)
240
+
225
241
  resp_size = ResponseHeader::SIZE + body_len
226
242
  # The header is in the buffer, but the body is not. As we don't have
227
243
  # a complete response, don't advance the buffer
@@ -22,7 +22,7 @@ module Dalli
22
22
  def get(key, options = nil)
23
23
  req = RequestFormatter.standard_request(opkey: :get, key: key)
24
24
  write(req)
25
- response_processor.get(cache_nils: cache_nils?(options))
25
+ response_processor.get(cache_nils: cache_nils?(options), raw: raw_request?(options))
26
26
  end
27
27
 
28
28
  def quiet_get_request(key)
@@ -33,7 +33,7 @@ module Dalli
33
33
  ttl = TtlSanitizer.sanitize(ttl)
34
34
  req = RequestFormatter.standard_request(opkey: :gat, key: key, ttl: ttl)
35
35
  write(req)
36
- response_processor.get(cache_nils: cache_nils?(options))
36
+ response_processor.get(cache_nils: cache_nils?(options), raw: raw_request?(options))
37
37
  end
38
38
 
39
39
  def touch(key, ttl)
@@ -44,10 +44,10 @@ module Dalli
44
44
 
45
45
  # TODO: This is confusing, as there's a cas command in memcached
46
46
  # and this isn't it. Maybe rename? Maybe eliminate?
47
- def cas(key)
47
+ def cas(key, options = nil)
48
48
  req = RequestFormatter.standard_request(opkey: :get, key: key)
49
49
  write(req)
50
- response_processor.data_cas_response
50
+ response_processor.data_cas_response(raw: raw_request?(options))
51
51
  end
52
52
 
53
53
  # Storage Commands
@@ -38,6 +38,7 @@ module Dalli
38
38
  @sock = nil
39
39
  @pid = nil
40
40
 
41
+ @fail_count = 0
41
42
  reset_down_info
42
43
  end
43
44
 
@@ -53,7 +54,7 @@ module Dalli
53
54
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
54
55
 
55
56
  @sock = memcached_socket
56
- @pid = PIDCache.pid
57
+ @pid = Process.pid
57
58
  @request_in_progress = false
58
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
59
60
  # SocketError = DNS resolution failure
@@ -84,6 +85,8 @@ module Dalli
84
85
  def down!
85
86
  close
86
87
  log_down_detected
88
+ # Once down_retry_delay passes, the server gets a full set of attempts
89
+ @fail_count = 0
87
90
 
88
91
  @error = $ERROR_INFO&.class&.name
89
92
  @msg ||= $ERROR_INFO&.message
@@ -113,7 +116,7 @@ module Dalli
113
116
  return unless @sock
114
117
 
115
118
  begin
116
- @sock.close
119
+ close_socket
117
120
  rescue StandardError
118
121
  nil
119
122
  end
@@ -122,6 +125,13 @@ module Dalli
122
125
  abort_request!
123
126
  end
124
127
 
128
+ # A forked child shares the parent's connection, so it closes only its
129
+ # own file descriptor. Closing the TLS socket itself would send
130
+ # close_notify and end the parent's TLS session.
131
+ def close_socket
132
+ fork_detected? ? @sock.to_io.close : @sock.close
133
+ end
134
+
125
135
  def connected?
126
136
  !@sock.nil?
127
137
  end
@@ -140,6 +150,11 @@ module Dalli
140
150
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
141
151
 
142
152
  @request_in_progress = false
153
+ # A completed request is what proves the server healthy again, so the
154
+ # failure count resets here rather than on reconnect: a server that
155
+ # accepts connections but never answers would otherwise reset it on
156
+ # every retry and never reach socket_max_failures.
157
+ @fail_count = 0
143
158
  end
144
159
 
145
160
  def abort_request!
@@ -154,7 +169,19 @@ module Dalli
154
169
  error_on_request!(e)
155
170
  end
156
171
 
172
+ # memcached can't store an item larger than 1 GiB (its -I maximum), so a
173
+ # reply claiming more (or a negative size) is malformed or hostile.
174
+ # Reading allocates the full count up front, so check before reading.
175
+ MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
176
+
177
+ def check_read_size!(count)
178
+ return if count.between?(0, MAX_READ_BYTES)
179
+
180
+ raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
181
+ end
182
+
157
183
  def read(count)
184
+ check_read_size!(count)
158
185
  @sock.readfull(count)
159
186
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError => e
160
187
  error_on_request!(e)
@@ -195,8 +222,9 @@ module Dalli
195
222
  raise Dalli::NetworkError, message
196
223
  end
197
224
 
225
+ # Called on connect. Deliberately leaves @fail_count alone; see
226
+ # finish_request!.
198
227
  def reset_down_info
199
- @fail_count = 0
200
228
  @down_at = nil
201
229
  @last_down_at = nil
202
230
  @msg = nil
@@ -222,14 +250,18 @@ module Dalli
222
250
  def close_on_fork
223
251
  message = 'Fork detected, re-connecting child process...'
224
252
  Dalli.logger.info { message }
225
- # Close socket on a fork, setting us up for reconnect
226
- # on next request.
253
+ # Closes the inherited socket without touching the parent's
254
+ # connection, setting us up for reconnect on next request.
227
255
  close
228
256
  raise Dalli::NetworkError, message
229
257
  end
230
258
 
231
259
  def fork_detected?
232
- @pid && @pid != PIDCache.pid
260
+ # Process.pid rather than PIDCache: PIDCache is refreshed by a
261
+ # Process._fork hook, and another library's fork hook (such as
262
+ # connection_pool closing its connections) can run in the child before
263
+ # it, which would close the parent's TLS session as if it were ours.
264
+ @pid && @pid != Process.pid
233
265
  end
234
266
 
235
267
  def log_down_detected
@@ -27,22 +27,27 @@ module Dalli
27
27
  @value_marshaller = value_marshaller
28
28
  end
29
29
 
30
- def meta_get_with_value(cache_nils: false)
30
+ # raw: true returns the value as stored, without deserializing or
31
+ # decompressing it, whatever flags it was stored with
32
+ def meta_get_with_value(cache_nils: false, raw: false)
31
33
  tokens = error_on_unexpected!([VA, EN, HD])
32
34
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
33
35
  return true unless tokens.first == VA
34
36
 
35
- @value_marshaller.retrieve(read_line, bitflags_from_tokens(tokens))
37
+ value = read_line
38
+ raw ? value : @value_marshaller.retrieve(value, bitflags_from_tokens(tokens))
36
39
  end
37
40
 
38
- def meta_get_with_value_and_cas
41
+ # raw: true returns the value as stored; see #meta_get_with_value
42
+ def meta_get_with_value_and_cas(raw: false)
39
43
  tokens = error_on_unexpected!([VA, EN, HD])
40
44
  return [nil, 0] if tokens.first == EN
41
45
 
42
46
  cas = cas_from_tokens(tokens)
43
47
  return [nil, cas] unless tokens.first == VA
44
48
 
45
- [@value_marshaller.retrieve(read_line, bitflags_from_tokens(tokens)), cas]
49
+ value = read_line
50
+ [raw ? value : @value_marshaller.retrieve(value, bitflags_from_tokens(tokens)), cas]
46
51
  end
47
52
 
48
53
  def meta_get_without_value
@@ -120,6 +125,10 @@ module Dalli
120
125
  end
121
126
 
122
127
  def full_response_from_buffer(tokens, body, resp_size)
128
+ # A hit with no key can't be matched to a request: skip it (false
129
+ # status) rather than yield it, or take it for the end of the pipeline
130
+ return [resp_size, false, nil, nil, nil] unless tokens.any? { |t| t.start_with?('k') }
131
+
123
132
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
124
133
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
125
134
  end
@@ -140,13 +149,14 @@ module Dalli
140
149
 
141
150
  tokens, header_len, body_len = tokens_from_header_buffer(buf)
142
151
 
143
- # We have a complete response that has no body.
144
- # This is either the response to the terminating
145
- # noop or, if the status is not MN, an intermediate
146
- # error response that needs to be discarded. A hit
147
- # on an empty value (VA 0) still has a body -- just
148
- # its terminator -- so it's parsed below as a value.
149
- return [header_len, true, nil, nil, nil] if no_body?(tokens, body_len)
152
+ # A complete response with no body. Only the terminating noop's MN
153
+ # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
154
+ # SERVER_ERROR, ...) answers one key's request: report it with a
155
+ # false status so the caller skips it and keeps reading. Treating it
156
+ # as the end would leave the remaining replies on the connection for
157
+ # later commands to read as their own. A hit on an empty value (VA 0)
158
+ # still has a body -- just its terminator -- so it's parsed below.
159
+ return [header_len, tokens.first == MN, nil, nil, nil] if no_body?(tokens, body_len)
150
160
 
151
161
  resp_size = header_len + body_len + TERMINATOR.length
152
162
  # The header is in the buffer, but the body is not. As we don't have
@@ -159,6 +169,22 @@ module Dalli
159
169
  full_response_from_buffer(tokens, body, resp_size)
160
170
  end
161
171
 
172
+ # memcached can't store an item larger than 1 GiB (its -I maximum). A
173
+ # pipelined reply claiming an impossible size would otherwise have the
174
+ # buffer wait for (and accumulate) that many bytes.
175
+ MAX_VALUE_BYTES = 1024 * 1024 * 1024
176
+
177
+ # The size given right after "VA", or 0 when it isn't a number
178
+ def positional_size(tokens)
179
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
180
+ end
181
+
182
+ def check_value_size!(size)
183
+ return if size.between?(0, MAX_VALUE_BYTES)
184
+
185
+ raise Dalli::DalliError, "Reply value size #{size} is out of range"
186
+ end
187
+
162
188
  # A zero-size reply has no body unless it's a VA (a hit on an empty value)
163
189
  def no_body?(tokens, body_len)
164
190
  body_len.zero? && tokens.first != VA
@@ -194,7 +220,11 @@ module Dalli
194
220
  end
195
221
 
196
222
  def body_len_from_tokens(tokens)
197
- value_from_tokens(tokens, 's')&.to_i
223
+ # memcached also gives the size positionally ("VA <size> ..."); use it
224
+ # when there's no s flag rather than reading the value as replies
225
+ size = tokens.any? { |t| t.start_with?('s') } ? value_from_tokens(tokens, 's')&.to_i : positional_size(tokens)
226
+ check_value_size!(size) if size
227
+ size
198
228
  end
199
229
 
200
230
  def value_from_tokens(tokens, flag)
@@ -27,7 +27,7 @@ module Dalli
27
27
  encoded_key, base64 = KeyRegularizer.encode(key)
28
28
  req = RequestFormatter.meta_get(key: encoded_key, base64: base64)
29
29
  write(req)
30
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
30
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
31
31
  end
32
32
 
33
33
  def quiet_get_request(key)
@@ -40,7 +40,7 @@ module Dalli
40
40
  encoded_key, base64 = KeyRegularizer.encode(key)
41
41
  req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64)
42
42
  write(req)
43
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
43
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
44
44
  end
45
45
 
46
46
  def touch(key, ttl)
@@ -53,11 +53,11 @@ module Dalli
53
53
 
54
54
  # TODO: This is confusing, as there's a cas command in memcached
55
55
  # and this isn't it. Maybe rename? Maybe eliminate?
56
- def cas(key)
56
+ def cas(key, options = nil)
57
57
  encoded_key, base64 = KeyRegularizer.encode(key)
58
58
  req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
59
59
  write(req)
60
- response_processor.meta_get_with_value_and_cas
60
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
61
61
  end
62
62
 
63
63
  # Storage Commands
@@ -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
@@ -47,8 +53,16 @@ module Dalli
47
53
  end
48
54
 
49
55
  def retrieve(value, bitflags)
50
- compressed = (bitflags & FLAG_COMPRESSED) != 0
51
- compressed ? compressor.decompress(value) : value
56
+ return value unless bitflags.anybits?(FLAG_COMPRESSED)
57
+
58
+ max_bytes = @compression_options[:decompressed_max_bytes]
59
+ # Custom compressors that only define decompress(data) keep working,
60
+ # without the limit
61
+ if max_bytes && compressor_accepts_max_bytes?
62
+ compressor.decompress(value, max_bytes: max_bytes)
63
+ else
64
+ compressor.decompress(value)
65
+ end
52
66
 
53
67
  # TODO: We likely want to move this rescue into the Dalli::Compressor / Dalli::GzipCompressor
54
68
  # itself, since not all compressors necessarily use Zlib. For now keep it here, so the behavior
@@ -57,6 +71,19 @@ module Dalli
57
71
  raise UnmarshalError, "Unable to uncompress value: #{$ERROR_INFO.message}"
58
72
  end
59
73
 
74
+ def compressor_accepts_max_bytes?
75
+ return @compressor_accepts_max_bytes if defined?(@compressor_accepts_max_bytes)
76
+
77
+ @compressor_accepts_max_bytes =
78
+ begin
79
+ compressor.method(:decompress).parameters.any? do |type, name|
80
+ name == :max_bytes && KEYWORD_PARAMETER_TYPES.include?(type)
81
+ end
82
+ rescue NameError # an object without a reflectable decompress method
83
+ false
84
+ end
85
+ end
86
+
60
87
  def compress_by_default?
61
88
  @compression_options[:compress]
62
89
  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 = '3.2.11'
4
+ VERSION = '3.2.13'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -57,6 +57,7 @@ end
57
57
  require_relative 'dalli/version'
58
58
 
59
59
  require_relative 'dalli/compressor'
60
+ require_relative 'dalli/json_serializer'
60
61
  require_relative 'dalli/client'
61
62
  require_relative 'dalli/key_manager'
62
63
  require_relative 'dalli/pipelined_getter'
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: 3.2.11
4
+ version: 3.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -40,6 +40,7 @@ files:
40
40
  - lib/dalli/cas/client.rb
41
41
  - lib/dalli/client.rb
42
42
  - lib/dalli/compressor.rb
43
+ - lib/dalli/json_serializer.rb
43
44
  - lib/dalli/key_manager.rb
44
45
  - lib/dalli/options.rb
45
46
  - lib/dalli/pid_cache.rb