Improve cache digest mask sizing - #2485
Conversation
An assert() call was improperly used to validate a received cache digest from a cache_peer. Switch to using an invalid value, so that the improper digest is rejected instead.
This comment was marked as resolved.
This comment was marked as resolved.
|
The relevant call is in peerDigestSetCBlock():783, which calls CalcMaskSize() with a peer-supplied The new code will cause mismatch between cblock.mask_size and the calculated capacity (we may also add an explicit non-zero check as an additional precaution), causing the rejection of the digest. No other call to CalcMaskSize( ) uses user input |
rousskov
left a comment
There was a problem hiding this comment.
The proposed solution has several conceptual and implementation problems. I will find the time to disclose and fix them. The ball is in my court.
rousskov
left a comment
There was a problem hiding this comment.
I will fix the problems identified in this review. The ball is in my court.
| uint64_t bitCount = (cap * bpe) + 7; | ||
| assert(bitCount < INT_MAX); // do not 31-bit overflow later | ||
| const uint64_t bitCount = (cap * bpe) + 7; | ||
| if (bitCount >= std::numeric_limits<int>::max()) |
There was a problem hiding this comment.
The proposed code is buggy. For example, CalcMaskSize(2^57 + 1, 128) overflows cap * bpe, resulting in incorrect 135 return value:
cap * bpe = (2^57 + 1) * 128 = (2^57 + 1) * 2^7 = 2^64 + 2^7 = 2^64 + 128;
The above multiplication wraps modulo 2^64, producing just 128, and leading to an incorrect "positive" 135 result rather than overflow detection:
(cap * bpe) + 7 = 128 + 7 = 135;
N.B. Unlike this PR code, the corresponding official code did not even try to overcome 64-bit overflows, but it is also buggy, for the same reason.
| assert(bitCount < INT_MAX); // do not 31-bit overflow later | ||
| const uint64_t bitCount = (cap * bpe) + 7; | ||
| if (bitCount >= std::numeric_limits<int>::max()) | ||
| return 0; // overflow; caller must treat 0 as invalid |
There was a problem hiding this comment.
Callers may forget to obey this "must". Depending on one's definition of "treat as invalid", it can be argued that at least one and possibly even two existing callers do not really "treat 0 as invalid" already.
In general, please avoid using special valid (from the compiler point of view) values like zeros or empty strings to flag invalid input.
|
|
||
| /// calculate the size of mask required to digest up to | ||
| /// a specified capacity and bitsize. | ||
| /// \returns 0 when inputs would overflow (invalid). |
There was a problem hiding this comment.
It also returns 0 in other cases. For example, CalcMaskSize(0, 8) AFAICT. This is one of the dangers with using valid (from the compiler point of view) values like zeros or empty strings to flag invalid input.
P.S. Current code may not have any CalcMaskSize(0, 8) callers, but that assertion does not address this concern because code will change (and because changing such code increases associated caller risks).
Also marked a few unaddressed problems. Addressing them may change this solution!
... with digest mask size calculations and with sender/recipient mask size calculations getting out of sync, causing legacy senders to reject digests generated by this modern/patched code. The only (pre-existing) problem this code does not solve is support for senders and receivers that have different `int` sizes. Fixed code does not assert in such environments. It rejects the digests with a level-0 cache.log message. That is good enough for now. The key in this solution is to limit calculated digest capacity (and derive mask size from that) rather than just focusing on safe mask size calculations derived from raw capacity estimates. This solution works because the recipient uses sender's digest capacity to re-calculate the mask size. If we provide the recipient with safe capacity values and a matching mask size, the legacy recipient should be happy. TODO: * Remove a temporary assertion that duplicates unsafe code. * Polish touched error messages. * Consider reducing diff (and hiding unchanged callers) by avoiding MaskSize() renaming.
It would be nice to keep that assertion, but it requires adding a public CacheDigest::UnsafeMaskSize() method, which is probably too much.
The script mentioned in 2022 commit d816f28 could not handle this case but applied a similar change to a nearby similar debugs().
This addition has no effect on typical 32-bit and 64-bit POSIX systems: SSIZE_MAX is far larger than other limits.
... because the corresponding CacheDigest::init() assertions are (and should always be) satisfied by positive capacity already. We do not have to force mask sizes to be always positive from that point of view.
Existing code still stores the number of bits as `int`. For example, CacheDigestStats::bit_on_count is an `int` and cacheDigestStats() stores bit position `pos` in an `int`.
| // This limit is paranoid because no instance can store enough objects to | ||
| // exceed this maximum. | ||
| const auto maxMaskSize = std::numeric_limits<uint64_t>::max() / 8; |
There was a problem hiding this comment.
If my math is correct, at 255 bits per entry, it would take about 2.3 million years to reach this limit while storing/adding 1000 new objects every second. Still, it is probably better to have this specific "we can count all bits using uint64_t math" limit than to use std::numeric_limits<uint64_t>::max().
| static uint64_t | ||
| UnsafeMaskSize(const uint64_t cap, const uint8_t bpe) | ||
| { | ||
| Assure(bpe); |
There was a problem hiding this comment.
Squid already rejects zero bpe in received digests. We must also reject digest_bits_per_entry 0 configuration directives, but perhaps that should be done in a dedicated PR.
| // Bug 4534: we still have to set an upper-limit at some reasonable value though. | ||
| // this matches cacheDigestCalcMaskSize doing (cap*bpe)+7 < INT_MAX | ||
| const uint64_t absolute_max = (INT_MAX -8) / Config.digest.bits_per_entry; | ||
| if (cap > absolute_max) { |
There was a problem hiding this comment.
This restriction is now R4 inside CacheDigest::CalcMaskSize().
| static_cast<uint64_t>(std::numeric_limits<int>::max()) / 8, // R2 | ||
| static_cast<uint64_t>(std::numeric_limits<ssize_t>::max()), // R3 | ||
| static_cast<uint64_t>(256)*1024*1024, // R4 | ||
| static_cast<uint64_t>(INT_MAX - 8) / 8}); // R5 |
There was a problem hiding this comment.
I used INT_MAX here instead of the usually preferred std::numeric_limits<int>::max() because I wanted to tie R5 to the problematic assertion in legacy code. That assertion is using INT_MAX.
| << ")."); | ||
| const auto calculatedMaskSize = CacheDigest::CalcMaskSize(cblock.capacity, cblock.bits_per_entry); | ||
| if (size_t(cblock.mask_size) != calculatedMaskSize) { | ||
| debugs(72, DBG_CRITICAL, "ERROR: " << host << " digest cblock is corrupted or unsupported " << |
There was a problem hiding this comment.
We can undo all changes in this file, but I think it is best to use a new ERROR message for these cases so that we can tell whether these errors are printed by problematic/legacy code or upgraded one. In a peering hierarchy, it may be tricky to be sure that every instance is running the intended Squid version...
| const auto safeCapMax = uint64_t(safeMaskSizeMax) * 8 / bpe; | ||
| const auto safeCap = std::min(cap, safeCapMax); | ||
| if (cap > safeCap) { | ||
| const auto absolute_max = safeCap; // diff reducer |
There was a problem hiding this comment.
We can reduce the diff further by using absolute_max instead of safeCap, but I think we should use safeCap instead because we have two sets of variables here, one set for the mask size (safeMaskSizeMax) and one for the digest capacity (safeCapMax and safeCap). absolute_max does not tell the reader which set/object that maximum applies to.
This change fixes cache digest capacity and mask size calculations to
improve validation of received cache digest metadata and to prevent
generation of cache digests that may trigger problems in both
digest-generating instances and digest-receiving peers.
This change does not affect Cache Digest population algorithm and
exchange protocol. This change preserves compatibility across old and
updated peers:
The text below details new and updated digest sizing limits.
A Squid instance may deal with two sources of Cache Digests:
A digest of the local instance caches. Since 2016 commit 831e953, its
mask size is capped at approximately INT_MAX bits. That maximum
usually corresponds to a ~256MB digest mask memory allocation. See
absolute_max calculation in old storeDigestCalcCap() code.
Digests sent to the instance by its cache_peers. These digests come
with peer-set capacity and mask sizes. The received mask size was
effectively capped at similar levels (using an assertion) when
peerDigestSetCBlock() called old CacheDigest::CalcMaskSize().
The two poorly duplicated limits were tied together by a stale C++
comment. This change removes that duplication, moving limit enforcement
from storeDigestCalcCap() into CalcMaskSize() and eliminating the
problematic assertion. If Squid receives a cache digest that violates
the new limits, that digest will be safely rejected with a level-0
"digest cblock is corrupted or unsupported" ERROR.
Also fixed integer overflow in CacheDigest::CalcMaskSize() calculations,
addressing an XXX comment correctly added in 2015 commit 5bc5e81 that
was incorrectly replaced with an incorrect assertion in 2016 commit
831e953. See new UnsafeMaskSize().
Also explicitly capped digest capacity and mask size to prevent integer
overflows where the calculated mask size is used, including uses in old
receiver code. See R1-R5 limits in updated CalcMaskSize(). Until all
Squids are upgraded, older installations will continue to receive
digests generated by upgraded Squids. Even as we improve Squid code to
eliminate these problematic uses, we should keep these limits until
older installations (that these limits protect) are no longer supported.
In typical environments (e.g., 32-bit
int), the new combined mask sizelimit is exactly 268'435'454 bytes which is only two bytes smaller than
the ~256MB limit added in 2016 commit 831e953. If an old peer sends a
digest mask with an "extra" byte or two, new code will safely reject it.