Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .audit/oberstet_fix_1911.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
61 changes: 51 additions & 10 deletions src/autobahn/asyncio/rawsocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
59 changes: 59 additions & 0 deletions src/autobahn/asyncio/test/test_aio_rawsocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions src/autobahn/twisted/test/test_tx_rawsocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading