PYTHON-5983 Validate uncompressed size in OP_COMPRESSED messages - #2976
PYTHON-5983 Validate uncompressed size in OP_COMPRESSED messages#2976dfengliu wants to merge 7 commits into
Conversation
The process_compression_header method previously discarded the uncompressed_size field from the compression sub-header. A malicious or compromised server could send a small compressed envelope (passing the max_message_size check) that decompresses to a very large payload, causing memory exhaustion. This change returns the uncompressed_size from the compression header and validates it against max_message_size before accepting the compressed payload.
There was a problem hiding this comment.
Pull request overview
This PR hardens PyMongo’s handling of OP_COMPRESSED messages by validating the compression sub-header’s uncompressed_size against the configured max_message_size, preventing decompression-driven memory exhaustion.
Changes:
- Extend
process_compression_header()to returnuncompressed_sizealong withop_codeandcompressor_id. - Add a
uncompressed_size > self._max_message_sizeguard in the asyncio protocol receive path (buffer_updated()), closing the connection withProtocolErrorwhen exceeded. - Add a unit test intended to assert the connection closes when
uncompressed_sizeexceeds the max.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
pymongo/network_layer.py |
Adds uncompressed_size extraction and validation in the asyncio protocol path for OP_COMPRESSED messages. |
test/asynchronous/test_async_network_layer.py |
Adds a new test meant to cover oversized uncompressed_size behavior. |
| def test_compression_uncompressed_size_exceeds_max_closes(self): | ||
| self.protocol._max_message_size = 1024 | ||
| self.protocol._header = memoryview( | ||
| bytearray( | ||
| pack_msg_header( | ||
| length=35, request_id=1, response_to=0, op_code=2012 | ||
| ) | ||
| ) | ||
| ) | ||
| self.protocol.process_header() | ||
| # Now feed compression sub-header with uncompressed_size > max | ||
| self.protocol._compression_header[:] = struct.pack( | ||
| "<iiB", 2013, 9999, 2 | ||
| ) | ||
| self.protocol._compression_index = 9 | ||
| self.protocol.buffer_updated(0) | ||
| self.protocol.transport.abort.assert_called() |
| ( | ||
| self._op_code, | ||
| uncompressed_size, | ||
| self._compressor_id, | ||
| ) = self.process_compression_header() | ||
| if uncompressed_size > self._max_message_size: | ||
| self.close( | ||
| ProtocolError( | ||
| f"Uncompressed message size ({uncompressed_size!r}) " | ||
| f"is larger than server max message size " | ||
| f"({self._max_message_size!r})" | ||
| ) | ||
| ) | ||
| return |
|
Hi @dfengliu, thanks for the PR! Could you move the bound into |
|
@blink1073 Thank you for the suggestion. I have moved the size validation into |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
pymongo/network_layer.py:783
- The OP_COMPRESSED sub-header’s
uncompressed_sizefield is still unpacked and discarded (_). This means a server can advertise an extremely large uncompressed size and the client will proceed to decompress, which is the memory-exhaustion vector described in the PR. Validateuncompressed_sizeagainstmax_message_sizebefore calling_decompress.
op_code, _, compressor_id = _UNPACK_COMPRESSION_HEADER(receive_data(conn, 9, deadline))
data = _decompress(receive_data(conn, length - 25, deadline), compressor_id, max_message_size)
test/asynchronous/test_async_network_layer.py:104
- This new test exercises
_decompress’s post-decompression length check, but it does not cover validating the OP_COMPRESSEDuncompressed_sizefield againstmax_message_size(the core requirement described in the PR). Add a regression test that feeds an OP_COMPRESSED header + compression sub-header withuncompressed_size > MAX_MESSAGE_SIZEinto the protocol/receive path and asserts aProtocolError(and that the connection is closed/aborted, if applicable).
class TestDecompress(unittest.TestCase):
def test_decompressed_size_exceeds_max_raises(self):
from pymongo.compression_support import _decompress
import zlib
# Compress a small payload that decompresses larger than max
payload = zlib.compress(b"x" * 100)
with self.assertRaisesRegex(ProtocolError, "Decompressed message size"):
_decompress(payload, 2, max_message_size=5)
# Normal decompression still works
result = _decompress(payload, 2, max_message_size=1024)
self.assertEqual(result, b"x" * 100)
pymongo/network_layer.py:37
decompressis imported but no longer used in this module (all call sites were switched to_decompress). This will fail linting (unused import).
from pymongo.compression_support import _decompress, decompress
test/asynchronous/test_async_network_layer.py:22
structis imported but not used in this test module, which will fail linting (unused import).
import asyncio
import struct
import sys
| if compressor_id is not None: | ||
| data = decompress(data, compressor_id) | ||
| data = _decompress(data, compressor_id, self._max_message_size) | ||
| return data, op_code |
| if len(result) > max_message_size: | ||
| from pymongo.errors import ProtocolError | ||
|
|
||
| raise ProtocolError( | ||
| f"Decompressed message size ({len(result)!r}) is larger than " | ||
| f"server max message size ({max_message_size!r})" | ||
| ) | ||
| return result |
blink1073
left a comment
There was a problem hiding this comment.
Thanks for moving this forward. One thing still needs to change: the check runs after decompression completes, so it measures the result rather than bounding it. Both zlib and zstd accept a max length on their incremental decompressors, which would apply the limit during decompression instead.
Smaller items:
- The
2**31 - 1default ondecompress()is effectively unbounded, and nothing in the
driver calls it now. Worth dropping it or defaulting toMAX_MESSAGE_SIZE. - Please restore the snappy comment explaining the
bytes(data)conversion. - The test payload is small enough that it only exercises the comparison. A large expansion case would be more useful, and
test/test_compression_support.pyis a better home for it. just lint-manualpicks up a few things, and this needs adoc/changelog.rstentry.
…ompression check Validate uncompressed_size from the OP_COMPRESSED sub-header against max_message_size before calling _decompress, in both async and sync receive paths. The internal _decompress function retains a post-decompression length check as defense-in-depth against servers that misreport the uncompressed size.
- Restore public decompress() as the original function without wrapper - Keep _decompress() with required max_message_size for internal validation - Restore snappy bytes(data) comment that was lost during refactoring - Move decompress size-limit test to test_compression_support.py with high expansion ratio payload - Add changelog entry
|
@blink1073 Thank you for the detailed review. I have addressed the items you raised:
Regarding the incremental decompressor suggestion: the current approach uses pre-validation (checking Would this approach be acceptable? |
|
The layered structure is fine to keep, but the max length arguments need to go in as well. elif compressor_id == ZlibContext.compressor_id:
import zlib
- result = zlib.decompress(data)
+ result = zlib.decompressobj().decompress(data, max_message_size + 1)
elif compressor_id == ZstdContext.compressor_id:
if sys.version_info >= (3, 14):
from compression import zstd
else:
from backports import zstd
- result = zstd.decompress(data)
+ result = zstd.ZstdDecompressor().decompress(data, max_message_size + 1)Snappy has no equivalent, so it keeps relying on the trailing check. Rest of the commit:
|
Apply the max_message_size limit during decompression for zlib and zstd using their incremental decompressor max_length parameter, so memory is bounded before the size check runs. Snappy has no such API and continues to rely on the post-decompression check. Collapse decompress into a single function with an optional max_message_size parameter, and restore the snappy bytes(data) comment. Add pre-validation of the OP_COMPRESSED sub-header's uncompressed_size in both async and sync receive paths, plus regression tests covering oversized declarations and decompression bombs.
|
@blink1073 Thank you for the detailed feedback. I have implemented the changes:
All 39 unit tests and the end-to-end scenarios pass. |
|
Thank you! We're getting close. I have two more requests:
|
- Make max_message_size a required parameter in decompress() and remove the unbounded None branch, ensuring the decompression limit is always applied. - Fix the test_compression_commands decompress spy to accept the max_message_size argument, resolving the TypeError that caused widespread CI failures after merging main. - Add tracemalloc-based peak memory test verifying the max_length bound actually limits allocation (2 MB vs 215 MB without the fix). - Add exact-boundary and snappy over-limit regression tests.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
pymongo/network_layer.py:800
- This compares the header-excluding
uncompressed_sizewith a limit that applies to the complete wire message, allowing the reconstructed message to exceed the negotiated maximum by 16 bytes. Include the standard header in this check and bound the decompressed body tomax_message_size - 16.
if uncompressed_size > max_message_size:
pymongo/compression_support.py:188
ZstdDecompressor.decompressis incremental as well, so this can silently return partial output for a truncated frame when the result stays under the size limit. Checkeofto retain the complete-frame validation previously provided by the one-shotzstd.decompresscall.
result = zstd.ZstdDecompressor().decompress(data, max_message_size + 1)
pymongo/network_layer.py:612
uncompressed_sizeis the original message body size and excludes the 16-byte standard header (pymongo/message.py:262packslen(data)). Comparing it directly with the total wire-message limit allows a reconstructed message 16 bytes overmax_message_size; the laterdecompresscall has the same mismatch. Validateuncompressed_size + 16here and passself._max_message_size - 16as the payload bound todecompress.
This issue also appears on line 800 of the same file.
if uncompressed_size > self._max_message_size:
test/asynchronous/test_async_network_layer.py:240
- This test exercises the synchronous
receive_messagepath inside a module explicitly documented as async-only (test/asynchronous/test_async_network_layer.py:15-19). Move it totest/test_network_layer.py, whose module documentation identifiesreceive_messageas sync-only and which already provides connection/receive helpers; that also avoids maintaining the duplicate_FakeSocketand_FakeConnfixtures added above.
class TestReceiveMessage(unittest.TestCase):
def test_oversized_uncompressed_size_rejected(self):
pymongo/compression_support.py:181
- Switching from one-shot
zlib.decompressto the incremental API removes end-of-stream validation: truncated zlib data can return partial bytes witheof == Falseinstead of raising as before. Preserve the bounded output while rejecting an incomplete stream.
result = zlib.decompressobj().decompress(data, max_message_size + 1)
| import snappy | ||
|
|
||
| return snappy.uncompress(bytes(data)) | ||
| result = snappy.uncompress(bytes(data)) |
|
@blink1073 Thank you for raising these points. Both requests are now addressed:
Additionally, I fixed a compatibility issue in the test_compression_commands spy that was causing CI failures after merging main — the spy now accepts and forwards the max_message_size argument. CI is currently waiting on approval for both GitHub Actions and Evergreen. Could you help approve the runs when you have a moment? Thanks! |
@blink1073 Thank you for raising these points. Both requests are now addressed:
Additionally, I fixed a compatibility issue in the test_compression_commands spy that was causing CI failures after merging main — the spy now accepts and forwards the max_message_size argument. CI is currently waiting on approval for both GitHub Actions and Evergreen. Could you help approve the runs when you have a moment? Thanks! |
|
I agree with the copilot comment about def _snappy_uncompressed_length(data: bytes | memoryview) -> int:
"""Read the varint-encoded uncompressed length from a raw snappy block."""
result = shift = 0
for i in range(5):
if i >= len(data):
raise ProtocolError("Truncated snappy payload")
byte = data[i]
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result
shift += 7
raise ProtocolError("Invalid snappy uncompressed length header")Then before declared = _snappy_uncompressed_length(data)
if declared > max_message_size:
raise ProtocolError(
f"Decompressed message size ({declared!r}) is larger than "
f"server max message size ({max_message_size!r})"
)
I think it is worth extending |
PYTHON-5983
Summary
Validate the
uncompressed_sizefield from the OP_COMPRESSED wire protocol compression sub-header againstmax_message_size.Details
The
process_compression_headermethod innetwork_layer.pypreviously unpacked the compression sub-header and discarded theuncompressed_sizefield. A malicious or compromised MongoDB server could send a small compressed envelope (passing the envelope size check) that decompresses to a very large payload, causing memory exhaustion.Changes
process_compression_headernow returnsuncompressed_sizein addition toop_codeandcompressor_iduncompressed_sizeagainstself._max_message_sizeand raisesProtocolErrorif it exceeds the limittest_compression_uncompressed_size_exceeds_max_closes