Skip to content

Commit 917a9ff

Browse files
Broaden the fix to all control characters, not just NUL
protocol.txt's actual rule is "must not include control characters or whitespace" -- \s alone missed NUL plus the rest of C0 (0x01-0x08, 0x0E-0x1F) and DEL (0x7F). Switches to \p{Cntrl} (equivalent to POSIX [:cntrl:], but without the "duplicated range" warning Ruby raises when \s and [:cntrl:] are combined in one class -- fatal under this suite's -w run). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 521c8d5 commit 917a9ff

5 files changed

Lines changed: 83 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ Other changes:
3838

3939
Bug Fixes:
4040

41-
- Base64-encode keys containing embedded NUL bytes (#1148)
42-
- `KeyRegularizer.required?` decided whether a key needed base64 encoding using `/\s/`, which does not match NUL -- a key that was otherwise ASCII-only and contained no whitespace (e.g. `"foo\x00bar"`) went out on the wire unencoded
43-
- Not a command-injection risk: the text protocol splits commands on CRLF, not NUL. The risk is key confusion -- anything downstream that treats the key as a C string (memcached itself, a proxy, logging) could silently truncate at the NUL and act on a different, shorter key than Dalli believes it sent
44-
- A raw NUL byte in a key was never protocol-compliant in the first place: memcached's own spec (`protocol.txt`) states a key "must not include control characters or whitespace," and NUL is a control character. The only sanctioned way to carry binary content in a key is the meta protocol's base64 (`b` flag) path -- the one whitespace and non-ASCII keys already used, and the one NUL-containing keys now use too
45-
- **Behavior change:** a key containing a NUL byte now produces different bytes on the wire (base64-encoded, per the meta protocol's `b` flag) than before. Existing cache entries stored under the old, unencoded form of such a key will read as a miss once every reader has upgraded. **During a rolling deploy, old and new Dalli versions disagree about which physical key a NUL-containing logical key maps to** -- not just a one-time cutover, but ongoing inconsistency between the old-version and new-version server pools for the duration of the rollout. Harmless for an ordinary cached value (worst case, extra cache misses); worth accounting for if such a key ever backs something stateful, like a lock or counter. Expected to be rare in practice: embedding a raw NUL byte in a cache key is unusual, and doing so was already outside what the protocol permits
41+
- Base64-encode keys containing control characters, not just NUL (#1148)
42+
- `KeyRegularizer.required?` decided whether a key needed base64 encoding using `/\s/`, which matches most whitespace but none of the C0 control range (0x00-0x1F) or DEL (0x7F) -- a key that was otherwise ASCII-only and contained no whitespace (e.g. `"foo\x00bar"` or a key with an embedded ESC byte) went out on the wire unencoded
43+
- Not a command-injection risk: the text protocol splits commands on CRLF, not other control bytes. The risk is key confusion -- anything downstream that treats one of these bytes specially (a C string terminating at NUL, a terminal or log line interpreting an escape byte) could silently act on a different key than Dalli believes it sent
44+
- A raw control byte in a key was never protocol-compliant in the first place: memcached's own spec (`protocol.txt`) states a key "must not include control characters or whitespace." The only sanctioned way to carry such content in a key is the meta protocol's base64 (`b` flag) path -- the one whitespace and non-ASCII keys already used, and the one these keys now use too. The check is now `/[\s[:cntrl:]]/`, matching that rule directly rather than special-casing NUL
45+
- **Behavior change:** a key containing a control character now produces different bytes on the wire (base64-encoded, per the meta protocol's `b` flag) than before. Existing cache entries stored under the old, unencoded form of such a key will read as a miss once every reader has upgraded. **During a rolling deploy, old and new Dalli versions disagree about which physical key such a logical key maps to** -- not just a one-time cutover, but ongoing inconsistency between the old-version and new-version server pools for the duration of the rollout. Harmless for an ordinary cached value (worst case, extra cache misses); worth accounting for if such a key ever backs something stateful, like a lock or counter. Expected to be rare in practice: embedding a raw control byte in a cache key is unusual, and doing so was already outside what the protocol permits
4646
- Found while auditing `request_formatter.rb` during the routing-token work in #1130 / #1147; unrelated to that change and predates it
4747

4848
- Retry transient network errors in `get_multi`, `set_multi` and `delete_multi` instead of silently swallowing them (#1149)

lib/dalli/protocol/key_regularizer.rb

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,25 @@ class Meta
1212
module KeyRegularizer
1313
module_function
1414

15-
# \s does not match NUL. A key with an embedded NUL is ASCII-only and
16-
# has no whitespace, so it would otherwise be written to the wire
17-
# unencoded -- not a protocol-injection risk (the text protocol splits
18-
# on CRLF, not NUL), but a downstream consumer that treats the key as
19-
# a C string (memcached itself, a proxy, logging) could silently
20-
# truncate at the NUL and act on a different, shorter key than Dalli
21-
# believes it sent.
15+
# protocol.txt requires that a key "must not include control
16+
# characters or whitespace" -- \p{Cntrl} is C0 (0x00-0x1F) plus DEL
17+
# (0x7F). \s alone misses NUL and the rest of that range: a key
18+
# containing one of those bytes but no whitespace is ASCII-only, so
19+
# it would otherwise be written to the wire unencoded. Not a
20+
# protocol-injection risk (the text protocol splits on CRLF, not
21+
# other control bytes), but a downstream consumer that treats the key
22+
# specially at one of those bytes (a C string terminating at NUL, a
23+
# terminal or log line interpreting an escape byte) could silently
24+
# act on a different key than Dalli believes it sent.
25+
#
26+
# Written as \p{Cntrl} rather than the POSIX [:cntrl:] bracket class:
27+
# \s and [:cntrl:] overlap (tab, newline, CR are in both), and Ruby
28+
# warns "character class has duplicated range" when they're combined
29+
# in one -- fatal here, since this suite's -w run treats warnings as
30+
# errors (see test_strict_warnings.rb). \p{Cntrl} matches the same
31+
# bytes without the overlap warning.
2232
def required?(key)
23-
!key.ascii_only? || /[\s\0]/.match?(key)
33+
!key.ascii_only? || /[\p{Cntrl}\s]/.match?(key)
2434
end
2535

2636
def encode(key)

test/integration/test_encoding.rb

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
end
2626
end
2727

28-
# KeyRegularizer.required? previously missed embedded NUL bytes ('\s'
29-
# doesn't match '\0'), so a key like this went on the wire unencoded --
30-
# this proves it now round-trips through the base64 path instead, and
31-
# (via the sibling key below) doesn't collide with a similar key that
32-
# has no NUL.
28+
# KeyRegularizer.required? previously missed embedded control bytes
29+
# ('\s' doesn't match NUL or most of the rest of the C0 range), so a key
30+
# like this went on the wire unencoded -- this proves it now round-trips
31+
# through the base64 path instead, and (via the sibling key below)
32+
# doesn't collide with a similar key that has no control byte.
3333
it 'supports keys with an embedded NUL byte, distinct from a similar key without one' do
3434
memcached_persistent(p) do |dc|
3535
nul_key = "foo\x00bar"
@@ -42,6 +42,19 @@
4242
assert_equal 'plain_value', dc.get(plain_key)
4343
end
4444
end
45+
46+
it 'supports keys with a non-NUL control byte (e.g. ESC), distinct from a similar key without one' do
47+
memcached_persistent(p) do |dc|
48+
esc_key = "foo\x1Bbar"
49+
plain_key = 'foobar'
50+
51+
dc.set(esc_key, 'esc_value')
52+
dc.set(plain_key, 'plain_value')
53+
54+
assert_equal 'esc_value', dc.get(esc_key)
55+
assert_equal 'plain_value', dc.get(plain_key)
56+
end
57+
end
4558
end
4659
end
4760
end

test/protocol/test_key_regularizer.rb

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,35 @@
5252
refute Dalli::Protocol::Meta::KeyRegularizer.required?(key)
5353
end
5454

55-
# \s does not match NUL, so an ASCII-only key with an embedded NUL and no
56-
# other whitespace would otherwise slip past this check and reach the
57-
# wire unencoded.
55+
# \s does not match NUL or most other control bytes, so an ASCII-only key
56+
# containing one but no whitespace would otherwise slip past this check
57+
# and reach the wire unencoded -- protocol.txt requires that a key "must
58+
# not include control characters or whitespace."
5859
it 'returns true for keys with embedded NUL bytes' do
5960
key = "foo\x00bar"
6061

6162
assert Dalli::Protocol::Meta::KeyRegularizer.required?(key)
6263
end
64+
65+
it 'returns true for keys with a non-NUL control byte (e.g. ESC)' do
66+
key = "foo\x1Bbar"
67+
68+
assert Dalli::Protocol::Meta::KeyRegularizer.required?(key)
69+
end
70+
71+
it 'returns true for keys containing DEL (0x7F)' do
72+
key = "foo\x7Fbar"
73+
74+
assert Dalli::Protocol::Meta::KeyRegularizer.required?(key)
75+
end
76+
77+
it 'returns true for every C0 control byte and DEL' do
78+
((0x00..0x1F).to_a + [0x7F]).each do |byte|
79+
key = "foo#{byte.chr}bar"
80+
81+
assert Dalli::Protocol::Meta::KeyRegularizer.required?(key), "byte 0x#{byte.to_s(16)} was not caught"
82+
end
83+
end
6384
end
6485

6586
describe '.encode' do
@@ -133,5 +154,13 @@
133154

134155
assert_equal original_key, decoded_key
135156
end
157+
158+
it 'encode then decode returns original key with a non-NUL control byte' do
159+
original_key = "foo\x1Bbar"
160+
encoded_key = Dalli::Protocol::Meta::KeyRegularizer.encode(original_key)
161+
decoded_key = Dalli::Protocol::Meta::KeyRegularizer.decode(encoded_key)
162+
163+
assert_equal original_key, decoded_key
164+
end
136165
end
137166
end

test/protocol/test_request_formatter.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,12 +557,20 @@ def assert_encoded(key)
557557
assert_encoded 'user:🎉:profile'
558558
end
559559

560-
# \s does not match NUL, so an ASCII-only key with an embedded NUL and no
561-
# other whitespace previously reached the wire unencoded.
560+
# \s does not match NUL or most other control bytes, so an ASCII-only key
561+
# containing one but no whitespace previously reached the wire unencoded.
562562
it 'returns base64 encoded key for keys with an embedded NUL byte' do
563563
assert_encoded "foo\x00bar"
564564
end
565565

566+
it 'returns base64 encoded key for keys with a non-NUL control byte (e.g. ESC)' do
567+
assert_encoded "foo\x1Bbar"
568+
end
569+
570+
it 'returns base64 encoded key for keys containing DEL (0x7F)' do
571+
assert_encoded "foo\x7Fbar"
572+
end
573+
566574
it 'handles empty keys' do
567575
assert_raw ''
568576
end

0 commit comments

Comments
 (0)