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_1986.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 project's AI_POLICY.md when creating code, documentation etc. for this pull request.

Submitted by: @oberstet
Date: 2026-10-17
Related issue(s): #1986
Branch: oberstet:fix_1986
72 changes: 72 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,78 @@ jobs:
- name: Unit test - asyncio / pytest - WITHOUT NVX
run: just test-asyncio cpy314 0

# The unit test suites on every supported Python run-time, with all four
# WebSocket compression extensions installed (#1986). quality-checks above
# additionally runs them on cpy314 with and without NVX, plus coverage.
unit-tests:
name: Unit Tests
needs: identifiers
runs-on: ubuntu-24.04

env:
BASE_REPO: ${{ needs.identifiers.outputs.base_repo }}
BASE_BRANCH: ${{ needs.identifiers.outputs.base_branch }}
PR_NUMBER: ${{ needs.identifiers.outputs.pr_number }}
PR_REPO: ${{ needs.identifiers.outputs.pr_repo }}
PR_BRANCH: ${{ needs.identifiers.outputs.pr_branch }}

strategy:
fail-fast: false
matrix:
python-env: [cpy314, cpy313, cpy312, cpy311, pypy311]

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive

- name: Install Just
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ~/bin
echo "$HOME/bin" >> $GITHUB_PATH

- name: Install uv
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.cargo/env
echo "$HOME/.cargo/bin" >> $GITHUB_PATH

- name: Verify toolchain installation
run: |
just --version
uv --version

- name: Setup uv cache
uses: actions/cache@v4
with:
path: ${{ env.UV_CACHE_DIR }}
key:
uv-cache-ubuntu-unit-${{ matrix.python-env }}-${{
hashFiles('pyproject.toml') }}
restore-keys: |
uv-cache-ubuntu-unit-${{ matrix.python-env }}-
uv-cache-ubuntu-unit-

- name: Create Python environment and install tools
run: |
just create ${{ matrix.python-env }}
just install-tools ${{ matrix.python-env }}

- name: Unit test - Twisted / trial
run: just test-twisted ${{ matrix.python-env }}

- name: Unit test - asyncio / pytest
run: just test-asyncio ${{ matrix.python-env }}

# Last: its "install" dependency replaces the editable install.
- name: All four WebSocket compression extensions available
run: just check-compressors ${{ matrix.python-env }}

test-serdes:
name: SerDes Conformance Tests
needs: identifiers
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@ Changelog
26.9.1
------

**WebSocket**

* Make all four per-message compression extensions (``permessage-deflate``, ``-bzip2``, ``-brotli``, ``-snappy``) enforce the decompression budget the same way: decompression stops near the remaining ``maxMessagePayloadSize`` budget instead of decompressing a whole frame first, and "over budget" as well as "budget already used up" raise ``PayloadExceededError``. brotli now bounds its output with ``Decompressor.process(output_buffer_limit=...)``, snappy is decompressed one framing chunk at a time, and deflate no longer lets one octet through once the budget is used up (#1986)
* Add ``autobahn.exception.PayloadCorruptError``: every compression extension now raises it for input it cannot decompress (corrupt, truncated, or not the extension's format), instead of the library's own exception (``zlib.error``, ``OSError``, ``brotli.error``, ...), and snappy no longer accepts such input silently. The protocol fails the connection with close code 1007 (invalid frame payload data), the same for every extension and on both Twisted and asyncio (#1986)
* Fix ``permessage-brotli`` failing on the second message of a connection when context takeover was negotiated (the default): the compressor and decompressor were reused after their brotli stream had finished. Each message is now a new brotli stream, which is the wire format brotli used for the first message already, and for no context takeover (#1986)
* Fix an asyncio WebSocket connection being left open, but no longer serviced, when an exception escaped the processing of received data: the connection is now dropped, as on Twisted (#1986)
* Add a documentation page on WebSocket compression (``docs/websocket/compression.rst``): for each of the four extensions, which Python package and which compression library (and bundled version) it uses on CPython and on PyPy, plus context takeover, decompression limits and the handling of invalid data. The old, unpublished page under ``docs/work/`` did not cover ``permessage-brotli`` (#1986)

**Build & CI/CD**

* Run the unit tests (Twisted/trial and asyncio/pytest) in CI on every supported Python: CPython 3.11–3.14 and PyPy 3.11, instead of on CPython 3.14 only, and with all four compression extensions installed (``python-snappy`` is added to the ``dev`` extra; ``just check-compressors`` verifies all four are available) (#1986)
* Adopt the contribution workflow shared by all WAMP projects: ``CONTRIBUTING.md``, the pull request template and ``.audit/README.md`` are now deployed byte-identically from wamp-cicd (GitHub issue first, red → green tests, AI-assistance disclosure) and kept in sync by a CI drift check; everything specific to Autobahn|Python moved to a new ``DEVELOPMENT.md`` (also included in the docs). ``.cicd`` is pinned to wamp-cicd ``38ded6a``, the same commit across the WAMP fleet (#1956)
* Add an ``import-smoke-lowest`` CI job (``just test-imports-lowest``) that runs the import smoke test on CPython 3.11 with every direct dependency resolved to the lowest version ``pyproject.toml`` allows (``uv --resolution lowest-direct``), so stale version floors are caught. The import smoke test now also covers the Twisted and asyncio bindings, each module imported in a fresh interpreter (#1955)

Expand All @@ -19,6 +28,7 @@ Changelog

**Dependencies**

* Raise the ``compress`` extra's floors to ``brotli>=1.2.0`` (CPython) and ``brotlicffi>=1.2.0`` (PyPy), the first versions with ``Decompressor.process(output_buffer_limit=...)`` (#1986)
* Fix ``import autobahn.twisted`` failing on CPython 3.11–3.13 with ``TypeError: unsupported operand type(s) for |: 'InterfaceClass' and 'InterfaceClass'`` when ``zope.interface`` < 6.2 is installed: ``autobahn.twisted.util`` and ``autobahn.twisted.wamp`` now use ``from __future__ import annotations``, and the ``twisted`` extra's ``zope.interface`` floor is raised from ``>=5.2.0`` to ``>=6.2``, the first release whose interfaces support ``|`` (#1955)
* Split the ``cbor2`` dependency by Python runtime: CPython uses the current ``>=6.1.0`` line (Rust/pyo3, binary wheels), while PyPy is pinned to ``cbor2==5.9.0`` — the final pure-Python release — deliberately keeping PyPy out of the native-extension matrix. This removes the ``cbor2`` Rust source-compile that was timing out the PyPy-aarch64 wheel build under QEMU emulation. The two implementations are verified wire-equivalent by a new cross-version CBOR conformance test (``examples/serdes/tests/test_cbor_cross_version.py``) that runs on both CPython and PyPy in CI and asserts each encodes/decodes the wamp-proto canonical CBOR vectors byte-for-byte identically. ``cbor2==5.9.0`` also carries the ``max_depth`` decoder hardening for CVE-2026-26209 (#1947)
* Raise the ``cryptography`` floor to ``>=50`` so PyPy (including PyPy-aarch64) installs a prebuilt wheel instead of source-compiling Rust under QEMU emulation, and refresh ``pip``/``packaging`` in ``install-build-tools`` before installing so newer platform wheel tags (e.g. ``manylinux_2_34`` PyPy-aarch64) resolve to wheels. ``>=50`` is also the first release clear of the known 2026 ``cryptography`` advisories — CVE-2026-26007 (fixed 46.0.5), CVE-2026-39892 (fixed 46.0.7) and the PKCS#7 Bleichenbacher oracle (fixed 50.0.0); since ``cryptography`` is Rust-only with no pure-Python fallback it cannot be capped, so the floor is raised to the security-clean version (#1947)
Expand Down
1 change: 1 addition & 0 deletions docs/programming-guide/websocket/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ This section covers WebSocket programming with Autobahn|Python.
:maxdepth: 2

/websocket/programming
/websocket/compression
/websocket/examples
/websocket/conformance
139 changes: 139 additions & 0 deletions docs/websocket/compression.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
WebSocket Compression
=====================

Autobahn|Python implements WebSocket per-message compression (`RFC 7692
<https://www.rfc-editor.org/rfc/rfc7692.html>`__) with four compression
extensions. Only ``permessage-deflate`` is standardized; the other three are
Autobahn|Python specific, and both peers must be Autobahn|Python (or another
implementation of the same extension) to use them.

The compression itself is done by a library for each extension, which is
either part of Python or a separate package. This page lists which library,
in which version, each extension uses, on CPython and on PyPy.

Extensions and Libraries
------------------------

.. list-table::
:header-rows: 1
:widths: 16 14 30 40

* - Extension
- Installed by
- Python package
- Compression library used
* - ``permessage-deflate``
- always available
- ``zlib`` (Python standard library)
- `zlib <https://zlib.net/>`__ - the zlib the Python interpreter was built with
* - ``permessage-bzip2``
- always available [1]_
- ``bz2`` (Python standard library)
- `bzip2 <https://sourceware.org/bzip2/>`__ (libbz2) - the libbz2 the Python interpreter was built with
* - ``permessage-brotli``
- ``compress`` extra
- CPython: `brotli <https://pypi.org/project/Brotli/>`__ ``>= 1.2.0``
(C extension, from the `google/brotli <https://github.com/google/brotli>`__
repository itself)

PyPy: `brotlicffi <https://pypi.org/project/brotlicffi/>`__ ``>= 1.2.0``
(CFFI, `python-hyper/brotlicffi <https://github.com/python-hyper/brotlicffi>`__)
- `google/brotli <https://github.com/google/brotli>`__ (libbrotli), bundled
in the package: brotli 1.2.0, brotlicffi 1.2.0.0 and brotlicffi 1.2.0.2
all bundle libbrotli **1.2.0**
* - ``permessage-snappy``
- installed separately (``pip install python-snappy``)
- `python-snappy <https://pypi.org/project/python-snappy/>`__ (tested with
0.7.3), a pure Python package on top of
`cramjam <https://github.com/milesgranger/pyrus-cramjam>`__ (Rust, PyO3)
- The Rust `snap <https://github.com/BurntSushi/rust-snappy>`__ crate, an
independent implementation of the Snappy format, bundled in cramjam
(cramjam 2.14.0 bundles snap **1.1.1**). **Not** `google/snappy
<https://github.com/google/snappy>`__ (libsnappy).

.. [1] Unless the Python interpreter was built without ``bz2``; the extension
is then not available.

Notes:

* ``brotli`` ``>= 1.2.0`` / ``brotlicffi`` ``>= 1.2.0`` is required: older
versions lack ``Decompressor.process(output_buffer_limit=...)``, which
``permessage-brotli`` needs to bound decompression (see below).
* Before version 0.7, python-snappy was a C extension linking google/snappy
(libsnappy). Autobahn|Python is tested with python-snappy 0.7.3 only.
* python-snappy declares no minimum version for cramjam.
* On PyPy, cramjam (a PyO3 extension) runs through PyPy's emulation of the
CPython C API (cpyext), unlike brotlicffi, which uses CFFI.
* python-snappy is not part of the ``compress`` extra; install it yourself
where you need ``permessage-snappy``.
* zlib and libbz2 come with the Python interpreter, so their version depends
on how it was built - not on Autobahn|Python. For zlib, check with

.. code-block:: python

import zlib
print(zlib.ZLIB_RUNTIME_VERSION)

For example, the PyPy 3.11 Linux binaries that ``uv`` installs ship their
own ``libz.so.1``.

To list the compression extensions available in an environment:

.. code-block:: python

from autobahn.websocket.compress import PERMESSAGE_COMPRESSION_EXTENSION

print(sorted(PERMESSAGE_COMPRESSION_EXTENSION))

In a development checkout, ``just check-compressors <venv>`` does the same
and fails unless all four are available; CI runs it on every supported
Python.

Context Takeover
----------------

With context takeover (the default), a compressor keeps its state from one
message to the next, so later messages can refer back to earlier ones.

* ``permessage-deflate`` takes over context as negotiated
(``client_no_context_takeover`` / ``server_no_context_takeover``).
* ``permessage-snappy`` negotiates the same parameters. The Snappy framing
format has no back-references across chunks, so context takeover makes no
difference to the compression ratio.
* ``permessage-bzip2`` and ``permessage-brotli`` never take over context:
each message is one complete bzip2 or brotli stream. ``permessage-brotli``
still negotiates the parameters.

Decompression Limits and Invalid Data
-------------------------------------

``maxMessagePayloadSize`` (see :doc:`programming`) bounds the size of a
message *after* decompression. All four extensions decompress incrementally
and stop once the remaining budget of the message is exceeded, rather than
decompressing a whole frame first:

* ``permessage-deflate`` and ``permessage-bzip2`` stop one octet over the
budget,
* ``permessage-brotli`` stops within one output buffer of the budget,
* ``permessage-snappy`` stops within one Snappy chunk (at most 64 KiB) of
the budget.

A message over the limit raises ``autobahn.exception.PayloadExceededError``
and fails the connection with close code 1009 (message too big). This is the
same for all four extensions, on both Twisted and asyncio.

Data an extension cannot decompress - corrupt, truncated, or not in the
extension's format - raises ``autobahn.exception.PayloadCorruptError`` and
fails the connection with close code 1007 (invalid frame payload data). This
is the same for all four extensions, on both Twisted and asyncio.

Without a limit (``maxMessagePayloadSize = 0``, the default), a small
compressed message can decompress to a very large one. Set a limit when
accepting compressed messages from peers you do not trust.

Example
-------

A complete example is in the repository:
`examples/twisted/websocket/echo_compressed
<https://github.com/crossbario/autobahn-python/tree/master/examples/twisted/websocket/echo_compressed>`__.
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ accelerate = [
# non-standard WebSocket compression support
# Note: zlib/deflate is always available (Python stdlib)
compress = [
"brotli>=1.0.0; platform_python_implementation == 'CPython'", # CPyExt for CPython
"brotlicffi>=1.0.0; platform_python_implementation != 'CPython'", # CFFI for PyPy
# >=1.2.0: Decompressor.process(output_buffer_limit=...), which the
# permessage-brotli receive path needs to bound decompression (#1986)
"brotli>=1.2.0; platform_python_implementation == 'CPython'", # CPyExt for CPython
"brotlicffi>=1.2.0; platform_python_implementation != 'CPython'", # CFFI for PyPy
# python-snappy is optional - only available if installed separately
# Users who need snappy: pip install python-snappy
]
Expand Down Expand Up @@ -178,6 +180,10 @@ dev = [
"pytest-asyncio>=0.14.0",
"pytest-runner>=2.11.1",
"pytest>=3.4.2",
# Test-only: python-snappy is an optional, user-installed dependency of
# permessage-snappy (see the "compress" extra), installed here so the
# snappy test cases run in CI too (#1986).
"python-snappy>=0.7.3",
"pyyaml>=4.2b4",
"qualname>=0.1.0",
"ruff>=0.12.1",
Expand Down
84 changes: 84 additions & 0 deletions src/autobahn/asyncio/test/test_aio_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,87 @@ async def on_connect(req):

assert len(values) == 1
assert values[0] == num * num


@pytest.mark.skipif(
not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only"
)
def test_corrupt_compressed_frame_closes_connection():
# A compressed frame that does not decompress must fail the connection
# (#1986). It must not stop the asyncio receive loop and leave the
# connection open.
from autobahn.websocket.compress import (
PerMessageDeflateOffer,
PerMessageDeflateOfferAccept,
)

def accept(offers):
for offer in offers:
if isinstance(offer, PerMessageDeflateOffer):
return PerMessageDeflateOfferAccept(offer)

loop = asyncio.new_event_loop()
try:
factory = WebSocketServerFactory(loop=loop)
factory.setProtocolOptions(perMessageCompressionAccept=accept)
server = factory()
transport = Mock()
server.connection_made(transport)

server.data_received(
b"\r\n".join(
[
b"GET /ws HTTP/1.1",
b"Host: www.example.com",
b"Sec-WebSocket-Version: 13",
b"Sec-WebSocket-Extensions: permessage-deflate",
b"Sec-WebSocket-Key: tXAxWFUqnhi86Ajj7dRY5g==",
b"Connection: keep-alive, Upgrade",
b"Upgrade: websocket",
b"\r\n", # last string doesn't get a \r\n from join()
]
)
)
loop.run_until_complete(asyncio.sleep(0.1))
assert server.state == server.STATE_OPEN
assert server._perMessageCompress is not None

# masked, FIN, RSV1 (compressed), binary frame with a corrupt body
body = b"\xff" * 64
mask = b"\x11\x22\x33\x44"
frame = bytes([0x80 | 0x40 | 0x02, 0x80 | len(body)]) + mask
frame += bytes(b ^ mask[i % 4] for i, b in enumerate(body))
server.data_received(frame)
loop.run_until_complete(asyncio.sleep(0.1))

assert transport.close.called or transport.abort.called, (
"connection left open after a corrupt compressed frame"
)
finally:
loop.close()


@pytest.mark.skipif(
not os.environ.get("USE_ASYNCIO", False), reason="test runs on asyncio only"
)
def test_unhandled_error_in_received_data_drops_connection():
# Any exception escaping the protocol's data processing must drop the
# connection, as on Twisted (#1986) - not stop the asyncio receive loop
# and leave the connection open.
loop = asyncio.new_event_loop()
try:
factory = WebSocketServerFactory(loop=loop)
server = factory()
transport = Mock()
server.connection_made(transport)

def broken(data):
raise RuntimeError("bug in data processing")

server._dataReceived = broken
server.data_received(b"anything")
loop.run_until_complete(asyncio.sleep(0.1))

assert transport.abort.called
finally:
loop.close()
13 changes: 12 additions & 1 deletion src/autobahn/asyncio/test/test_event_loop_1965.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,19 @@ def test_run_from_sync_code(code):
env = {
k: v for k, v in os.environ.items() if k not in ("USE_TWISTED", "USE_ASYNCIO")
}
# Deprecation warnings are errors in the child, except the one passlib
# triggers on Python < 3.13 by importing the stdlib "crypt" module (a later
# -W option takes precedence).
result = subprocess.run(
[sys.executable, "-W", "error::DeprecationWarning", "-c", code],
[
sys.executable,
"-W",
"error::DeprecationWarning",
"-W",
"ignore:'crypt' is deprecated:DeprecationWarning",
"-c",
code,
],
capture_output=True,
text=True,
env=env,
Expand Down
Loading
Loading