Skip to content

Commit 38bd50c

Browse files
feat(webhooks): make signature optional on verify_and_parse_sqs/sns (CHA-3071)
Stream does not ship an X-Signature on SQS or SNS deliveries — those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so HMAC verification on top is theatre. signature + secret are now optional on both module helpers and on the StreamChat / StreamChatAsync instance methods. - verify_and_parse_sqs(body) -> decode + parse - verify_and_parse_sqs(body, sig, secret) -> decode + verify + parse - verify_and_parse_sns(envelope_body) -> unwrap + decode + parse - verify_and_parse_sns(envelope_body, sig, secret) -> + verify Passing only one of (signature, secret) raises InvalidWebhookError. The HTTP-webhook path (verify_and_parse_webhook) is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7c7cbeb commit 38bd50c

4 files changed

Lines changed: 137 additions & 41 deletions

File tree

docs/webhooks/webhooks_overview/webhooks_overview.md

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,42 +141,49 @@ The original `client.verify_webhook(request.body, request.headers["X-Signature"]
141141

142142
#### SQS / SNS firehose
143143

144-
For events delivered through SQS or SNS, call the matching helper. It base64-decodes the envelope, gzip-decompresses when the magic bytes are present, verifies the HMAC, and returns the parsed event.
144+
For events delivered through SQS or SNS, call the matching helper. It base64-decodes the envelope, gzip-decompresses when the magic bytes are present, and returns the parsed event.
145+
146+
Stream does **not** ship an `X-Signature` on SQS or SNS deliveries: those transports run on AWS-internal infrastructure that is already authenticated end-to-end. SQS queues are reached via IAM-authenticated polling, and SNS notifications carry an AWS signature on the notification envelope itself, so verifying that the message really came from your topic happens at the AWS layer. Layering an HMAC check on top is redundant, so `signature` and `secret` are optional for the SQS/SNS helpers. If you want the legacy verification pipeline you can still pass both — but you do not need to.
145147

146148
For SQS, pass the message `Body` (already the payload):
147149

148150
```python
149-
event = client.verify_and_parse_sqs(
150-
sqs_message["Body"],
151-
sqs_message["MessageAttributes"]["X-Signature"]["StringValue"],
152-
)
151+
event = client.verify_and_parse_sqs(sqs_message["Body"])
153152
```
154153

155154
For SNS, pass the **raw notification body** (the full `{"Type":"Notification", ...}` JSON envelope Amazon delivers). The SDK extracts the inner `Message` field for you, so the call site mirrors what HTTP frameworks already hand you in `request.body`:
156155

157156
```python
158-
import json
159-
160157
# Django SNS HTTP delivery
161-
attrs = json.loads(request.body)["MessageAttributes"]
162-
event = client.verify_and_parse_sns(
163-
request.body, # raw envelope (bytes/str)
164-
attrs["X-Signature"]["Value"],
165-
)
158+
event = client.verify_and_parse_sns(request.body) # raw envelope (bytes/str)
166159
```
167160

168161
#### Stateless / module-level form
169162

170-
If you do not want to construct a `StreamChat` client (for example in a lightweight Lambda that only handles webhooks), call the module-level helpers directly. They take the API secret as a third argument and are otherwise identical:
163+
If you do not want to construct a `StreamChat` client (for example in a lightweight Lambda that only handles webhooks), call the module-level helpers directly. The HTTP helper still requires the signature and secret; the SQS/SNS helpers take them as optional positional arguments:
171164

172165
```python
173166
from stream_chat import webhook
174167

175168
event = webhook.verify_and_parse_webhook(body, signature, secret)
169+
event = webhook.verify_and_parse_sqs(message_body)
170+
event = webhook.verify_and_parse_sns(notification_body)
171+
172+
# Opt-in HMAC verification for SQS / SNS (defence in depth)
176173
event = webhook.verify_and_parse_sqs(message_body, signature, secret)
177174
event = webhook.verify_and_parse_sns(notification_body, signature, secret)
178175
```
179176

177+
Passing only one of `signature` / `secret` to the SQS or SNS helper is a programmer error and raises `InvalidWebhookError("signature and secret must both be provided to verify the SQS/SNS payload")`.
178+
179+
##### Arguments
180+
181+
| Argument | `verify_and_parse_webhook` | `verify_and_parse_sqs` | `verify_and_parse_sns` |
182+
| ------------------- | -------------------------- | ---------------------- | ---------------------- |
183+
| body / message_body / notification_body | required | required | required |
184+
| signature | required | optional | optional |
185+
| secret | required | optional | optional |
186+
180187
The module also exposes the primitives the composites are built from — `gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload`, `verify_signature` (constant-time HMAC-SHA256), and `parse_event` — for callers that need to run the steps individually.
181188

182189
All webhook requests contain these headers:

stream_chat/base/client.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,42 +157,58 @@ def verify_and_parse_webhook(
157157
def verify_and_parse_sqs(
158158
self,
159159
message_body: Union[bytes, str],
160-
signature: Union[str, bytes],
160+
signature: Optional[Union[str, bytes]] = None,
161161
) -> Dict[str, Any]:
162-
"""Verify and parse an SQS firehose webhook event.
162+
"""Parse an SQS firehose webhook event.
163163
164164
Reverses the base64 (+ optional gzip) wrapping on the SQS
165-
``Body``, verifies the ``X-Signature`` message attribute against
166-
the app's API secret, and returns the parsed event.
165+
``Body`` and returns the parsed event. Stream does not attach
166+
an ``X-Signature`` to SQS deliveries -- the transport is an
167+
IAM-authenticated AWS queue, so HMAC verification on top is
168+
redundant and signature verification is therefore optional.
169+
When ``signature`` is supplied the app's API secret is used to
170+
run the legacy verification pipeline.
167171
168172
:param message_body: SQS message ``Body`` (string)
169-
:param signature: ``X-Signature`` message attribute value
173+
:param signature: optional ``X-Signature`` message attribute
174+
value; when supplied, signature verification runs
170175
:raises stream_chat.webhook.InvalidWebhookError: on
171176
signature mismatch or any decode error
172177
"""
173178
from stream_chat.webhook import verify_and_parse_sqs
174179

180+
if signature is None:
181+
return verify_and_parse_sqs(message_body)
175182
return verify_and_parse_sqs(message_body, signature, self.api_secret)
176183

177184
def verify_and_parse_sns(
178185
self,
179-
message: Union[bytes, str],
180-
signature: Union[str, bytes],
186+
notification_body: Union[bytes, str],
187+
signature: Optional[Union[str, bytes]] = None,
181188
) -> Dict[str, Any]:
182-
"""Verify and parse an SNS firehose webhook event.
189+
"""Parse an SNS firehose webhook event.
183190
184191
Reverses the base64 (+ optional gzip) wrapping on the SNS
185-
``Message``, verifies the ``X-Signature`` message attribute
186-
against the app's API secret, and returns the parsed event.
187-
188-
:param message: SNS notification ``Message`` field (string)
189-
:param signature: ``X-Signature`` message attribute value
192+
``Message`` and returns the parsed event. Stream does not
193+
attach an ``X-Signature`` to SNS deliveries -- AWS already
194+
signs the SNS notification envelope, so HMAC verification on
195+
top is redundant and signature verification is therefore
196+
optional. When ``signature`` is supplied the app's API secret
197+
is used to run the legacy verification pipeline.
198+
199+
:param notification_body: raw SNS notification body (the full
200+
``{"Type":"Notification", ...}`` JSON envelope, or a
201+
pre-extracted ``Message`` string)
202+
:param signature: optional ``X-Signature`` message attribute
203+
value; when supplied, signature verification runs
190204
:raises stream_chat.webhook.InvalidWebhookError: on
191205
signature mismatch or any decode error
192206
"""
193207
from stream_chat.webhook import verify_and_parse_sns
194208

195-
return verify_and_parse_sns(message, signature, self.api_secret)
209+
if signature is None:
210+
return verify_and_parse_sns(notification_body)
211+
return verify_and_parse_sns(notification_body, signature, self.api_secret)
196212

197213
@abc.abstractmethod
198214
def update_app_settings(

stream_chat/tests/test_webhook_compression.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,24 @@ def test_signature_over_compressed_or_wrapped_bytes_rejected(self):
282282
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
283283
verify_and_parse_sqs(wrapped, sig_over_wrapped, API_SECRET)
284284

285+
def test_verify_and_parse_sqs_without_signature_parses(self):
286+
assert verify_and_parse_sqs(_b64(JSON_BODY)) == EVENT_DICT
287+
assert verify_and_parse_sqs(_b64(_gzip(JSON_BODY))) == EVENT_DICT
288+
assert verify_and_parse_sqs(_b64(_gzip(JSON_BODY)).encode()) == EVENT_DICT
289+
290+
def test_static_verify_and_parse_sqs_raises_on_partial_creds(self):
291+
wrapped = _b64(_gzip(JSON_BODY))
292+
with pytest.raises(
293+
InvalidWebhookError,
294+
match=r"signature and secret must both be provided",
295+
):
296+
verify_and_parse_sqs(wrapped, _sign(JSON_BODY))
297+
with pytest.raises(
298+
InvalidWebhookError,
299+
match=r"signature and secret must both be provided",
300+
):
301+
verify_and_parse_sqs(wrapped, secret=API_SECRET)
302+
285303

286304
class TestVerifyAndParseSns:
287305
def test_pre_extracted_message_round_trip(self):
@@ -309,6 +327,13 @@ def test_rejects_signature_over_envelope(self):
309327
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
310328
verify_and_parse_sns(envelope, sig_over_envelope, API_SECRET)
311329

330+
def test_verify_and_parse_sns_without_signature_parses(self):
331+
wrapped = _b64(_gzip(JSON_BODY))
332+
envelope = _sns_envelope(wrapped)
333+
assert verify_and_parse_sns(envelope) == EVENT_DICT
334+
assert verify_and_parse_sns(wrapped) == EVENT_DICT
335+
assert verify_and_parse_sns(_b64(JSON_BODY)) == EVENT_DICT
336+
312337

313338
class TestSyncClientMethods:
314339
def test_verify_and_parse_webhook(self, sync_client: StreamChat):
@@ -329,6 +354,13 @@ def test_signature_mismatch_via_client(self, sync_client: StreamChat):
329354
with pytest.raises(InvalidWebhookError, match=r"signature mismatch"):
330355
sync_client.verify_and_parse_webhook(JSON_BODY, "0" * 64)
331356

357+
def test_instance_verify_and_parse_sqs_without_signature(self):
358+
client = StreamChat(api_key=API_KEY, api_secret="")
359+
wrapped = _b64(_gzip(JSON_BODY))
360+
envelope = _sns_envelope(wrapped)
361+
assert client.verify_and_parse_sqs(wrapped) == EVENT_DICT
362+
assert client.verify_and_parse_sns(envelope) == EVENT_DICT
363+
332364

333365
class TestSyncClientLegacyVerifyWebhook:
334366
"""The legacy boolean helper stays unchanged for backward compatibility."""

stream_chat/webhook.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
INVALID_WEBHOOK_INVALID_BASE64 = "invalid base64 encoding"
2929
INVALID_WEBHOOK_GZIP_FAILED = "gzip decompression failed"
3030
INVALID_WEBHOOK_INVALID_JSON = "invalid JSON payload"
31+
INVALID_WEBHOOK_PARTIAL_AWS_CREDS = (
32+
"signature and secret must both be provided to verify the SQS/SNS payload"
33+
)
3134

3235

3336
class InvalidWebhookError(Exception):
@@ -179,6 +182,18 @@ def _verify_and_parse(
179182
return parse_event(payload_bytes)
180183

181184

185+
def _maybe_verify_and_parse(
186+
payload_bytes: bytes,
187+
signature: Optional[Union[str, bytes]],
188+
secret: Optional[str],
189+
) -> Dict[str, Any]:
190+
if not signature and not secret:
191+
return parse_event(payload_bytes)
192+
if not signature or not secret:
193+
raise InvalidWebhookError(INVALID_WEBHOOK_PARTIAL_AWS_CREDS)
194+
return _verify_and_parse(payload_bytes, signature, secret)
195+
196+
182197
def verify_and_parse_webhook(
183198
body: _BytesLike,
184199
signature: Union[str, bytes],
@@ -198,25 +213,51 @@ def verify_and_parse_webhook(
198213

199214
def verify_and_parse_sqs(
200215
message_body: _BytesLike,
201-
signature: Union[str, bytes],
202-
secret: str,
216+
signature: Optional[Union[str, bytes]] = None,
217+
secret: Optional[str] = None,
203218
) -> Dict[str, Any]:
204-
"""Decode the SQS ``Body`` (base64, then gzip-if-magic), verify the
205-
HMAC ``signature`` from the ``X-Signature`` message attribute, and
206-
return the parsed event.
219+
"""Decode the SQS ``Body`` (base64, then gzip-if-magic) and return
220+
the parsed event.
221+
222+
Stream does not attach an ``X-Signature`` to SQS deliveries: the
223+
transport is an IAM-authenticated AWS queue, so the queue ARN
224+
already proves origin. HMAC verification on top is redundant and
225+
is therefore optional. When ``signature`` and ``secret`` are both
226+
supplied the legacy verification pipeline still runs, so existing
227+
callers keep working unchanged.
228+
229+
:param message_body: SQS message ``Body`` (string)
230+
:param signature: optional ``X-Signature`` message attribute value
231+
:param secret: optional API secret matching ``signature``
232+
:raises InvalidWebhookError: on signature mismatch, any decode
233+
error, or when only one of ``signature`` / ``secret`` is given
207234
"""
208235
inflated = decode_sqs_payload(message_body)
209-
return _verify_and_parse(inflated, signature, secret)
236+
return _maybe_verify_and_parse(inflated, signature, secret)
210237

211238

212239
def verify_and_parse_sns(
213-
message: _BytesLike,
214-
signature: Union[str, bytes],
215-
secret: str,
240+
notification_body: _BytesLike,
241+
signature: Optional[Union[str, bytes]] = None,
242+
secret: Optional[str] = None,
216243
) -> Dict[str, Any]:
217-
"""Decode the SNS ``Message`` (identical to SQS handling), verify
218-
the HMAC ``signature`` from the ``X-Signature`` message attribute,
219-
and return the parsed event.
244+
"""Decode the SNS ``Message`` (identical to SQS handling) and return
245+
the parsed event.
246+
247+
Stream does not attach an ``X-Signature`` to SNS deliveries: AWS
248+
already signs the SNS notification envelope, so verifying that the
249+
request really came from your topic happens at the SNS layer.
250+
HMAC verification on top is optional. When ``signature`` and
251+
``secret`` are both supplied the legacy verification pipeline still
252+
runs, so existing callers keep working unchanged.
253+
254+
:param notification_body: raw SNS notification body (the full
255+
``{"Type":"Notification", ...}`` JSON envelope, or a
256+
pre-extracted ``Message`` string)
257+
:param signature: optional ``X-Signature`` message attribute value
258+
:param secret: optional API secret matching ``signature``
259+
:raises InvalidWebhookError: on signature mismatch, any decode
260+
error, or when only one of ``signature`` / ``secret`` is given
220261
"""
221-
inflated = decode_sns_payload(message)
222-
return _verify_and_parse(inflated, signature, secret)
262+
inflated = decode_sns_payload(notification_body)
263+
return _maybe_verify_and_parse(inflated, signature, secret)

0 commit comments

Comments
 (0)