Skip to content

Commit 7d9baba

Browse files
authored
Fix 1911 (#1922)
* start new dev branch; add audit file * Add failing tests: asyncio RawSocket receive limit must be configurable (#1911) The asyncio RawSocket receive size limit is hardwired to 16 MB: the dead max_size=None branch in RawSocketProtocol.__init__ always yields length exponent 15, and there is no asyncio equivalent of the Twisted factory's setProtocolOptions(maxMessagePayloadSize=...). So an asyncio WAMP peer cannot tighten its receive cap for DoS hardening, and Crossbar's rawsocket max_message_size has no effect on the asyncio path - a both-backend parity gap. - test_aio_rawsocket.py: parametrized tests (RECV_LIMIT_CASES) asserting that after setProtocolOptions(maxMessagePayloadSize=N) the server handshake advertises the configured length exponent and enforces the matching receive cap (N rounded up to the next power of two), plus a test that a frame declaring more octets than the configured cap (but under 16 MB) is rejected. - test_tx_rawsocket.py: the same table asserted against the Twisted backend (already configurable) as the parity contract - the two backends cannot be imported into one process (autobahn.twisted forces txaio.use_twisted), so parity is pinned by both matching the same formula. To make the asyncio red behavioural rather than an AttributeError, setProtocolOptions/resetProtocolOptions are added to the asyncio factory but not yet applied to the protocol (__call__ ignores the configured value), so the new tests fail because the cap stays hardwired at 16 MB. Enforcement lands next. Note: This work was completed with AI assistance (Claude Code). * Make the asyncio RawSocket receive limit configurable (#1911) Activate the configuration surface added in the previous commit so setProtocolOptions(maxMessagePayloadSize=...) actually reaches the protocol, reaching parity with the Twisted backend. - WampRawSocketFactory.__call__ now pushes the factory's _max_message_size onto the protocol via _set_max_message_size(). - RawSocketProtocol grows _set_max_message_size(max_size), which rounds the configured max up to the next power of two and derives the advertised handshake length exponent (2 ** (9 + exp)) and enforced receive cap, the same formula the Twisted backend uses. The dead max_size=None branch in __init__ is removed; __init__ keeps the 16 MB default for a protocol built without a factory. An asyncio WAMP peer can now tighten its RawSocket receive cap for DoS hardening, and Crossbar's RawSocket max_message_size takes effect on the asyncio path. Makes the tests added in the previous commit pass; the Twisted parity test pins both backends to the same accept/reject decisions. Note: This work was completed with AI assistance (Claude Code).
1 parent 583b534 commit 7d9baba

5 files changed

Lines changed: 151 additions & 10 deletions

File tree

.audit/oberstet_fix_1911.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- [ ] I did **not** use any AI-assistance tools to help create this pull request.
2+
- [x] I **did** use AI-assistance tools to *help* create this pull request.
3+
- [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.
4+
5+
Submitted by: @oberstet
6+
Date: 2026-07-14
7+
Related issue(s): #1911
8+
Branch: oberstet:fix_1911

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Changelog
1313
* 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)
1414
* 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)
1515
* 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)
16+
* 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)
1617

1718
**FlatBuffers**
1819

src/autobahn/asyncio/rawsocket.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -177,16 +177,22 @@ def stringReceived(self, data):
177177

178178
class RawSocketProtocol(PrefixProtocol):
179179
def __init__(self):
180-
max_size = None
181-
if max_size:
182-
exp = int(math.ceil(math.log(max_size, 2))) - 9
183-
if exp > 15:
184-
raise ValueError("Maximum length is 16M")
185-
self.max_length = 2 ** (exp + 9)
186-
self._length_exp = exp
187-
else:
188-
self._length_exp = 15
189-
self.max_length = 2**24
180+
# Default receive cap: 16 MB (length exponent 15). The factory overrides
181+
# this from setProtocolOptions(maxMessagePayloadSize=...) via
182+
# _set_max_message_size() when the protocol is built.
183+
self._length_exp = 15
184+
self.max_length = 2**24
185+
186+
def _set_max_message_size(self, max_size):
187+
# Round the configured max up to the next power of two and derive the
188+
# advertised handshake length exponent (the peer is asked to send
189+
# messages of at most 2 ** (9 + exp) octets), mirroring the Twisted
190+
# backend so both enforce and advertise the same receive cap.
191+
exp = int(math.ceil(math.log(max_size, 2))) - 9
192+
if exp < 0 or exp > 15:
193+
raise ValueError("maxMessagePayloadSize must be in [512, 2 ** 24]")
194+
self._length_exp = exp
195+
self.max_length = 2 ** (exp + 9)
190196

191197
def connection_made(self, transport):
192198
PrefixProtocol.connection_made(self, transport)
@@ -477,10 +483,45 @@ class WampRawSocketFactory:
477483

478484
log = txaio.make_logger()
479485

486+
# RawSocket max payload size is 16M
487+
# (https://wamp-proto.org/_static/gen/wamp_latest_ietf.html#handshake)
488+
_max_message_size = 2**24
489+
490+
def resetProtocolOptions(self):
491+
self._max_message_size = 2**24
492+
493+
def setProtocolOptions(self, maxMessagePayloadSize=None):
494+
"""
495+
Set RawSocket protocol options. Mirrors the Twisted RawSocket factory so
496+
the same ``maxMessagePayloadSize`` knob configures the receive size limit
497+
on both backends.
498+
499+
:param maxMessagePayloadSize: Maximum length (in octets) of a received
500+
RawSocket message, in ``[512, 2**24]``; rounded up to the next power
501+
of two for the advertised handshake length exponent. ``None`` leaves
502+
the current value unchanged (default ``2**24`` = 16 MB).
503+
"""
504+
self.log.debug(
505+
"{klass}.setProtocolOptions(maxMessagePayloadSize={maxMessagePayloadSize})",
506+
klass=self.__class__.__name__,
507+
maxMessagePayloadSize=maxMessagePayloadSize,
508+
)
509+
assert maxMessagePayloadSize is None or (
510+
isinstance(maxMessagePayloadSize, int)
511+
and maxMessagePayloadSize >= 512
512+
and maxMessagePayloadSize <= 2**24
513+
)
514+
if (
515+
maxMessagePayloadSize is not None
516+
and maxMessagePayloadSize != self._max_message_size
517+
):
518+
self._max_message_size = maxMessagePayloadSize
519+
480520
@public
481521
def __call__(self):
482522
proto = self.protocol()
483523
proto.factory = self
524+
proto._set_max_message_size(self._max_message_size)
484525
return proto
485526

486527

src/autobahn/asyncio/test/test_aio_rawsocket.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,62 @@ def test_wamp_client_bad_magic_byte_aborts_cleanly():
309309

310310
transport.close.assert_called_once_with()
311311
client.onOpen.assert_not_called()
312+
313+
314+
# ---------------------------------------------------------------------------
315+
# Issue #1911: the asyncio RawSocket receive size limit must be configurable via
316+
# the factory's setProtocolOptions(maxMessagePayloadSize=...), at parity with the
317+
# Twisted backend. Both round the configured max up to the next power of two for
318+
# the advertised handshake length exponent and the enforced receive cap. Parity
319+
# is asserted against the same formula in the Twisted suite
320+
# (test_tx_rawsocket.py), because the two backends cannot be imported into one
321+
# process (autobahn.twisted forces txaio.use_twisted).
322+
323+
# (maxMessagePayloadSize, advertised length exponent, enforced receive cap)
324+
RECV_LIMIT_CASES = [
325+
(512, 0, 512),
326+
(1000, 1, 1024), # rounded up to the next power of two
327+
(1024, 1, 1024),
328+
(4096, 3, 4096),
329+
(2**20, 11, 2**20),
330+
(2**24, 15, 2**24),
331+
]
332+
333+
334+
def _make_configured_server(max_size):
335+
transport = Mock(spec_set=("abort", "close", "write", "get_extra_info"))
336+
messages = []
337+
transport.write = Mock(side_effect=lambda m: messages.append(m))
338+
session = Mock(spec=["onOpen", "onMessage"])
339+
factory = WampRawSocketServerFactory(lambda: session)
340+
factory.setProtocolOptions(maxMessagePayloadSize=max_size)
341+
proto = factory()
342+
proto.connection_made(transport)
343+
ser_id = sorted(proto.factory._serializers.keys())[0]
344+
# client opening handshake: magic, (length-exp 15 | serializer id), 0, 0
345+
proto.data_received(bytes(bytearray([0x7F, 0xF0 | ser_id, 0, 0])))
346+
return proto, transport, messages
347+
348+
349+
@pytest.mark.skipif(
350+
not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only"
351+
)
352+
@pytest.mark.parametrize("max_size,exp,cap", RECV_LIMIT_CASES)
353+
def test_server_receive_limit_advertised_and_enforced(max_size, exp, cap):
354+
proto, transport, messages = _make_configured_server(max_size)
355+
# the server handshake reply advertises the configured length exponent
356+
reply = messages[0]
357+
assert reply[1] >> 4 == exp
358+
# and the enforced receive cap reflects the configured max
359+
assert proto.max_length == cap
360+
361+
362+
@pytest.mark.skipif(
363+
not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only"
364+
)
365+
def test_server_receive_limit_rejects_oversized_frame():
366+
# configure a 1024-octet receive cap; a frame declaring 2000 octets (over the
367+
# configured cap but under the hardwired 16 MB default) must be rejected.
368+
proto, transport, messages = _make_configured_server(1024)
369+
proto.data_received(b"\x00" + (2000).to_bytes(3, "big"))
370+
assert transport.close.called

src/autobahn/twisted/test/test_tx_rawsocket.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,38 @@ def test_handshake_succeeds(self):
7070
session_mock.onOpen.assert_called_once_with(p)
7171
server_session_mock.onOpen.assert_called_once_with(sp)
7272

73+
def test_receive_limit_advertised_and_enforced(self):
74+
"""
75+
``setProtocolOptions(maxMessagePayloadSize=...)`` configures the server's
76+
received-message size cap: the advertised handshake length exponent and
77+
the enforced ``MAX_LENGTH`` are the configured max rounded up to the next
78+
power of two. This mirrors the asyncio backend (see
79+
``test_aio_rawsocket.RECV_LIMIT_CASES``) so both backends make identical
80+
accept/reject decisions for the same configuration (#1911).
81+
"""
82+
# (maxMessagePayloadSize, advertised length exponent, enforced cap)
83+
cases = [
84+
(512, 0, 512),
85+
(1000, 1, 1024), # rounded up to the next power of two
86+
(1024, 1, 1024),
87+
(4096, 3, 4096),
88+
(2**20, 11, 2**20),
89+
(2**24, 15, 2**24),
90+
]
91+
for max_size, exp, cap in cases:
92+
with self.subTest(max_size=max_size):
93+
sf = WampRawSocketServerFactory(lambda: Mock())
94+
sf.setProtocolOptions(maxMessagePayloadSize=max_size)
95+
sp = sf.buildProtocol(None)
96+
sp.transport = FakeTransport()
97+
sp.connectionMade()
98+
ser_id = sorted(sf._serializers.keys())[0]
99+
# client opening handshake: magic, (length-exp 15 | serializer)
100+
sp.dataReceived(bytes([0x7F, 0xF0 | ser_id, 0, 0]))
101+
written = sp.transport._written
102+
self.assertEqual(written[1] >> 4, exp)
103+
self.assertEqual(sp.MAX_LENGTH, cap)
104+
73105
def test_server_bad_magic_byte_aborts_cleanly(self):
74106
"""
75107
A server receiving an invalid magic byte in the opening handshake

0 commit comments

Comments
 (0)