dalli 5.1.2 → 5.1.4

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: 53113b610a737a8a40a4f830c24251218161e36a83c01e3cf92b85e74ce2bbf1
4
- data.tar.gz: 510c940cb0ee92a95e77939ffe277fc1a299539dff69d7e70cb2a8ca621ae94f
3
+ metadata.gz: 70f17667b9c623f9233be342d920de385bab797b44a4bc9f1e85c233f766404f
4
+ data.tar.gz: 527b9ff3dd4699ad5d307107668b31d3a739675825ad4d0b2cfb35ac09581a81
5
5
  SHA512:
6
- metadata.gz: eacfdd099e2236d46abc8c445467f34088a23455cf42fa9cef9a328dfc64b474c0456063c2b40fb7e958ae96a455c63bf29ea8ad118501f64a46090364999d68
7
- data.tar.gz: c0327598c3d0384e50bc132ef44cd5ccb94b346969eeee4b2235d43c7e31f2cde4fc9867b81c8f5ceffb6c624a01a1b3ea8de44a6ac856521fbcaa714d2a516d
6
+ metadata.gz: 9e54f454a1bca3b79f229a219b458533d2778c1521043c1ee7c2d203ae0d25e1449fc375b59754aaadac79de6ea484170ba52c10008da6abd40451821300a60c
7
+ data.tar.gz: 1dea61992e48d8786dbb3ef929e30c53d3544bafed895334cf4316ad0710791c4ebb0887fb4fa63b79358c0029be11b3495a2875a24956a296246fe9d5e4eaa0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.1.4
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
13
+ - With a `namespace`, a request retried after a transient network error (a timeout, or a connection closed by memcached or a proxy) applied the namespace a second time, so a retried read could return a different key's value and a retried write could overwrite a different key
14
+ - Affects single-key operations since 5.0.3, single-server `get_multi` since 5.1.0, and `get_with_metadata` and `fetch_with_lock` since 4.1.0; fixed in 5.2.2, 5.1.4, 5.0.10 and 4.3.7. Clients without a namespace aren't affected
15
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
16
+ - A read made with `raw: true` doesn't ask for flags, but a reply carrying them anyway (from a proxy or a hostile server) still had its value deserialized. Raw reads now ignore flags in the reply
17
+ - Affects 5.1.3 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
18
+
19
+ Bug fixes:
20
+
21
+ - `get_with_metadata` and `fetch_with_lock` retried the final error raised when a server is marked down, so with `down_retry_delay: 0` they retried an unresponsive server forever. They now retry only retryable errors, like other operations
22
+ - Fix a regression in 5.1.3: 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
23
+ - Fix a regression in 5.1.3: over TLS, every request buffered in a `quiet` block was sent as its own TLS record and system call (2.5 times slower for a block of 2000 deletes). Each flush is one write again
24
+ - Handle malformed replies from a broken or hostile server or proxy: a negative value size, a hit with no key, or a hit with no `s` flag could leave the connection out of step or raise a non-Dalli error
25
+ - 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
26
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
27
+
28
+ Development:
29
+
30
+ - Run the Tests, RuboCop and Profiles workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (backport of #1198)
31
+ - 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)
32
+
33
+ 5.1.3
34
+ ==========
35
+
36
+ Security:
37
+
38
+ - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
39
+ - 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
40
+ - 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
41
+ - 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
42
+ - Reject routing tokens that add meta flags, and stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
43
+ - `p_token` and `l_token` now reject whitespace and control characters, not just CR, LF and NUL. A space let a token add meta flags to the request it was sent with: changing an item's TTL on a read, creating stub items on a miss, turning a delete into a stale tombstone, or reading a different key
44
+ - Affects 5.1.0 and later; fixed in 5.2.1 and 5.1.3
45
+ - 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`
46
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
47
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
48
+ - 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
49
+ - 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
50
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
51
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
52
+ - `get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `get_cas` and `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
53
+ - The `raw` part affects `get_with_metadata` since 4.2.0, and the other methods since they gained per-request options in 5.1.0
54
+ - `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
55
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
56
+ - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
57
+ - 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
58
+ - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
59
+ - 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
60
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
61
+
62
+ Bug fixes:
63
+
64
+ - 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
65
+
7
66
  5.1.2
8
67
  ==========
9
68
 
@@ -16,6 +75,10 @@ Bug fixes:
16
75
  - Affects the meta protocol since 3.2.0 (the default since 5.0.0)
17
76
  - Thanks to Julian Richard Contreras for this contribution
18
77
 
78
+ Development:
79
+
80
+ - Add property tests for request building and fuzz tests for the reply parsers
81
+
19
82
  5.1.1
20
83
  ==========
21
84
 
data/README.md CHANGED
@@ -54,9 +54,13 @@ The separator must be a single non-alphanumeric character. Valid examples: `:`,
54
54
  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:
55
55
 
56
56
  ```ruby
57
- Dalli::Client.new('localhost:11211', serializer: JSON)
57
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
58
58
  ```
59
59
 
60
+ `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.
61
+
62
+ Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method (`get`, `get_multi`, `get_cas`, `get_with_metadata` and `fetch`).
63
+
60
64
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
61
65
 
62
66
  ## OpenTelemetry Tracing
data/lib/dalli/client.rb CHANGED
@@ -136,14 +136,18 @@ module Dalli
136
136
  #
137
137
  def get_with_metadata(key, options = {})
138
138
  validate_routing_tokens!(options)
139
- key = key.to_s
140
- key = @key_manager.validate_key(key)
139
+ # A retry re-runs this method with its original arguments, so `key` must
140
+ # stay as the caller gave it; reassigning it would add the namespace again.
141
+ validated_key = @key_manager.validate_key(key.to_s)
141
142
 
142
- server = ring.server_for_key(key)
143
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
144
- server.request(:meta_get, key, options)
143
+ server = ring.server_for_key(validated_key)
144
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
145
+ server.request(:meta_get, validated_key, options)
145
146
  end
146
- rescue NetworkError => e
147
+ # Only a retryable error is retried. The NetworkError raised when a server
148
+ # is marked down is final, as in #perform; retrying it looped forever once
149
+ # down_retry_delay let the same server be tried again.
150
+ rescue RetryableNetworkError => e
147
151
  Dalli.logger.debug { e.inspect }
148
152
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
149
153
  retry
@@ -302,14 +306,17 @@ module Dalli
302
306
  validate_integer!(:lock_ttl, lock_ttl)
303
307
  validate_integer!(:recache_threshold, recache_threshold)
304
308
  validate_routing_tokens!(req_options)
305
- key = key.to_s
306
- key = @key_manager.validate_key(key)
309
+ # Left unchanged for the retry below; see get_with_metadata
310
+ validated_key = @key_manager.validate_key(key.to_s)
307
311
 
308
- server = ring.server_for_key(key)
309
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
310
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
312
+ server = ring.server_for_key(validated_key)
313
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
314
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
311
315
  end
312
- rescue NetworkError => e
316
+ # Only a retryable error is retried. The NetworkError raised when a server
317
+ # is marked down is final, as in #perform; retrying it looped forever once
318
+ # down_retry_delay let the same server be tried again.
319
+ rescue RetryableNetworkError => e
313
320
  Dalli.logger.debug { e.inspect }
314
321
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
315
322
  retry
@@ -727,10 +734,11 @@ module Dalli
727
734
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
728
735
  # dropping a key it can't route on both the single- and multi-server paths.
729
736
  def single_server_get_multi(keys, req_options = nil)
730
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
737
+ # A new array, so the retry below starts again from the caller's keys
738
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
731
739
  return {} unless (server = single_server)
732
740
 
733
- result = server.request(:read_multi_req, keys, req_options)
741
+ result = server.request(:read_multi_req, validated_keys, req_options)
734
742
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
735
743
  result
736
744
  rescue Dalli::RetryableNetworkError => e
@@ -821,7 +829,7 @@ module Dalli
821
829
  # check means unwinding through Protocol::Base#request, which logs the
822
830
  # failure as unexpected and closes the connection -- a caller passing a
823
831
  # bad token should get a clean ArgumentError and keep its connection.
824
- ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
832
+ ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
825
833
  private_constant :ROUTING_TOKEN_FORBIDDEN
826
834
 
827
835
  def validate_routing_tokens!(req_options)
@@ -837,7 +845,9 @@ module Dalli
837
845
  # here (it would also excuse [] / {} from the type check below).
838
846
  return if value.nil? || (value.is_a?(String) && value.empty?)
839
847
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
840
- raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
848
+ return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
849
+
850
+ raise ArgumentError, "#{name} must not contain whitespace or control characters"
841
851
  end
842
852
 
843
853
  def cas_core(key, always_set, ttl = nil, req_options = nil)
@@ -895,18 +905,20 @@ module Dalli
895
905
  # rubocop:enable Naming/MethodParameterName
896
906
  return yield if block_given?
897
907
 
898
- key = key.to_s
899
- key = @key_manager.validate_key(key)
908
+ # `retry` below re-runs this method with its original arguments, so `key`
909
+ # must stay as the caller gave it: reassigning it would apply the
910
+ # namespace a second time and send the retry to a different key.
911
+ validated_key = @key_manager.validate_key(key.to_s)
900
912
 
901
- server = ring.server_for_key(key)
913
+ server = ring.server_for_key(validated_key)
902
914
 
903
915
  if Instrumentation.enabled?
904
916
  op_name = op.name
905
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
906
- server.request(op, key, ...)
917
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
918
+ server.request(op, validated_key, ...)
907
919
  end
908
920
  else
909
- server.request(op, key, ...)
921
+ server.request(op, validated_key, ...)
910
922
  end
911
923
  rescue RetryableNetworkError => e
912
924
  Dalli.logger.debug { e.inspect }
@@ -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
@@ -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,28 @@ 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), 0].max]
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 && !prefix.empty?
130
+ prefix = prefix[0...-1]
131
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
+ end
133
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
134
+
135
+ # Only possible with a digest_class whose digests aren't short hex strings
136
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
137
+ end
138
+
139
+ # memcached's key limit applies to the key as sent: in bytes, and after
140
+ # base64 encoding when the key needs it. A key can be within the limit in
141
+ # characters and still be rejected, and memcached's CLIENT_ERROR for it
142
+ # must never reach a pipelined get.
143
+ def wire_length(key)
144
+ return key.bytesize unless Dalli::Protocol::Meta::KeyRegularizer.required?(key)
145
+
146
+ ((key.bytesize + 2) / 3) * 4
126
147
  end
127
148
 
128
149
  def prefix_length(digest)
@@ -51,6 +51,14 @@ module Dalli
51
51
  @raw_mode
52
52
  end
53
53
 
54
+ # True when values should come back as stored: the client is in raw mode,
55
+ # or this request passed raw: true. Retrieval paths use it to skip
56
+ # requesting flags, so a stored value is never deserialized or
57
+ # decompressed for a caller who asked for raw bytes.
58
+ def raw_request?(options)
59
+ raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
60
+ end
61
+
54
62
  # Chokepoint method for error handling and ensuring liveness
55
63
  def request(opkey, *args)
56
64
  verify_state(opkey)
@@ -142,7 +150,15 @@ module Dalli
142
150
  reconnect_on_pipeline_complete!
143
151
  values = nil
144
152
 
145
- response_buffer.read
153
+ # Only the read is socket I/O. The caller's block runs below, outside
154
+ # this rescue, so an Errno or Timeout::Error it raises reaches the
155
+ # caller instead of being mistaken for a network failure (which closed
156
+ # the connection and retried the whole get_multi, yielding keys twice).
157
+ begin
158
+ response_buffer.read
159
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
160
+ @connection_manager.error_on_request!(e)
161
+ end
146
162
 
147
163
  status, cas, key, value = response_buffer.process_single_getk_response
148
164
  # status is not nil only if we have a full response to parse
@@ -168,8 +184,6 @@ module Dalli
168
184
  end
169
185
 
170
186
  values || {}
171
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
172
- @connection_manager.error_on_request!(e)
173
187
  end
174
188
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
175
189
 
@@ -183,10 +197,13 @@ module Dalli
183
197
  @connection_manager.abort_request!
184
198
  return true unless connected?
185
199
 
186
- # Closes the connection, which ensures that our connection
187
- # is in a clean state for future requests
188
- @connection_manager.error_on_request!('External timeout')
189
- rescue NetworkError
200
+ # Closes the connection, which ensures that our connection is in a
201
+ # clean state for future requests. The get_multi ran out of time
202
+ # overall rather than hitting a socket error, so it doesn't count
203
+ # toward socket_max_failures: two slow get_multis in a row would
204
+ # otherwise mark a healthy server down.
205
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
206
+ @connection_manager.close
190
207
  true
191
208
  end
192
209
 
@@ -314,6 +331,7 @@ module Dalli
314
331
  # Use clear (not reset) to keep pipeline_complete? = true, which is
315
332
  # the expected state before pipeline_response_setup is called.
316
333
  response_buffer.clear
334
+ response_buffer.raw = raw_request?(options)
317
335
 
318
336
  # The terminating noop is sent by pipeline_response_setup
319
337
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -325,6 +343,7 @@ module Dalli
325
343
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
326
344
  # Initialize the response buffer for draining during send phase
327
345
  response_buffer.ensure_ready
346
+ response_buffer.raw = raw_request?(options)
328
347
 
329
348
  keys.each_slice(chunk_size) do |chunk|
330
349
  # Build and write this chunk of requests
@@ -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,37 @@ module Dalli
284
334
 
285
335
  private
286
336
 
337
+ # One write per flush. A TLS socket sends each argument of
338
+ # write(*parts) as its own record and system call, so the parts are
339
+ # joined first.
340
+ def flush_write_buffer
341
+ return if @write_buffer.empty?
342
+
343
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
344
+ discard_write_buffer
345
+ end
346
+
347
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
348
+ # String#<< refuses to combine when both hold non-ASCII bytes
349
+ if String.method_defined?(:append_as_bytes)
350
+ def joined_write_buffer
351
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
352
+ @write_buffer.each { |part| out.append_as_bytes(part) }
353
+ out
354
+ end
355
+ else
356
+ def joined_write_buffer
357
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
358
+ @write_buffer.each { |part| out << part.b }
359
+ out
360
+ end
361
+ end
362
+
363
+ def discard_write_buffer
364
+ @write_buffer.clear
365
+ @write_buffer_bytes = 0
366
+ end
367
+
287
368
  # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
288
369
  # until it has `count` bytes, accumulating across TCP chunks internally,
289
370
  # and only hands back a shorter (or nil) buffer when the stream hits EOF.
@@ -38,20 +38,20 @@ module Dalli
38
38
  RequestFormatter.plain_meta_get(key, skip_flags)
39
39
  end
40
40
  flushed_write(req)
41
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
41
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
42
42
  end
43
43
 
44
44
  def quiet_get_request(key, options = nil)
45
45
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
46
- RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
46
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_request?(options),
47
47
  **routing_token_kwargs(options))
48
48
  end
49
49
 
50
50
  # Same requests as quiet_get_request for each key, built in one pass
51
51
  # and without the trailing noop.
52
52
  def quiet_get_requests(keys, options = nil, return_cas: true)
53
- RequestFormatter.multi_meta_get(keys, skip_flags: raw_mode?, return_cas: return_cas, terminate: false,
54
- **routing_token_kwargs(options))
53
+ RequestFormatter.multi_meta_get(keys, skip_flags: raw_request?(options), return_cas: return_cas,
54
+ terminate: false, **routing_token_kwargs(options))
55
55
  end
56
56
 
57
57
  def gat(key, ttl, options = nil)
@@ -59,7 +59,7 @@ module Dalli
59
59
  skip_flags = raw_mode? || (options && options[:raw])
60
60
  req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
61
61
  flushed_write(req)
62
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
62
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
63
63
  end
64
64
 
65
65
  def touch(key, ttl)
@@ -72,9 +72,10 @@ module Dalli
72
72
  # TODO: This is confusing, as there's a cas command in memcached
73
73
  # and this isn't it. Maybe rename? Maybe eliminate?
74
74
  def cas(key, options = nil)
75
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
75
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
76
+ **routing_token_kwargs(options))
76
77
  flushed_write(req)
77
- response_processor.meta_get_with_value_and_cas
78
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
78
79
  end
79
80
 
80
81
  # Comprehensive meta get with support for all metadata flags.
@@ -103,7 +104,7 @@ module Dalli
103
104
  # - :last_access - seconds since last access (only if return_last_access: true)
104
105
  def meta_get(key, options = {})
105
106
  req = RequestFormatter.meta_get(
106
- key: key, value: true, return_cas: true,
107
+ key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
107
108
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
108
109
  return_hit_status: options[:return_hit_status],
109
110
  return_last_access: options[:return_last_access],
@@ -115,7 +116,7 @@ module Dalli
115
116
  response_processor.meta_get_with_metadata(
116
117
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
117
118
  return_last_access: options[:return_last_access],
118
- return_ttl_remaining: options[:return_ttl_remaining]
119
+ return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
119
120
  )
120
121
  end
121
122
 
@@ -266,7 +267,7 @@ module Dalli
266
267
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
267
268
  # machinery (IO.select, response buffering, server grouping).
268
269
  def read_multi_req(keys, options = nil)
269
- is_raw = raw_mode?
270
+ is_raw = raw_request?(options)
270
271
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
271
272
  flushed_write(buffer)
272
273
  buffer.clear
@@ -282,7 +283,7 @@ module Dalli
282
283
  # Shared by both the single-server fast path and PipelinedGetter's
283
284
  # per-server-group request, so this one change covers both routes.
284
285
  def read_multi_with_metadata_req(keys, options = nil)
285
- is_raw = raw_mode?
286
+ is_raw = raw_request?(options)
286
287
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
287
288
  **routing_token_kwargs(options))
288
289
  flushed_write(buffer)
@@ -299,7 +300,9 @@ module Dalli
299
300
  next unless line.start_with?('VA ')
300
301
 
301
302
  tokens = line.chomp!(TERMINATOR).split
302
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
303
+ size = tokens[1].to_i
304
+ response_processor.check_value_size!(size)
305
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
303
306
  stale = response_processor.stale_from_tokens(tokens)
304
307
  cas = response_processor.cas_from_tokens(tokens)
305
308
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
@@ -326,7 +329,9 @@ module Dalli
326
329
 
327
330
  def parse_multi_get_value(line, key_index, is_raw)
328
331
  tokens = line.chomp!(TERMINATOR).split
329
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
332
+ size = tokens[1].to_i
333
+ response_processor.check_value_size!(size)
334
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
330
335
  raw_key = tokens[key_index]
331
336
  return unless raw_key
332
337
 
@@ -200,11 +200,11 @@ module Dalli
200
200
  # specifications to a proxy or router inbetween a client and a
201
201
  # memcached daemon."
202
202
  #
203
- # Empty / nil tokens are treated as no-ops. CRLF and null bytes are
204
- # rejected with ArgumentError to prevent the token from being used as a
205
- # wire-protocol injection vector (e.g. "foo\r\nflush_all\r\n" would
206
- # otherwise be parsed as a second command by memcached or any
207
- # intermediate proxy/LB).
203
+ # Empty / nil tokens are treated as no-ops. Whitespace and control
204
+ # characters are rejected with ArgumentError so a token can't be used
205
+ # for wire-protocol injection: CRLF would add a second command (e.g.
206
+ # "foo\r\nflush_all\r\n"), and a space would add meta flags to this one
207
+ # (e.g. "foo T1" changing an item's TTL on a read).
208
208
  def routing_tokens(p_token: nil, l_token: nil)
209
209
  # Only an empty *String* is a no-op. Checking respond_to?(:empty?)
210
210
  # instead would also swallow p_token: [] / {} before the type check
@@ -261,15 +261,18 @@ module Dalli
261
261
 
262
262
  private
263
263
 
264
- # Disallowed bytes: CR, LF, NUL. Any of these embedded in a routing
265
- # token would let the caller inject a second wire-protocol command.
266
- ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
264
+ # Disallowed bytes: whitespace and control characters. CR, LF or NUL
265
+ # would let a token inject a second command; a space would let it add
266
+ # meta flags (T, N, I, q, ...) to the request it rides on.
267
+ ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
267
268
  private_constant :ROUTING_TOKEN_FORBIDDEN
268
269
 
269
270
  def validate_routing_token!(name, value)
270
271
  return if value.nil?
271
272
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
272
- raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
273
+ return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
274
+
275
+ raise ArgumentError, "#{name} must not contain whitespace or control characters"
273
276
  end
274
277
 
275
278
  # Numeric flag values are written straight into the command line, so a
@@ -15,6 +15,9 @@ module Dalli
15
15
  # when advancing through parsed responses.
16
16
  ##
17
17
  class ResponseBuffer
18
+ # Whether the current pipeline was requested with raw: true
19
+ attr_writer :raw
20
+
18
21
  def initialize(io_source, response_processor)
19
22
  @io_source = io_source
20
23
  @response_processor = response_processor
@@ -39,7 +42,7 @@ module Dalli
39
42
  # Attempts to process a single response from the buffer,
40
43
  # advancing the offset past the consumed bytes.
41
44
  def process_single_getk_response
42
- response = @response_processor.getk_response_from_buffer(@buffer, @offset)
45
+ response = @response_processor.getk_response_from_buffer(@buffer, @offset, raw: @raw || false)
43
46
  @offset += response.pop
44
47
  response
45
48
  end
@@ -24,6 +24,8 @@ module Dalli
24
24
  SERVER_ERROR = 'SERVER_ERROR'
25
25
 
26
26
  VA_PREFIX = 'VA '
27
+ # memcached can't store an item larger than 1 GiB (its -I maximum)
28
+ MAX_VALUE_BYTES = 1024 * 1024 * 1024
27
29
  FLAGS_TOKEN_PREFIX = ' f'
28
30
  BYTE_B = 'b'.ord
29
31
  BYTE_C = 'c'.ord
@@ -46,12 +48,15 @@ module Dalli
46
48
  @value_marshaller = value_marshaller
47
49
  end
48
50
 
49
- def meta_get_with_value(cache_nils: false)
51
+ # raw: true (a request made with raw: true) returns the value as stored,
52
+ # ignoring any flags in the reply. The request didn't ask for flags, so
53
+ # a reply carrying them anyway must not get the value deserialized.
54
+ def meta_get_with_value(cache_nils: false, raw: false)
50
55
  line = read_line
51
56
  # A hit ("VA <size> f<flags>") is parsed in place rather than split
52
57
  # into tokens, which saves several allocations on the hottest path.
53
58
  if line&.start_with?(VA_PREFIX)
54
- return @value_marshaller.retrieve(read_data(size_from_va_line(line)), bitflags_from_va_line(line))
59
+ return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
55
60
  end
56
61
 
57
62
  tokens = line&.split || []
@@ -59,7 +64,7 @@ module Dalli
59
64
  when EN
60
65
  cache_nils ? ::Dalli::NOT_FOUND : nil
61
66
  when VA # only reached for an unusually formatted hit line
62
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
67
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
63
68
  when HD
64
69
  true
65
70
  else
@@ -67,14 +72,14 @@ module Dalli
67
72
  end
68
73
  end
69
74
 
70
- def meta_get_with_value_and_cas
75
+ def meta_get_with_value_and_cas(raw: false)
71
76
  tokens = error_on_unexpected!(T_VA_EN_HD)
72
77
  return [nil, 0] if tokens.first == EN
73
78
 
74
79
  cas = cas_from_tokens(tokens)
75
80
  return [nil, cas] unless tokens.first == VA
76
81
 
77
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
82
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
78
83
  end
79
84
 
80
85
  def meta_get_without_value
@@ -94,13 +99,13 @@ module Dalli
94
99
  # Used by meta_get for comprehensive metadata retrieval.
95
100
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
96
101
  def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
97
- return_ttl_remaining: false)
102
+ return_ttl_remaining: false, raw: false)
98
103
  tokens = error_on_unexpected!(T_VA_EN_HD)
99
104
  result = build_metadata_result(tokens)
100
105
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
101
106
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
102
107
  result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
103
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
108
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
104
109
  result
105
110
  end
106
111
 
@@ -117,11 +122,11 @@ module Dalli
117
122
  }
118
123
  end
119
124
 
120
- def parse_value_from_tokens(tokens, cache_nils)
125
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
121
126
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
122
127
  return unless tokens.first == VA
123
128
 
124
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
129
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
125
130
  end
126
131
 
127
132
  def meta_set_with_cas
@@ -212,7 +217,9 @@ module Dalli
212
217
  # The remaining three values in the array are the ResponseHeader,
213
218
  # key, and value.
214
219
  ##
215
- def getk_response_from_buffer(buf, offset = 0)
220
+ # raw: true returns values as stored, ignoring flags in the replies;
221
+ # see meta_get_with_value.
222
+ def getk_response_from_buffer(buf, offset = 0, raw: false)
216
223
  # Find the header terminator starting from offset
217
224
  term_idx = buf.byteindex(TERMINATOR, offset)
218
225
  return [0] unless term_idx
@@ -234,16 +241,20 @@ module Dalli
234
241
  when BYTE_B then base64 ||= token == 'b'
235
242
  end
236
243
  end
237
- body_len = flag_int(size, 's')
238
-
239
- # We have a complete response that has no body.
240
- # This is either the response to the terminating
241
- # noop or, if the status is not MN, an intermediate
242
- # error response that needs to be discarded. A hit
243
- # on an empty value (VA 0) still has a body -- just
244
- # its terminator -- so it's parsed below as a value.
245
- return [true, header_len] if body_len.zero? && tokens.first != VA
246
-
244
+ # memcached also gives the size positionally ("VA <size> ..."); use it
245
+ # when there's no s flag rather than reading the value as replies
246
+ body_len = size ? flag_int(size, 's') : positional_size(tokens)
247
+
248
+ # A complete response with no body. Only the terminating noop's MN
249
+ # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
250
+ # SERVER_ERROR, ...) answers one key's request: report it with a
251
+ # false status so the caller skips it and keeps reading. Treating it
252
+ # as the end would leave the remaining replies on the connection for
253
+ # later commands to read as their own. A hit on an empty value (VA 0)
254
+ # still has a body -- just its terminator -- so it's parsed below.
255
+ return [tokens.first == MN, header_len] if body_len.zero? && tokens.first != VA
256
+
257
+ check_value_size!(body_len)
247
258
  resp_size = header_len + body_len + TERMINATOR.length
248
259
  # The header is in the buffer, but the body is not. As we don't have
249
260
  # a complete response, don't advance the buffer
@@ -252,12 +263,21 @@ module Dalli
252
263
  # The full response is in our buffer, so parse it and return
253
264
  # the values
254
265
  body = buf.byteslice(offset + header_len, body_len)
255
- value = @value_marshaller.retrieve(body, flag_int(bitflags, 'f'))
256
- key = key ? key.delete_prefix!('k') : 0
266
+ value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
267
+ # A hit with no key can't be matched to a request: skip it (false
268
+ # status) rather than yield it, or take it for the end of the pipeline
269
+ return [false, resp_size] unless key
270
+
271
+ key = key.delete_prefix!('k')
257
272
  key = KeyRegularizer.decode(key) if base64
258
273
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
259
274
  end
260
275
 
276
+ # Values from a raw request are returned as stored
277
+ def retrieve(value, bitflags, raw)
278
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
279
+ end
280
+
261
281
  # Integer value of a flag token such as "f123", or 0 when absent.
262
282
  # Strips the prefix in place, like value_from_tokens.
263
283
  def flag_int(token, flag)
@@ -346,6 +366,19 @@ module Dalli
346
366
  end
347
367
  end
348
368
 
369
+ # A pipelined reply claiming an impossible size would otherwise have the
370
+ # buffer wait for (and accumulate) that many bytes
371
+ # The size given right after "VA", or 0 when it isn't a number
372
+ def positional_size(tokens)
373
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
374
+ end
375
+
376
+ def check_value_size!(size)
377
+ return if size.between?(0, MAX_VALUE_BYTES)
378
+
379
+ raise Dalli::DalliError, "Reply value size #{size} is out of range"
380
+ end
381
+
349
382
  def read_line
350
383
  @io_source.read_line&.chomp!(TERMINATOR)
351
384
  end
@@ -356,6 +389,9 @@ module Dalli
356
389
  end
357
390
 
358
391
  def read_data(data_size)
392
+ # Checked before the terminator is added: -1 or -2 would otherwise
393
+ # read 1 or 0 bytes and leave the connection out of step
394
+ check_value_size!(data_size)
359
395
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
360
396
  end
361
397
  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.1.2'
4
+ VERSION = '5.1.4'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
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.1.2
4
+ version: 5.1.4
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