From 1c84575e8394d9a9d05aada1df647ef74e4b817f Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 1 Sep 2026 21:25:04 +0300 Subject: [PATCH] Decline permessage-deflate when the compressor window would be 8 bits zlib cannot build a raw deflate compressor with an 8 bit window: zlib.compressobj(wbits=-8) raises ValueError("Invalid initialization option"), while zlib.decompressobj(wbits=-8) is fine. permessage-deflate uses raw deflate, so 8 is usable for the window we decompress with and unusable for the window we compress with. _MAX_WINDOW_BITS_VALUES accepts "8" from the wire, and both factories pass it straight through to PerMessageDeflate as local_max_window_bits, so a peer asking for a window of 8 bits on the side that compresses turned a handshake into a ValueError. On a default server that is one request header: Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=8 returned 500 where 9 returned 101. With server_no_context_takeover the encoder is built lazily, so the handshake succeeded and the first encode() raised instead. RFC 7692 7.1.2.1 makes 8 a legal value in an offer and says a server declines an offer it cannot support, so both factories now raise NegotiationError for the compressor side only. The server declines the extension and the connection proceeds uncompressed; the client fails the connection with a clear error instead of a ValueError. 8 remains accepted for the decompressor side, which works. This matches docs/topics/compression.rst, which already documents the usable range as 9 to 15. --- docs/project/changelog.rst | 8 ++++++ .../extensions/permessage_deflate.py | 13 +++++++++ tests/extensions/test_permessage_deflate.py | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/project/changelog.rst b/docs/project/changelog.rst index 738288e4..59ccb15a 100644 --- a/docs/project/changelog.rst +++ b/docs/project/changelog.rst @@ -44,6 +44,14 @@ Improvements * :func:`~asyncio.client.connect` now closes connections with close code 1011 (internal error) when exiting the context manager with an exception. +Bug fixes +......... + +* Negotiating ``permessage-deflate`` no longer fails with a :exc:`ValueError` + when the peer asks for a window of 8 bits on the side that compresses. + :mod:`zlib` cannot create a raw deflate compressor with such a window, so + the extension is declined instead. + .. _17.1: 17.1 diff --git a/src/websockets/extensions/permessage_deflate.py b/src/websockets/extensions/permessage_deflate.py index 50276637..0c926f69 100644 --- a/src/websockets/extensions/permessage_deflate.py +++ b/src/websockets/extensions/permessage_deflate.py @@ -464,6 +464,12 @@ def process_response_params( elif client_max_window_bits > self.client_max_window_bits: raise NegotiationError("unsupported client_max_window_bits") + # zlib cannot build a raw deflate compressor with an 8 bit window, so + # 8 is unusable for the window we compress with, even though it is a + # legal value on the wire and fine for the window we decompress with. + if client_max_window_bits == 8: + raise NegotiationError("unsupported client_max_window_bits") + return PerMessageDeflate( server_no_context_takeover, # remote_no_context_takeover client_no_context_takeover, # local_no_context_takeover @@ -658,6 +664,13 @@ def process_request_params( elif self.client_max_window_bits < client_max_window_bits: client_max_window_bits = self.client_max_window_bits + # zlib cannot build a raw deflate compressor with an 8 bit window, so + # 8 is unusable for the window we compress with, even though it is a + # legal value on the wire and fine for the window we decompress with. + # RFC 7692 7.1.2.1 lets a server decline an offer it cannot support. + if server_max_window_bits == 8: + raise NegotiationError("unsupported server_max_window_bits") + return ( _build_parameters( server_no_context_takeover, diff --git a/tests/extensions/test_permessage_deflate.py b/tests/extensions/test_permessage_deflate.py index 71f8e8e4..bdb88113 100644 --- a/tests/extensions/test_permessage_deflate.py +++ b/tests/extensions/test_permessage_deflate.py @@ -463,6 +463,12 @@ def test_process_response_params(self): [("server_max_window_bits", "7")], NegotiationError, ), + ( + # 8 is legal for the window we decompress with. + (False, False, None, None), + [("server_max_window_bits", "8")], + (False, False, 8, 15), + ), ( (False, False, None, None), [("server_max_window_bits", "10")], @@ -519,6 +525,12 @@ def test_process_response_params(self): [("client_max_window_bits", "7")], NegotiationError, ), + ( + # 8 is unusable for the window we compress with. + (False, False, None, True), + [("client_max_window_bits", "8")], + NegotiationError, + ), ( (False, False, None, True), [("client_max_window_bits", "10")], @@ -774,6 +786,14 @@ def test_process_request_params(self): None, NegotiationError, ), + ( + # 8 is unusable for the window we compress with; RFC 7692 + # 7.1.2.1 lets the server decline the offer. + (False, False, None, None), + [("server_max_window_bits", "8")], + None, + NegotiationError, + ), ( (False, False, None, None), [("server_max_window_bits", "10")], @@ -835,6 +855,13 @@ def test_process_request_params(self): None, InvalidParameterValue, ), + ( + # 8 is legal for the window we decompress with. + (False, False, None, None), + [("client_max_window_bits", "8")], + [("client_max_window_bits", "8")], # doesn't matter + (False, False, 8, 15), + ), ( (False, False, None, None), [("client_max_window_bits", "10")],