diff --git a/.audit/oberstet_fix_1910.md b/.audit/oberstet_fix_1910.md new file mode 100644 index 000000000..d94c1a785 --- /dev/null +++ b/.audit/oberstet_fix_1910.md @@ -0,0 +1,8 @@ +- [ ] I did **not** use any AI-assistance tools to help create this pull request. +- [x] I **did** use AI-assistance tools to *help* create this pull request. +- [x] I have read, understood and followed the projects' [AI Policy](https://github.com/crossbario/autobahn-python/blob/main/AI_POLICY.md) when creating code, documentation etc. for this pull request. + +Submitted by: @oberstet +Date: 2026-07-14 +Related issue(s): #1910 +Branch: oberstet:fix_1910 diff --git a/docs/changelog.rst b/docs/changelog.rst index c7179867a..540f5ab23 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,7 @@ Changelog * Fix WebSocket ``maxMessagePayloadSize`` being enforced against the compressed on-the-wire frame length instead of the uncompressed reassembled message size when permessage-compress (deflate/bzip2/snappy/brotli) is negotiated. A small compressed frame could inflate far beyond the configured limit and be delivered to the application (a decompression-bomb style denial-of-service; security advisory GHSA-hxp9-w8x3-p566, same class as CVE-2016-10544). The limit is now re-checked at the inflation site against the running uncompressed message size, and the connection is failed with close code 1009 (message too big) before delivery — for both the whole-message and streaming receive APIs and every compression backend. Behaviour change: a compressed message that inflates past ``maxMessagePayloadSize`` is now rejected where it previously passed; uncompressed traffic and the per-frame ``maxFramePayloadSize`` wire guard are unaffected (#1909) * Fix the permessage-deflate ``max_message_size`` receive cap silently truncating an over-limit message and raising a zlib error instead of cleanly rejecting it: the bounded ``decompress(…, max_length)`` left the remaining input in ``unconsumed_tail`` undrained, so the message was corrupted rather than reported. Decompression is now bounded cumulatively across frames and raises ``PayloadExceededError`` as soon as the uncompressed size would exceed the cap (#1908) +* Make bounded decompression backend-agnostic: ``decompress_message_data()`` gains an optional ``max_output_len`` argument (documented on the ``PerMessageCompress`` base class) and every permessage-compress backend now honours it. deflate and bzip2 stop inflating once the limit is reached (native incremental cap); snappy and brotli, whose libraries expose no output-length argument, inflate the frame (already bounded on the wire by ``maxFramePayloadSize``) and then reject — a weaker but still clean per-frame guarantee. The WebSocket receive path passes the remaining ``maxMessagePayloadSize`` budget so a compressed frame no longer expands unbounded into memory before the size check; the previous post-inflation check (#1909) remains as a backstop. Previously only deflate had any decompressed-output cap, so a snappy/bzip2/brotli frame could inflate fully into memory first (#1910) **FlatBuffers** diff --git a/src/autobahn/websocket/compress_base.py b/src/autobahn/websocket/compress_base.py index 9846509b4..627aba019 100644 --- a/src/autobahn/websocket/compress_base.py +++ b/src/autobahn/websocket/compress_base.py @@ -60,4 +60,22 @@ class PerMessageCompressResponseAccept: class PerMessageCompress: """ Base class for WebSocket compression negotiated parameters. + + Concrete subclasses (one per permessage-compress extension) implement the + decompression interface used by the WebSocket protocol: + + - ``start_decompress_message(self)`` + - ``decompress_message_data(self, data, max_output_len=None)`` + - ``end_decompress_message(self)`` + + Bounded-decompression contract for ``decompress_message_data``: when + ``max_output_len`` is not ``None``, the call returns at most + ``max_output_len`` octets of decompressed output and raises + :class:`autobahn.exception.PayloadExceededError` if the input would produce + more - it never silently truncates. ``max_output_len=None`` (the default) + leaves decompression unbounded. Backends whose underlying library exposes an + incremental output limit (deflate, bzip2) enforce the bound before fully + inflating a frame; backends without one (snappy, brotli) inflate the frame + (already bounded on the wire by ``maxFramePayloadSize``) and then check, + a weaker but still-clean per-frame guarantee. """ diff --git a/src/autobahn/websocket/compress_brotli.py b/src/autobahn/websocket/compress_brotli.py index a5154456c..4ca3a87f8 100644 --- a/src/autobahn/websocket/compress_brotli.py +++ b/src/autobahn/websocket/compress_brotli.py @@ -31,6 +31,7 @@ except ImportError: import brotlicffi as brotli +from autobahn.exception import PayloadExceededError from autobahn.websocket.compress_base import ( PerMessageCompress, PerMessageCompressOffer, @@ -483,8 +484,19 @@ def start_decompress_message(self): if self._decompressor is None or self.server_no_context_takeover: self._decompressor = brotli.Decompressor() - def decompress_message_data(self, data): - return self._decompressor.process(data) + def decompress_message_data(self, data, max_output_len=None): + # brotli's Decompressor.process() has no output-length argument, so the + # frame is decompressed in full (bounded on the wire by + # maxFramePayloadSize) and then checked. This is a weaker, + # per-frame-granular guarantee than the incremental cap deflate/bzip2 + # provide, but it still rejects an over-budget message cleanly. + data = self._decompressor.process(data) + if max_output_len is not None and len(data) > max_output_len: + raise PayloadExceededError( + "WebSocket message exceeds decompression limit of " + f"{max_output_len} octets" + ) + return data def end_decompress_message(self): pass diff --git a/src/autobahn/websocket/compress_bzip2.py b/src/autobahn/websocket/compress_bzip2.py index 9b1050383..fa30afd37 100644 --- a/src/autobahn/websocket/compress_bzip2.py +++ b/src/autobahn/websocket/compress_bzip2.py @@ -26,6 +26,7 @@ import bz2 +from autobahn.exception import PayloadExceededError from autobahn.websocket.compress_base import ( PerMessageCompress, PerMessageCompressOffer, @@ -522,8 +523,22 @@ def start_decompress_message(self): if self._decompressor is None: self._decompressor = bz2.BZ2Decompressor() - def decompress_message_data(self, data): - return self._decompressor.decompress(data) + def decompress_message_data(self, data, max_output_len=None): + if max_output_len is None: + return self._decompressor.decompress(data) + # BZ2Decompressor.decompress(data, max_length) returns at most + # max_length bytes and buffers any excess internally. Cap at one octet + # over the budget: if that yields more than max_output_len octets the + # message is over budget and is rejected (matching deflate's + # strictly-greater boundary). (needs_input is unreliable here - it also + # goes False at end-of-stream, i.e. for an under-budget message.) + data = self._decompressor.decompress(data, max(max_output_len, 0) + 1) + if len(data) > max_output_len: + raise PayloadExceededError( + "WebSocket message exceeds decompression limit of " + f"{max_output_len} octets" + ) + return data def end_decompress_message(self): self._decompressor = None diff --git a/src/autobahn/websocket/compress_deflate.py b/src/autobahn/websocket/compress_deflate.py index c3566a3b9..65c8d32bd 100644 --- a/src/autobahn/websocket/compress_deflate.py +++ b/src/autobahn/websocket/compress_deflate.py @@ -816,27 +816,37 @@ def start_decompress_message(self): self._decompress_message_size = 0 - def decompress_message_data(self, data): - if self.max_message_size is None: + def decompress_message_data(self, data, max_output_len=None): + # The output is bounded by the smaller of two optional caps: the + # extension-level max_message_size (negotiated, cumulative across all + # frames of the message) and the per-call max_output_len (the remaining + # protocol-level budget). If neither is set, decompression is unbounded. + limits = [] + if self.max_message_size is not None: + limits.append(self.max_message_size - self._decompress_message_size) + if max_output_len is not None: + limits.append(max_output_len) + if not limits: return self._decompressor.decompress(data) - # Cap output at the remaining message budget. zlib treats a max_length + # Cap output at the tighter remaining budget. zlib treats a max_length # of 0 as "unlimited", so once the budget is exhausted we cap the next # call at 1 byte: any further real output then lands in unconsumed_tail # and triggers the clean rejection below. - remaining = self.max_message_size - self._decompress_message_size - data = self._decompressor.decompress(data, remaining if remaining > 0 else 1) + limit = min(limits) + data = self._decompressor.decompress(data, limit if limit > 0 else 1) self._decompress_message_size += len(data) # A non-empty unconsumed_tail means more output was available than the - # (cumulative) budget allowed, i.e. the message exceeds max_message_size. - # Reject cleanly instead of silently truncating - truncation both drops - # application data and corrupts the deflate stream, so the subsequent + # budget allowed, i.e. the message exceeds the limit. Reject cleanly + # instead of silently truncating - truncation both drops application + # data and corrupts the deflate stream, so the subsequent # end_decompress_message() would raise "zlib error -3" on the trailer. if self._decompressor.unconsumed_tail: raise PayloadExceededError( - "WebSocket message exceeds configured max_message_size of " - f"{self.max_message_size} octets" + "WebSocket message exceeds decompression limit " + f"(max_message_size={self.max_message_size}, " + f"max_output_len={max_output_len})" ) return data diff --git a/src/autobahn/websocket/compress_snappy.py b/src/autobahn/websocket/compress_snappy.py index e8337e4c7..b95d370e5 100644 --- a/src/autobahn/websocket/compress_snappy.py +++ b/src/autobahn/websocket/compress_snappy.py @@ -26,6 +26,7 @@ import snappy +from autobahn.exception import PayloadExceededError from autobahn.websocket.compress_base import ( PerMessageCompress, PerMessageCompressOffer, @@ -478,8 +479,19 @@ def start_decompress_message(self): if self._decompressor is None or self.server_no_context_takeover: self._decompressor = snappy.StreamDecompressor() - def decompress_message_data(self, data): - return self._decompressor.decompress(data) + def decompress_message_data(self, data, max_output_len=None): + # python-snappy's StreamDecompressor has no output-length argument, so + # the frame is decompressed in full (bounded on the wire by + # maxFramePayloadSize) and then checked. This is a weaker, + # per-frame-granular guarantee than the incremental cap deflate/bzip2 + # provide, but it still rejects an over-budget message cleanly. + data = self._decompressor.decompress(data) + if max_output_len is not None and len(data) > max_output_len: + raise PayloadExceededError( + "WebSocket message exceeds decompression limit of " + f"{max_output_len} octets" + ) + return data def end_decompress_message(self): pass diff --git a/src/autobahn/websocket/protocol.py b/src/autobahn/websocket/protocol.py index dd86156af..5d8718e3a 100755 --- a/src/autobahn/websocket/protocol.py +++ b/src/autobahn/websocket/protocol.py @@ -1857,8 +1857,36 @@ def onFrameData(self, payload: bytes) -> bool | None: octets=_LazyHexFormatter(payload), ) - # XXX oberstet - payload = self._perMessageCompress.decompress_message_data(payload) + # Bound inflation by the remaining uncompressed message budget. + # onMessageFrameBegin() already added this frame's COMPRESSED + # length to message_data_total_length, so subtracting it back + # out yields the uncompressed total of the preceding frames; the + # remainder up to maxMessagePayloadSize is what this frame may + # inflate to. Passing it as max_output_len lets backends with an + # incremental cap (deflate, bzip2) stop inflating at the limit + # instead of expanding the whole frame into memory first. A + # PayloadExceededError means the message exceeds the limit; the + # post-inflation check below is the backstop for backends that + # can only bound per-frame (snappy, brotli). + if self.maxMessagePayloadSize > 0: + max_output_len = self.maxMessagePayloadSize - ( + self.message_data_total_length - compressedLen + ) + else: + max_output_len = None + try: + payload = self._perMessageCompress.decompress_message_data( + payload, max_output_len=max_output_len + ) + except PayloadExceededError: + if not self.failedByMe: + self.wasMaxMessagePayloadSizeExceeded = True + self._max_message_size_exceeded( + self.maxMessagePayloadSize, + self.maxMessagePayloadSize, + f"received WebSocket message exceeds payload limit of {self.maxMessagePayloadSize} octets after decompression", + ) + return False uncompressedLen = len(payload) else: l = len(payload) diff --git a/src/autobahn/websocket/test/test_websocket_compress.py b/src/autobahn/websocket/test/test_websocket_compress.py new file mode 100644 index 000000000..f3fff9866 --- /dev/null +++ b/src/autobahn/websocket/test/test_websocket_compress.py @@ -0,0 +1,257 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) typedef int GmbH +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +############################################################################### + +# Backend-neutral: this module is NOT gated on USE_TWISTED, so it runs under +# both the Twisted (trial) and asyncio (pytest) coverage phases. The +# permessage-compress backends are shared code, so bounded-decompression must +# behave identically regardless of the networking backend. + +import unittest +import zlib + +from autobahn.exception import PayloadExceededError +from autobahn.websocket.compress_deflate import PerMessageDeflate + + +def _make_compressor_pair(name): + """ + Build a (client_compressor, server_decompressor) pair for the named + permessage-compress extension, or return None if its optional dependency + is not installed. The server decompressor is created WITHOUT any + extension-level max_message_size, so the bounded-decompress behaviour under + test is driven purely by the ``max_output_len`` argument. + + Concrete per-codec imports (rather than the union-typed extension registry) + keep the constructor call sites resolvable for the static type checker. + """ + if name == "permessage-deflate": + return ( + PerMessageDeflate(False, False, False, 15, 15, 8), + PerMessageDeflate(True, False, False, 15, 15, 8), + ) + if name == "permessage-bzip2": + try: + from autobahn.websocket.compress_bzip2 import PerMessageBzip2 + except ImportError: + return None + return (PerMessageBzip2(False, 9, 9), PerMessageBzip2(True, 9, 9)) + if name == "permessage-snappy": + try: + from autobahn.websocket.compress_snappy import PerMessageSnappy + except ImportError: + return None + return ( + PerMessageSnappy(False, False, False), + PerMessageSnappy(True, False, False), + ) + if name == "permessage-brotli": + try: + from autobahn.websocket.compress_brotli import PerMessageBrotli + except ImportError: + return None + return ( + PerMessageBrotli(False, False, False), + PerMessageBrotli(True, False, False), + ) + return None + + +# The permessage-compress extensions to exercise; unavailable ones (optional +# dependency not installed) are skipped per-subtest. +_CODECS = [ + "permessage-deflate", + "permessage-bzip2", + "permessage-snappy", + "permessage-brotli", +] + + +class BoundedDecompressMaxOutputLenTests(unittest.TestCase): + """ + ``decompress_message_data(data, max_output_len=N)`` must bound decompressed + output: a message that inflates beyond ``N`` is rejected cleanly with + ``PayloadExceededError`` (never silently truncated), an under-budget message + round-trips byte-exact, and no ``max_output_len`` (the default) keeps the + unbounded behaviour. This must hold for every compression backend. + """ + + BIG = b"x" * 4096 # inflates well beyond the small budget, compresses tiny + SMALL = b"y" * 64 + + @staticmethod + def _compress(compressor, payload): + compressor.start_compress_message() + body = compressor.compress_message_data(payload) + body += compressor.end_compress_message() + return body + + def test_bounded_rejects_oversized(self): + for codec in _CODECS: + with self.subTest(codec=codec): + pair = _make_compressor_pair(codec) + if pair is None: + continue + compressor, decompressor = pair + body = self._compress(compressor, self.BIG) + decompressor.start_decompress_message() + self.assertRaises( + PayloadExceededError, + decompressor.decompress_message_data, + body, + max_output_len=64, + ) + + def test_bounded_under_limit_roundtrips(self): + for codec in _CODECS: + with self.subTest(codec=codec): + pair = _make_compressor_pair(codec) + if pair is None: + continue + compressor, decompressor = pair + body = self._compress(compressor, self.SMALL) + decompressor.start_decompress_message() + out = decompressor.decompress_message_data( + body, max_output_len=4096 + ) + self.assertEqual(out, self.SMALL) + decompressor.end_decompress_message() + + def test_bounded_boundary_ok(self): + # Inflated size exactly equal to max_output_len is accepted in full: + # the limit is "strictly greater", consistent across all backends. + payload = b"z" * 256 + for codec in _CODECS: + with self.subTest(codec=codec): + pair = _make_compressor_pair(codec) + if pair is None: + continue + compressor, decompressor = pair + body = self._compress(compressor, payload) + decompressor.start_decompress_message() + out = decompressor.decompress_message_data( + body, max_output_len=len(payload) + ) + self.assertEqual(out, payload) + decompressor.end_decompress_message() + + def test_unbounded_default_roundtrips(self): + for codec in _CODECS: + with self.subTest(codec=codec): + pair = _make_compressor_pair(codec) + if pair is None: + continue + compressor, decompressor = pair + body = self._compress(compressor, self.BIG) + decompressor.start_decompress_message() + out = decompressor.decompress_message_data(body) + self.assertEqual(out, self.BIG) + decompressor.end_decompress_message() + + +class PerMessageDeflateMaxMessageSizeTests(unittest.TestCase): + """ + The permessage-deflate extension-level ``max_message_size`` cap (relocated + here from test_websocket_frame.py so it runs under both backends). This is + the deflate-negotiated whole-message cap; ``max_output_len`` above is the + per-call cap the protocol layer passes. + """ + + @staticmethod + def _decoder(max_message_size=None): + return PerMessageDeflate( + is_server=False, + server_no_context_takeover=False, + client_no_context_takeover=False, + server_max_window_bits=15, + client_max_window_bits=15, + mem_level=8, + max_message_size=max_message_size, + ) + + @staticmethod + def _compress(payload, window_bits=15): + # Produce the permessage-deflate wire body for `payload`: raw + # DEFLATE, Z_SYNC_FLUSH, with the trailing 0x00 0x00 0xff 0xff + # stripped (mirrors PerMessageDeflate.end_compress_message()). + compressor = zlib.compressobj( + zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -window_bits + ) + return ( + compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH) + )[:-4] + + def test_max_size_rejects_oversized(self): + # A message that inflates beyond max_message_size must be rejected + # cleanly (PayloadExceededError), NOT silently truncated to the cap. + decoder = self._decoder(max_message_size=10) + body = self._compress(b"x" * 2000) + decoder.start_decompress_message() + self.assertRaises( + PayloadExceededError, decoder.decompress_message_data, body + ) + + def test_max_size_boundary_ok(self): + # A message whose inflated size is exactly max_message_size is + # accepted and returned in full (the limit is "strictly greater"). + decoder = self._decoder(max_message_size=2000) + body = self._compress(b"x" * 2000) + decoder.start_decompress_message() + data = decoder.decompress_message_data(body) + self.assertEqual(data, b"x" * 2000) + + def test_under_max_size_roundtrips_without_corruption(self): + # Under the cap: full data returned AND end_decompress_message() + # must not raise. The old truncation left the stream mid-token, + # so the sync-flush trailer raised zlib error -3. + decoder = self._decoder(max_message_size=2000) + payload = b"x" * 1000 + body = self._compress(payload) + decoder.start_decompress_message() + data = decoder.decompress_message_data(body) + self.assertEqual(data, payload) + decoder.end_decompress_message() + + def test_max_size_cumulative_across_frames(self): + # The cap bounds the whole message, not each frame: a message + # delivered in two frame-sized chunks whose combined inflated size + # exceeds the cap must be rejected, not truncated. + decoder = self._decoder(max_message_size=1500) + body = self._compress(b"x" * 2000) + half = len(body) // 2 + decoder.start_decompress_message() + + def feed_both(): + decoder.decompress_message_data(body[:half]) + decoder.decompress_message_data(body[half:]) + + self.assertRaises(PayloadExceededError, feed_both) + + def test_no_max_size(self): + decoder = self._decoder(max_message_size=None) + body = self._compress(b"x" * 2000) + decoder.start_decompress_message() + data = decoder.decompress_message_data(body) + self.assertEqual(data, b"x" * 2000) diff --git a/src/autobahn/websocket/test/test_websocket_frame.py b/src/autobahn/websocket/test/test_websocket_frame.py index a1f829be5..52ef291cb 100644 --- a/src/autobahn/websocket/test/test_websocket_frame.py +++ b/src/autobahn/websocket/test/test_websocket_frame.py @@ -26,7 +26,6 @@ import os import struct -import zlib if os.environ.get("USE_TWISTED", False): from base64 import b64decode @@ -41,8 +40,6 @@ WebSocketServerFactory, WebSocketServerProtocol, ) - from autobahn.exception import PayloadExceededError - from autobahn.websocket.compress_deflate import PerMessageDeflate from twisted.internet.address import IPv4Address from twisted.internet.task import Clock from twisted.trial import unittest @@ -85,84 +82,6 @@ def collect(d, *args): mock_handshake_server = b'HTTP/1.1 101 Switching Protocols\r\nServer: AutobahnPython/0.10.2\r\nX-Powered-By: AutobahnPython/0.10.2\r\nUpgrade: WebSocket\r\nConnection: Upgrade\r\nSec-WebSocket-Protocol: wamp.2.json\r\nSec-WebSocket-Accept: QIatSt9QkZPyS4QQfdufO8TgkL0=\r\n\r\n\x81~\x02\x19[1,"crossbar",{"roles":{"subscriber":{"features":{"publisher_identification":true,"pattern_based_subscription":true,"subscription_revocation":true}},"publisher":{"features":{"publisher_identification":true,"publisher_exclusion":true,"subscriber_blackwhite_listing":true}},"caller":{"features":{"caller_identification":true,"progressive_call_results":true}},"callee":{"features":{"progressive_call_results":true,"pattern_based_registration":true,"registration_revocation":true,"shared_registration":true,"caller_identification":true}}}}]\x18' - class TestDeflate(unittest.TestCase): - @staticmethod - def _decoder(max_message_size=None): - return PerMessageDeflate( - is_server=False, - server_no_context_takeover=False, - client_no_context_takeover=False, - server_max_window_bits=15, - client_max_window_bits=15, - mem_level=8, - max_message_size=max_message_size, - ) - - @staticmethod - def _compress(payload, window_bits=15): - # Produce the permessage-deflate wire body for `payload`: raw - # DEFLATE, Z_SYNC_FLUSH, with the trailing 0x00 0x00 0xff 0xff - # stripped (mirrors PerMessageDeflate.end_compress_message()). - compressor = zlib.compressobj( - zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -window_bits - ) - return ( - compressor.compress(payload) + compressor.flush(zlib.Z_SYNC_FLUSH) - )[:-4] - - def test_max_size_rejects_oversized(self): - # A message that inflates beyond max_message_size must be rejected - # cleanly (PayloadExceededError), NOT silently truncated to the cap. - decoder = self._decoder(max_message_size=10) - body = self._compress(b"x" * 2000) - decoder.start_decompress_message() - self.assertRaises( - PayloadExceededError, decoder.decompress_message_data, body - ) - - def test_max_size_boundary_ok(self): - # A message whose inflated size is exactly max_message_size is - # accepted and returned in full (the limit is "strictly greater"). - decoder = self._decoder(max_message_size=2000) - body = self._compress(b"x" * 2000) - decoder.start_decompress_message() - data = decoder.decompress_message_data(body) - self.assertEqual(data, b"x" * 2000) - - def test_under_max_size_roundtrips_without_corruption(self): - # Under the cap: full data returned AND end_decompress_message() - # must not raise. The old truncation left the stream mid-token, - # so the sync-flush trailer raised zlib error -3. - decoder = self._decoder(max_message_size=2000) - payload = b"x" * 1000 - body = self._compress(payload) - decoder.start_decompress_message() - data = decoder.decompress_message_data(body) - self.assertEqual(data, payload) - decoder.end_decompress_message() - - def test_max_size_cumulative_across_frames(self): - # The cap bounds the whole message, not each frame: a message - # delivered in two frame-sized chunks whose combined inflated size - # exceeds the cap must be rejected, not truncated. - decoder = self._decoder(max_message_size=1500) - body = self._compress(b"x" * 2000) - half = len(body) // 2 - decoder.start_decompress_message() - - def feed_both(): - decoder.decompress_message_data(body[:half]) - decoder.decompress_message_data(body[half:]) - - self.assertRaises(PayloadExceededError, feed_both) - - def test_no_max_size(self): - decoder = self._decoder(max_message_size=None) - body = self._compress(b"x" * 2000) - decoder.start_decompress_message() - data = decoder.decompress_message_data(body) - self.assertEqual(data, b"x" * 2000) - class TestClient(unittest.TestCase): def setUp(self): self.factory = WebSocketClientFactory(protocols=["wamp.2.json"])