Skip to content
Open
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
36 changes: 35 additions & 1 deletion stream_chat/tests/test_webhook_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
21 changes: 19 additions & 2 deletions stream_chat/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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]


Expand All @@ -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:
Expand Down
Loading