diff --git a/stream_chat/tests/test_webhook_compression.py b/stream_chat/tests/test_webhook_compression.py index c22853f..811e5fb 100644 --- a/stream_chat/tests/test_webhook_compression.py +++ b/stream_chat/tests/test_webhook_compression.py @@ -26,7 +26,7 @@ import pytest -from stream_chat import StreamChat, StreamChatAsync +from stream_chat import StreamChat, StreamChatAsync, webhook from stream_chat.base.exceptions import ( INVALID_WEBHOOK_GZIP_FAILED, INVALID_WEBHOOK_INVALID_BASE64, @@ -98,6 +98,40 @@ def test_truncated_gzip_with_magic_raises(self): gunzip_payload(bad) assert str(exc_info.value) == INVALID_WEBHOOK_GZIP_FAILED + def test_corrupt_deflate_data_raises(self): + bad = bytearray(_gzip(JSON_BODY)) + bad[10:14] = b"\xff\xff\xff\xff" + with pytest.raises(InvalidWebhookError) as exc_info: + gunzip_payload(bytes(bad)) + assert str(exc_info.value) == INVALID_WEBHOOK_GZIP_FAILED + + def test_inflates_multiple_members(self): + assert gunzip_payload(_gzip(b"ab") + _gzip(b"cd")) == b"abcd" + + def test_inflates_body_at_limit(self): + limit = webhook.MAX_WEBHOOK_PAYLOAD_BYTES + assert limit == 10 * 1024 * 1024 + assert len(gunzip_payload(_gzip(b"\x00" * limit))) == limit + + def test_gzip_bomb_raises_before_signature_check(self): + limit = webhook.MAX_WEBHOOK_PAYLOAD_BYTES + bomb = _gzip(b"\x00" * (limit + 1)) + expected = f"{INVALID_WEBHOOK_GZIP_FAILED}: payload exceeds {limit} bytes" + with pytest.raises(InvalidWebhookError) as exc_info: + verify_and_parse_webhook(bomb, "deadbeef", API_SECRET) + assert str(exc_info.value) == expected + with pytest.raises(InvalidWebhookError, match="payload exceeds"): + parse_sqs(_b64(bomb)) + + def test_custom_limit(self, monkeypatch): + monkeypatch.setattr(webhook, "MAX_WEBHOOK_PAYLOAD_BYTES", len(JSON_BODY) - 1) + with pytest.raises(InvalidWebhookError, match="payload exceeds"): + gunzip_payload(_gzip(JSON_BODY)) + monkeypatch.setattr(webhook, "MAX_WEBHOOK_PAYLOAD_BYTES", len(JSON_BODY)) + assert gunzip_payload(_gzip(JSON_BODY)) == JSON_BODY + monkeypatch.setattr(webhook, "MAX_WEBHOOK_PAYLOAD_BYTES", 1) + assert gunzip_payload(JSON_BODY) == JSON_BODY + class TestDecodeSqsPayload: def test_base64_only_no_compression(self): diff --git a/stream_chat/webhook.py b/stream_chat/webhook.py index 032aa68..35d41ab 100644 --- a/stream_chat/webhook.py +++ b/stream_chat/webhook.py @@ -17,7 +17,9 @@ import gzip import hashlib import hmac +import io import json +import zlib from typing import Any, Dict, Optional, Union from stream_chat.base.exceptions import ( @@ -30,6 +32,11 @@ GZIP_MAGIC = b"\x1f\x8b" +# Caps the decompressed size of a gzip webhook body. gunzip_payload runs before +# the signature check, so without a cap an unsigned gzip bomb could exhaust +# memory. Raise it if your events are larger. +MAX_WEBHOOK_PAYLOAD_BYTES = 10 * 1024 * 1024 + _BytesLike = Union[bytes, bytearray, memoryview, str] @@ -48,14 +55,24 @@ def gunzip_payload(body: _BytesLike) -> bytes: Magic-byte detection (rather than relying on a header) means the same handler stays correct when middleware - Rails, Django, Laravel, Phoenix - auto-decompresses the request before your code sees it. + + Raises :class:`InvalidWebhookError` when the gzip stream is malformed or + decompresses to more than :data:`MAX_WEBHOOK_PAYLOAD_BYTES`. """ raw = _to_bytes(body) if raw[:2] != GZIP_MAGIC: return raw + limit = MAX_WEBHOOK_PAYLOAD_BYTES try: - return gzip.decompress(raw) - except (gzip.BadGzipFile, OSError, EOFError) as exc: + with gzip.GzipFile(fileobj=io.BytesIO(raw)) as f: + out = f.read(limit + 1) + except (OSError, EOFError, zlib.error) as exc: raise InvalidWebhookError(INVALID_WEBHOOK_GZIP_FAILED) from exc + if len(out) > limit: + raise InvalidWebhookError( + f"{INVALID_WEBHOOK_GZIP_FAILED}: payload exceeds {limit} bytes" + ) + return out def decode_sqs_payload(body: _BytesLike) -> bytes: