diff --git a/.audit/oberstet_fix_1911.md b/.audit/oberstet_fix_1911.md new file mode 100644 index 000000000..1cd6cf21b --- /dev/null +++ b/.audit/oberstet_fix_1911.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): #1911 +Branch: oberstet:fix_1911 diff --git a/docs/changelog.rst b/docs/changelog.rst index 540f5ab23..112aaf991 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,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) +* Make the asyncio RawSocket receive size limit configurable, at parity with the Twisted backend. The asyncio ``WampRawSocketFactory`` now exposes ``setProtocolOptions(maxMessagePayloadSize=...)`` / ``resetProtocolOptions()`` (bounds ``[512, 2**24]``, default 16 MB), and the configured value drives both the advertised handshake length exponent and the enforced receive cap (rounded up to the next power of two), matching the Twisted factory. Previously the asyncio receive limit was hardwired to 16 MB (a dead ``max_size=None`` branch), so an asyncio WAMP peer could not tighten its RawSocket receive limit for DoS hardening and Crossbar's RawSocket ``max_message_size`` had no effect on the asyncio path (#1911) **FlatBuffers** diff --git a/src/autobahn/asyncio/rawsocket.py b/src/autobahn/asyncio/rawsocket.py index 97eb57ba2..5e46f13ef 100644 --- a/src/autobahn/asyncio/rawsocket.py +++ b/src/autobahn/asyncio/rawsocket.py @@ -177,16 +177,22 @@ def stringReceived(self, data): class RawSocketProtocol(PrefixProtocol): def __init__(self): - max_size = None - if max_size: - exp = int(math.ceil(math.log(max_size, 2))) - 9 - if exp > 15: - raise ValueError("Maximum length is 16M") - self.max_length = 2 ** (exp + 9) - self._length_exp = exp - else: - self._length_exp = 15 - self.max_length = 2**24 + # Default receive cap: 16 MB (length exponent 15). The factory overrides + # this from setProtocolOptions(maxMessagePayloadSize=...) via + # _set_max_message_size() when the protocol is built. + self._length_exp = 15 + self.max_length = 2**24 + + def _set_max_message_size(self, max_size): + # Round the configured max up to the next power of two and derive the + # advertised handshake length exponent (the peer is asked to send + # messages of at most 2 ** (9 + exp) octets), mirroring the Twisted + # backend so both enforce and advertise the same receive cap. + exp = int(math.ceil(math.log(max_size, 2))) - 9 + if exp < 0 or exp > 15: + raise ValueError("maxMessagePayloadSize must be in [512, 2 ** 24]") + self._length_exp = exp + self.max_length = 2 ** (exp + 9) def connection_made(self, transport): PrefixProtocol.connection_made(self, transport) @@ -477,10 +483,45 @@ class WampRawSocketFactory: log = txaio.make_logger() + # RawSocket max payload size is 16M + # (https://wamp-proto.org/_static/gen/wamp_latest_ietf.html#handshake) + _max_message_size = 2**24 + + def resetProtocolOptions(self): + self._max_message_size = 2**24 + + def setProtocolOptions(self, maxMessagePayloadSize=None): + """ + Set RawSocket protocol options. Mirrors the Twisted RawSocket factory so + the same ``maxMessagePayloadSize`` knob configures the receive size limit + on both backends. + + :param maxMessagePayloadSize: Maximum length (in octets) of a received + RawSocket message, in ``[512, 2**24]``; rounded up to the next power + of two for the advertised handshake length exponent. ``None`` leaves + the current value unchanged (default ``2**24`` = 16 MB). + """ + self.log.debug( + "{klass}.setProtocolOptions(maxMessagePayloadSize={maxMessagePayloadSize})", + klass=self.__class__.__name__, + maxMessagePayloadSize=maxMessagePayloadSize, + ) + assert maxMessagePayloadSize is None or ( + isinstance(maxMessagePayloadSize, int) + and maxMessagePayloadSize >= 512 + and maxMessagePayloadSize <= 2**24 + ) + if ( + maxMessagePayloadSize is not None + and maxMessagePayloadSize != self._max_message_size + ): + self._max_message_size = maxMessagePayloadSize + @public def __call__(self): proto = self.protocol() proto.factory = self + proto._set_max_message_size(self._max_message_size) return proto diff --git a/src/autobahn/asyncio/test/test_aio_rawsocket.py b/src/autobahn/asyncio/test/test_aio_rawsocket.py index 618e94b22..394a75a4f 100644 --- a/src/autobahn/asyncio/test/test_aio_rawsocket.py +++ b/src/autobahn/asyncio/test/test_aio_rawsocket.py @@ -309,3 +309,62 @@ def test_wamp_client_bad_magic_byte_aborts_cleanly(): transport.close.assert_called_once_with() client.onOpen.assert_not_called() + + +# --------------------------------------------------------------------------- +# Issue #1911: the asyncio RawSocket receive size limit must be configurable via +# the factory's setProtocolOptions(maxMessagePayloadSize=...), at parity with the +# Twisted backend. Both round the configured max up to the next power of two for +# the advertised handshake length exponent and the enforced receive cap. Parity +# is asserted against the same formula in the Twisted suite +# (test_tx_rawsocket.py), because the two backends cannot be imported into one +# process (autobahn.twisted forces txaio.use_twisted). + +# (maxMessagePayloadSize, advertised length exponent, enforced receive cap) +RECV_LIMIT_CASES = [ + (512, 0, 512), + (1000, 1, 1024), # rounded up to the next power of two + (1024, 1, 1024), + (4096, 3, 4096), + (2**20, 11, 2**20), + (2**24, 15, 2**24), +] + + +def _make_configured_server(max_size): + transport = Mock(spec_set=("abort", "close", "write", "get_extra_info")) + messages = [] + transport.write = Mock(side_effect=lambda m: messages.append(m)) + session = Mock(spec=["onOpen", "onMessage"]) + factory = WampRawSocketServerFactory(lambda: session) + factory.setProtocolOptions(maxMessagePayloadSize=max_size) + proto = factory() + proto.connection_made(transport) + ser_id = sorted(proto.factory._serializers.keys())[0] + # client opening handshake: magic, (length-exp 15 | serializer id), 0, 0 + proto.data_received(bytes(bytearray([0x7F, 0xF0 | ser_id, 0, 0]))) + return proto, transport, messages + + +@pytest.mark.skipif( + not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only" +) +@pytest.mark.parametrize("max_size,exp,cap", RECV_LIMIT_CASES) +def test_server_receive_limit_advertised_and_enforced(max_size, exp, cap): + proto, transport, messages = _make_configured_server(max_size) + # the server handshake reply advertises the configured length exponent + reply = messages[0] + assert reply[1] >> 4 == exp + # and the enforced receive cap reflects the configured max + assert proto.max_length == cap + + +@pytest.mark.skipif( + not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only" +) +def test_server_receive_limit_rejects_oversized_frame(): + # configure a 1024-octet receive cap; a frame declaring 2000 octets (over the + # configured cap but under the hardwired 16 MB default) must be rejected. + proto, transport, messages = _make_configured_server(1024) + proto.data_received(b"\x00" + (2000).to_bytes(3, "big")) + assert transport.close.called diff --git a/src/autobahn/twisted/test/test_tx_rawsocket.py b/src/autobahn/twisted/test/test_tx_rawsocket.py index 8577a8cf1..ce833b38c 100644 --- a/src/autobahn/twisted/test/test_tx_rawsocket.py +++ b/src/autobahn/twisted/test/test_tx_rawsocket.py @@ -70,6 +70,38 @@ def test_handshake_succeeds(self): session_mock.onOpen.assert_called_once_with(p) server_session_mock.onOpen.assert_called_once_with(sp) + def test_receive_limit_advertised_and_enforced(self): + """ + ``setProtocolOptions(maxMessagePayloadSize=...)`` configures the server's + received-message size cap: the advertised handshake length exponent and + the enforced ``MAX_LENGTH`` are the configured max rounded up to the next + power of two. This mirrors the asyncio backend (see + ``test_aio_rawsocket.RECV_LIMIT_CASES``) so both backends make identical + accept/reject decisions for the same configuration (#1911). + """ + # (maxMessagePayloadSize, advertised length exponent, enforced cap) + cases = [ + (512, 0, 512), + (1000, 1, 1024), # rounded up to the next power of two + (1024, 1, 1024), + (4096, 3, 4096), + (2**20, 11, 2**20), + (2**24, 15, 2**24), + ] + for max_size, exp, cap in cases: + with self.subTest(max_size=max_size): + sf = WampRawSocketServerFactory(lambda: Mock()) + sf.setProtocolOptions(maxMessagePayloadSize=max_size) + sp = sf.buildProtocol(None) + sp.transport = FakeTransport() + sp.connectionMade() + ser_id = sorted(sf._serializers.keys())[0] + # client opening handshake: magic, (length-exp 15 | serializer) + sp.dataReceived(bytes([0x7F, 0xF0 | ser_id, 0, 0])) + written = sp.transport._written + self.assertEqual(written[1] >> 4, exp) + self.assertEqual(sp.MAX_LENGTH, cap) + def test_server_bad_magic_byte_aborts_cleanly(self): """ A server receiving an invalid magic byte in the opening handshake