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_1910.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): #1910
Branch: oberstet:fix_1910
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
18 changes: 18 additions & 0 deletions src/autobahn/websocket/compress_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
16 changes: 14 additions & 2 deletions src/autobahn/websocket/compress_brotli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
except ImportError:
import brotlicffi as brotli

from autobahn.exception import PayloadExceededError
from autobahn.websocket.compress_base import (
PerMessageCompress,
PerMessageCompressOffer,
Expand Down Expand Up @@ -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
19 changes: 17 additions & 2 deletions src/autobahn/websocket/compress_bzip2.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import bz2

from autobahn.exception import PayloadExceededError
from autobahn.websocket.compress_base import (
PerMessageCompress,
PerMessageCompressOffer,
Expand Down Expand Up @@ -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
30 changes: 20 additions & 10 deletions src/autobahn/websocket/compress_deflate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions src/autobahn/websocket/compress_snappy.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import snappy

from autobahn.exception import PayloadExceededError
from autobahn.websocket.compress_base import (
PerMessageCompress,
PerMessageCompressOffer,
Expand Down Expand Up @@ -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
32 changes: 30 additions & 2 deletions src/autobahn/websocket/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading