Skip to content

Commit b6a25ce

Browse files
committed
Security fix: defend against zip bombs.
1 parent 2b89213 commit b6a25ce

File tree

7 files changed

+39
-10
lines changed

7 files changed

+39
-10
lines changed

docs/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Changelog
88

99
*In development*
1010

11+
.. note::
12+
13+
**Version 5.0 fixes a security issue introduced in version 4.0.**
14+
15+
websockets 4.0 was vulnerable to denial of service by memory exhaustion
16+
because it didn't enforce ``max_size`` when decompressing compressed
17+
messages.
18+
1119
.. warning::
1220

1321
**Version 5.0 adds a** ``user_info`` **field to the return value of**

websockets/extensions/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class Extension:
7272
"""
7373
name = ...
7474

75-
def decode(self, frame):
75+
def decode(self, frame, *, max_size=None):
7676
"""
7777
Decode an incoming frame.
7878

websockets/extensions/permessage_deflate.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ..exceptions import (
1010
DuplicateParameter, InvalidParameterName, InvalidParameterValue,
11-
NegotiationError
11+
NegotiationError, PayloadTooBig
1212
)
1313
from ..framing import CTRL_OPCODES, OP_CONT
1414

@@ -463,7 +463,7 @@ def __repr__(self):
463463
self.local_max_window_bits),
464464
]))
465465

466-
def decode(self, frame):
466+
def decode(self, frame, *, max_size=None):
467467
"""
468468
Decode an incoming frame.
469469
@@ -495,11 +495,18 @@ def decode(self, frame):
495495
self.decoder = zlib.decompressobj(
496496
wbits=-self.remote_max_window_bits)
497497

498-
# Uncompress compressed frames.
498+
# Uncompress compressed frames. Protect against zip bombs by
499+
# preventing zlib from decompressing more than max_length bytes
500+
# (except when the limit is disabled with max_size = None).
499501
data = frame.data
500502
if frame.fin:
501503
data += _EMPTY_UNCOMPRESSED_BLOCK
502-
data = self.decoder.decompress(data)
504+
max_length = 0 if max_size is None else max_size
505+
data = self.decoder.decompress(data, max_length)
506+
if self.decoder.unconsumed_tail:
507+
raise PayloadTooBig(
508+
"Uncompressed payload length exceeds size limit (? > {} bytes)"
509+
.format(max_size))
503510

504511
# Allow garbage collection of the decoder if it won't be reused.
505512
if frame.fin and self.remote_no_context_takeover:

websockets/extensions/test_permessage_deflate.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from ..exceptions import (
55
DuplicateParameter, InvalidParameterName, InvalidParameterValue,
6-
NegotiationError
6+
NegotiationError, PayloadTooBig
77
)
88
from ..framing import (
99
OP_BINARY, OP_CLOSE, OP_CONT, OP_PING, OP_PONG, OP_TEXT, Frame,
@@ -835,3 +835,15 @@ def test_compress_settings(self):
835835
rsv1=True,
836836
data=b'\x00\x05\x00\xfa\xffcaf\xc3\xa9\x00', # not compressed
837837
))
838+
839+
# Frames aren't decoded beyond max_length.
840+
841+
def test_decompress_max_size(self):
842+
frame = Frame(True, OP_TEXT, ('a' * 20).encode('utf-8'))
843+
844+
enc_frame = self.extension.encode(frame)
845+
846+
self.assertEqual(enc_frame.data, b'JL\xc4\x04\x00\x00')
847+
848+
with self.assertRaises(PayloadTooBig):
849+
self.extension.decode(enc_frame, max_size=10)

websockets/framing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def read(cls, reader, *, mask, max_size=None, extensions=None):
119119
length, = struct.unpack('!Q', data)
120120
if max_size is not None and length > max_size:
121121
raise PayloadTooBig(
122-
"Payload length exceeds limit: {} > {} bytes"
122+
"Payload length exceeds size limit ({} > {} bytes)"
123123
.format(length, max_size))
124124
if mask:
125125
mask_bits = yield from reader(4)
@@ -134,7 +134,7 @@ def read(cls, reader, *, mask, max_size=None, extensions=None):
134134
if extensions is None:
135135
extensions = []
136136
for extension in reversed(extensions):
137-
frame = extension.decode(frame)
137+
frame = extension.decode(frame, max_size=max_size)
138138

139139
frame.check()
140140

websockets/test_client_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ class NoOpExtension:
204204
def __repr__(self):
205205
return 'NoOpExtension()'
206206

207-
def decode(self, frame):
207+
def decode(self, frame, *, max_size=None):
208208
return frame
209209

210210
def encode(self, frame):

websockets/test_framing.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ def encode(frame):
217217
return frame._replace(data=data)
218218

219219
# This extensions is symmetrical.
220-
decode = encode
220+
@staticmethod
221+
def decode(frame, *, max_size=None):
222+
return Rot13.encode(frame)
221223

222224
self.round_trip(
223225
b'\x81\x05uryyb',

0 commit comments

Comments
 (0)