Skip to content

[CHA-3071] feat: decode gzip-compressed webhook bodies#169

Open
nijeesh-stream wants to merge 1 commit intomainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads
Open

[CHA-3071] feat: decode gzip-compressed webhook bodies#169
nijeesh-stream wants to merge 1 commit intomainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads

Conversation

@nijeesh-stream
Copy link
Copy Markdown
Contributor

Ticket

Summary

Server-side webhook compression is landing in GetStream/chat#13222. When an app opts in (webhook_compression_algorithm = "gzip") every outbound webhook body is gzipped and a Content-Encoding: gzip header is added. X-Signature is still computed over the uncompressed JSON, so handlers must decompress before verifying.

This PR adds the customer-side primitives so handlers built on this SDK keep working when their app gets flipped on.

What's new

  • Client::decompressWebhookBody(string $body, ?string $contentEncoding): string
    • null / '' / whitespace → returns body unchanged.
    • gzip (case-insensitive, trimmed) → gzdecode.
    • Anything else → StreamException with a message that names the offending encoding and points the operator back at webhook_compression_algorithm.
  • Client::verifyAndDecodeWebhook(string $body, string $signature, ?string $contentEncoding): string
    • Decompresses (if needed), verifies HMAC over the uncompressed bytes, and returns the raw JSON. Throws StreamException on signature mismatch.
  • Client::verifyWebhook now uses hash_equals instead of === so the signature comparison is constant-time. Public surface is unchanged.

The SDK supports gzip only. Any other Content-Encoding (br, zstd, deflate, …) raises a clear error rather than silently dropping the body.

Tests

tests/unit/WebhookCompressionTest.php (16 cases under the unit suite, 20 total in the suite):

  • gzip round-trip
  • passthrough on null, empty, whitespace
  • case-insensitive + trimmed encoding header
  • six-row data provider rejecting br, brotli, zstd, deflate, compress, lz4 with the expected hint
  • corrupted gzip bytes → failed to gzip-decode
  • verifyWebhook constant-time true / false
  • verifyAndDecodeWebhook happy paths (gzip + passthrough), bad signature, and the regression case where the signature was computed over the compressed bytes (must reject).
docker run --rm -v "$(pwd)":/app -w /app php:8.2-cli vendor/bin/phpunit --testsuite='Unit Test Suite'
...
OK (20 tests, 37 assertions)

vendor/bin/php-cs-fixer fix --dry-run --diff clean on the touched files.

Backward compatibility

  • No change to existing callers. verifyWebhook($body, $signature) keeps the same signature and behavior; the only difference is the comparison is now constant-time.
  • New methods are additive.
  • No new dependencies — gzip uses the built-in zlib extension.

Made with Cursor

Adds Client::decompressWebhookBody and Client::verifyAndDecodeWebhook so
handlers can accept the new outbound webhook compression
(GetStream/chat#13222) without changing how X-Signature is verified.

decompressWebhookBody runs gzdecode when the Content-Encoding header is
gzip, returns the body unchanged when the header is null or empty, and
throws StreamException for any other value with a message that points
the operator at the app's webhook_compression_algorithm setting.

verifyAndDecodeWebhook chains decompression with the existing HMAC check
and returns the raw JSON when the signature matches. The signature is
always computed over the uncompressed bytes, matching the server.

verifyWebhook switches to hash_equals so the comparison is constant-time.

Tests cover gzip round-trip, null/empty/whitespace passthrough, case-
insensitive Content-Encoding, invalid gzip bytes, every non-gzip
encoding being rejected with a clear message, signature mismatch, and
the regression case where the signature was computed over the
compressed bytes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant