From d983247cd8a95cbccdec99fbf2a02f15d23df4db Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 6 May 2026 16:38:31 +0200 Subject: [PATCH 01/13] [CHA-3071] feat: decode gzip-compressed webhook bodies 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 --- lib/GetStream/StreamChat/Client.php | 59 ++++++++++- tests/unit/WebhookCompressionTest.php | 137 ++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/unit/WebhookCompressionTest.php diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 4464fbf..745708d 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1224,13 +1224,70 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b } /** Verify the signature added to a webhook event. + * + * The signature is always computed over the uncompressed JSON body. When webhook + * compression is enabled on the app the request body must be decompressed (via + * {@see decompressWebhookBody()} or {@see verifyAndDecodeWebhook()}) before being + * passed to this method. * @throws StreamException */ public function verifyWebhook(string $requestBody, string $XSignature): bool { $signature = hash_hmac("sha256", $requestBody, $this->apiSecret); - return $signature === $XSignature; + return hash_equals($signature, $XSignature); + } + + /** Decompress the body of an outbound webhook according to its Content-Encoding header. + * + * This SDK only supports `gzip`. A null or empty encoding returns the body unchanged. + * Any other value raises a {@see StreamException} so callers can surface a clear error + * and the operator can flip the app back to `gzip` on the dashboard. + * + * @throws StreamException + */ + public function decompressWebhookBody(string $body, ?string $contentEncoding): string + { + if ($contentEncoding === null) { + return $body; + } + $encoding = strtolower(trim($contentEncoding)); + if ($encoding === '') { + return $body; + } + if ($encoding !== 'gzip') { + throw new StreamException( + 'unsupported webhook Content-Encoding: ' . $contentEncoding + . '. This SDK only supports gzip; set webhook_compression_algorithm to "gzip"' + . ' on the app config.' + ); + } + $decoded = @gzdecode($body); + if ($decoded === false) { + throw new StreamException('failed to gzip-decode webhook body'); + } + return $decoded; + } + + /** Decompresses (when Content-Encoding is set) and verifies the HMAC signature of an + * outbound webhook request, returning the raw JSON body when the signature matches. + * + * This is the recommended entry point for webhook handlers when webhook compression + * may be enabled on the app: it handles every value of `Content-Encoding` Stream may + * send and keeps signature verification on the uncompressed body. + * + * @throws StreamException if the signature does not match or the body cannot be decoded + */ + public function verifyAndDecodeWebhook( + string $body, + string $signature, + ?string $contentEncoding + ): string { + $decoded = $this->decompressWebhookBody($body, $contentEncoding); + if (!$this->verifyWebhook($decoded, $signature)) { + throw new StreamException('invalid webhook signature'); + } + return $decoded; } /** Searches for messages. diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php new file mode 100644 index 0000000..403bb5d --- /dev/null +++ b/tests/unit/WebhookCompressionTest.php @@ -0,0 +1,137 @@ +client = new Client(self::API_KEY, self::API_SECRET); + } + + private function sign(string $body): string + { + return hash_hmac('sha256', $body, self::API_SECRET); + } + + public function testDecompressWebhookBodyPassthroughWhenEncodingNull(): void + { + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, null)); + } + + public function testDecompressWebhookBodyPassthroughWhenEncodingEmpty(): void + { + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, '')); + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, ' ')); + } + + public function testDecompressWebhookBodyRoundTripsGzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertNotFalse($compressed); + $this->assertNotEquals(self::JSON_BODY, $compressed); + + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'gzip')); + } + + public function testDecompressWebhookBodyHandlesEncodingCaseInsensitively(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'GZIP')); + $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, ' gzip ')); + } + + /** + * @dataProvider nonGzipEncodings + */ + public function testDecompressWebhookBodyRejectsEveryNonGzipEncoding(string $encoding): void + { + try { + $this->client->decompressWebhookBody(self::JSON_BODY, $encoding); + $this->fail("expected StreamException for encoding '$encoding'"); + } catch (StreamException $e) { + $this->assertStringContainsString('unsupported', $e->getMessage()); + $this->assertStringContainsString('gzip', $e->getMessage()); + } + } + + public static function nonGzipEncodings(): array + { + return [ + 'brotli short' => ['br'], + 'brotli long' => ['brotli'], + 'zstd' => ['zstd'], + 'deflate' => ['deflate'], + 'compress' => ['compress'], + 'lz4' => ['lz4'], + ]; + } + + public function testDecompressWebhookBodyThrowsOnInvalidGzipBytes(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/failed to gzip-decode/'); + $this->client->decompressWebhookBody('not actually gzip', 'gzip'); + } + + public function testVerifyWebhookUsesConstantTimeComparison(): void + { + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue($this->client->verifyWebhook(self::JSON_BODY, $sig)); + $this->assertFalse($this->client->verifyWebhook(self::JSON_BODY, 'deadbeef')); + } + + public function testVerifyAndDecodeWebhookGzipHappyPath(): void + { + $compressed = gzencode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + + $decoded = $this->client->verifyAndDecodeWebhook($compressed, $sig, 'gzip'); + $this->assertSame(self::JSON_BODY, $decoded); + } + + public function testVerifyAndDecodeWebhookPassthroughHappyPath(): void + { + $sig = $this->sign(self::JSON_BODY); + + $this->assertSame( + self::JSON_BODY, + $this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, null) + ); + $this->assertSame( + self::JSON_BODY, + $this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, '') + ); + } + + public function testVerifyAndDecodeWebhookThrowsOnSignatureMismatch(): void + { + $compressed = gzencode(self::JSON_BODY); + + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndDecodeWebhook($compressed, 'deadbeef', 'gzip'); + } + + public function testVerifyAndDecodeWebhookRejectsSignatureOverCompressedBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); + + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndDecodeWebhook($compressed, $sigOverCompressed, 'gzip'); + } +} From 401d2093865401f3bf1491b2233d542b5f49524f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 7 May 2026 12:33:08 +0200 Subject: [PATCH 02/13] [CHA-3071] feat: support base64 payload_encoding for SQS / SNS Extends `decompressWebhookBody` and `verifyAndDecodeWebhook` with an optional `$payloadEncoding` argument. When set to "base64" (the wrapper Stream applies for SQS / SNS firehose so the message stays valid UTF-8 over the queue), the body is base64-decoded before gzip decompression. The HMAC signature continues to be computed over the innermost (uncompressed, base64-decoded) JSON, so the verification rule is invariant across HTTP webhooks and SQS / SNS. `null` / `""` for payloadEncoding is a no-op, so the HTTP webhook path is byte-identical to before this change. Default value of `null` preserves backward compatibility with the previous 3-argument call. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 46 ++++++++++ lib/GetStream/StreamChat/Client.php | 72 ++++++++++++---- tests/unit/WebhookCompressionTest.php | 83 +++++++++++++++++++ 3 files changed, 185 insertions(+), 16 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 527ccca..9f71b4e 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -93,6 +93,52 @@ All webhook requests contain these headers: | X-Webhook-Attempt | Number of webhook request attempt starting from 1 | 1 | | X-Api-Key | Your application’s API key. Should be used to validate request signature | a1b23cdefgh4 | | X-Signature | HMAC signature of the request body. See Signature section | ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb | +| Content-Encoding | Compression algorithm applied to the request body. Only set when webhook compression is enabled on the app | `gzip` | + +### Compressed webhook bodies + +GZIP compression can be enabled for hooks payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The computation overhead introduced by the decompression step is usually negligible and offset by the much smaller payload. + +When payload compression is enabled, webhook HTTP requests will include the `Content-Encoding: gzip` header and the request body will be compressed with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) handle this transparently and strip the header before your handler runs — in that case the body you see is already raw JSON. + +Before enabling compression, make sure that: + +* Your backend integration is using a recent version of our official SDKs with compression support +* If you don't use an official SDK, make sure that your code supports receiving compressed payloads +* The payload signature check is done on the **uncompressed** payload + +Use `Client::verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON string ready to parse: + +```php +// $rawBody — bytes read straight from the HTTP request body (php://input) +// $signature — value of the X-Signature header +// $contentEncoding — value of the Content-Encoding header (null when absent) +$json = $client->verifyAndDecodeWebhook($rawBody, $signature, $contentEncoding); +$event = json_decode($json, true); +``` + +If you prefer to handle the steps yourself, the primitives are also exposed: + +```php +$json = $client->decompressWebhookBody($rawBody, $contentEncoding); +$valid = $client->verifyWebhook($json, $signature); +``` + +This SDK only supports `gzip`. Any other `Content-Encoding` value raises a `StreamException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `updateAppSettings()` or the dashboard. + +#### SQS / SNS payloads + +The same helper handles compressed messages delivered through SQS or SNS. There the compressed body is base64-wrapped so it stays valid UTF-8 over the queue. Pass the encoding values that arrived with the message (typically as message attributes such as `Content-Encoding`, `payload_encoding`, and `X-Signature`) as the extra `$payloadEncoding` argument: + +```php +// $body — the SQS Body / SNS Message string +// $signature — X-Signature attribute value +// $contentEncoding — "gzip" when compression is enabled, otherwise null +// $payloadEncoding — "base64" for SQS / SNS firehose payloads +$json = $client->verifyAndDecodeWebhook($body, $signature, $contentEncoding, $payloadEncoding); +``` + +The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. ## Webhook types diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 745708d..420edd6 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1238,22 +1238,55 @@ public function verifyWebhook(string $requestBody, string $XSignature): bool return hash_equals($signature, $XSignature); } - /** Decompress the body of an outbound webhook according to its Content-Encoding header. + /** Decompress the body of an outbound Stream message (webhook / SQS / SNS). * - * This SDK only supports `gzip`. A null or empty encoding returns the body unchanged. - * Any other value raises a {@see StreamException} so callers can surface a clear error - * and the operator can flip the app back to `gzip` on the dashboard. + * The returned string is the uncompressed JSON the server signed. Decode order + * matches the inverse of how the server built the message: + * 1. If `$payloadEncoding` is `"base64"`, base64-decode the body. Stream uses this + * wrapper for SQS / SNS firehose so the message stays valid UTF-8 over transport. + * 2. If `$contentEncoding` is `"gzip"`, gunzip the result. + * + * This SDK only supports `gzip` for compression and `base64` for the transport + * wrapper. Any other value raises a {@see StreamException} so callers can surface + * a clear error and the operator can flip the app back to `gzip` on the dashboard. + * `null` / `""` for either argument is a no-op, so the HTTP webhook path is + * identical to before this method existed. * * @throws StreamException */ - public function decompressWebhookBody(string $body, ?string $contentEncoding): string - { + public function decompressWebhookBody( + string $body, + ?string $contentEncoding, + ?string $payloadEncoding = null + ): string { + $working = $body; + + if ($payloadEncoding !== null) { + $pe = strtolower(trim($payloadEncoding)); + if ($pe !== '') { + if ($pe !== 'base64' && $pe !== 'b64') { + throw new StreamException( + 'unsupported webhook payload_encoding: ' . $payloadEncoding + . '. This SDK only supports base64.' + ); + } + $decoded = base64_decode($working, true); + if ($decoded === false) { + throw new StreamException( + 'failed to base64-decode webhook body (payload_encoding: ' + . $payloadEncoding . ')' + ); + } + $working = $decoded; + } + } + if ($contentEncoding === null) { - return $body; + return $working; } $encoding = strtolower(trim($contentEncoding)); if ($encoding === '') { - return $body; + return $working; } if ($encoding !== 'gzip') { throw new StreamException( @@ -1262,28 +1295,35 @@ public function decompressWebhookBody(string $body, ?string $contentEncoding): s . ' on the app config.' ); } - $decoded = @gzdecode($body); + $decoded = @gzdecode($working); if ($decoded === false) { throw new StreamException('failed to gzip-decode webhook body'); } return $decoded; } - /** Decompresses (when Content-Encoding is set) and verifies the HMAC signature of an - * outbound webhook request, returning the raw JSON body when the signature matches. + /** Decompresses and verifies the HMAC signature of an outbound Stream message, + * returning the raw JSON body when the signature matches. + * + * This is the recommended entry point for handlers, regardless of transport: + * + * - HTTP webhooks: `$body` is the request body, `$signature` comes from `X-Signature`, + * `$contentEncoding` from `Content-Encoding`, `$payloadEncoding` is `null`. + * - SQS / SNS firehose: `$body` is the SQS `Body` / SNS `Message`, the other three + * come from the corresponding message attributes. * - * This is the recommended entry point for webhook handlers when webhook compression - * may be enabled on the app: it handles every value of `Content-Encoding` Stream may - * send and keeps signature verification on the uncompressed body. + * The signature is always computed over the innermost (uncompressed, + * base64-decoded) JSON, so the verification rule is invariant across transports. * * @throws StreamException if the signature does not match or the body cannot be decoded */ public function verifyAndDecodeWebhook( string $body, string $signature, - ?string $contentEncoding + ?string $contentEncoding, + ?string $payloadEncoding = null ): string { - $decoded = $this->decompressWebhookBody($body, $contentEncoding); + $decoded = $this->decompressWebhookBody($body, $contentEncoding, $payloadEncoding); if (!$this->verifyWebhook($decoded, $signature)) { throw new StreamException('invalid webhook signature'); } diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 403bb5d..844bc71 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -134,4 +134,87 @@ public function testVerifyAndDecodeWebhookRejectsSignatureOverCompressedBytes(): $this->expectExceptionMessageMatches('/invalid webhook signature/'); $this->client->verifyAndDecodeWebhook($compressed, $sigOverCompressed, 'gzip'); } + + public function testDecompressWebhookBodyRoundTripsBase64Gzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + + $this->assertSame( + self::JSON_BODY, + $this->client->decompressWebhookBody($wrapped, 'gzip', 'base64') + ); + $this->assertSame( + self::JSON_BODY, + $this->client->decompressWebhookBody($wrapped, 'GZIP', 'BASE64') + ); + $this->assertSame( + self::JSON_BODY, + $this->client->decompressWebhookBody($wrapped, 'gzip', 'b64') + ); + } + + public function testDecompressWebhookBodyRoundTripsBase64Only(): void + { + $wrapped = base64_encode(self::JSON_BODY); + + $this->assertSame( + self::JSON_BODY, + $this->client->decompressWebhookBody($wrapped, null, 'base64') + ); + $this->assertSame( + self::JSON_BODY, + $this->client->decompressWebhookBody($wrapped, '', 'base64') + ); + } + + /** + * @dataProvider unsupportedPayloadEncodings + */ + public function testDecompressWebhookBodyRejectsUnsupportedPayloadEncoding(string $payloadEncoding): void + { + try { + $this->client->decompressWebhookBody(self::JSON_BODY, null, $payloadEncoding); + $this->fail("expected StreamException for payload_encoding '$payloadEncoding'"); + } catch (StreamException $e) { + $this->assertStringContainsString('payload_encoding', $e->getMessage()); + } + } + + public static function unsupportedPayloadEncodings(): array + { + return [ + 'hex' => ['hex'], + 'url' => ['url'], + 'binary' => ['binary'], + ]; + } + + public function testDecompressWebhookBodyThrowsOnInvalidBase64(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/base64-decode/'); + $this->client->decompressWebhookBody('not!valid!base64', null, 'base64'); + } + + public function testVerifyAndDecodeWebhookBase64GzipHappyPath(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + + $decoded = $this->client->verifyAndDecodeWebhook($wrapped, $sig, 'gzip', 'base64'); + $this->assertSame(self::JSON_BODY, $decoded); + } + + public function testVerifyAndDecodeWebhookRejectsSignatureOverWrappedBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET); + + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndDecodeWebhook($wrapped, $sigOverWrapped, 'gzip', 'base64'); + } } From 5561cb6fd0e859866ce52dfd6f0da50d55e0de8f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 15:55:29 +0200 Subject: [PATCH 03/13] refactor(webhooks): switch to verifyAndParse* API (CHA-3071) Replaces verifyAndDecodeWebhook / decompressWebhookBody with the cross-SDK contract documented at https://getstream.io/chat/docs/node/webhooks_overview/. Helpers on Client: Static primitives: Client::ungzipPayload - gzip magic-byte detection + inflate Client::decodeSqsPayload - base64 then ungzip-if-magic Client::decodeSnsPayload - alias for decodeSqsPayload Client::verifySignature - constant-time HMAC-SHA256 comparison (parameter order matches the cross-SDK spec: body, signature, secret) Client::parseEvent - JSON -> array (typed event lands later) Instance composite (return parsed event array): \$client->verifyAndParseWebhook(\$body, \$signature) \$client->verifyAndParseSqs(\$messageBody, \$signature) \$client->verifyAndParseSns(\$message, \$signature) The composite functions auto-detect compression from body bytes, so the same handler stays correct whether or not Stream is currently compressing payloads, and behind middleware that auto-decompresses. The legacy \$client->verifyWebhook(\$body, \$signature) bool helper is kept for backward compatibility (now delegates to verifySignature). Co-authored-by: Cursor --- lib/GetStream/StreamChat/Client.php | 200 +++++++++++++--------- tests/unit/WebhookCompressionTest.php | 236 +++++++++++++------------- 2 files changed, 237 insertions(+), 199 deletions(-) diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 420edd6..a38fc56 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1225,109 +1225,147 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b /** Verify the signature added to a webhook event. * - * The signature is always computed over the uncompressed JSON body. When webhook - * compression is enabled on the app the request body must be decompressed (via - * {@see decompressWebhookBody()} or {@see verifyAndDecodeWebhook()}) before being - * passed to this method. + * Backward-compatible boolean helper. New integrations should call + * {@see verifyAndParseWebhook()} (or the SQS / SNS variants), which also handle + * gzip payload compression and return the parsed event. + * * @throws StreamException */ public function verifyWebhook(string $requestBody, string $XSignature): bool { - $signature = hash_hmac("sha256", $requestBody, $this->apiSecret); - - return hash_equals($signature, $XSignature); + return self::verifySignature($requestBody, $XSignature, $this->apiSecret); } - /** Decompress the body of an outbound Stream message (webhook / SQS / SNS). + /** Constant-time HMAC-SHA256 verification of `$signature` against the digest of + * `$body` using `$secret` as the key. * - * The returned string is the uncompressed JSON the server signed. Decode order - * matches the inverse of how the server built the message: - * 1. If `$payloadEncoding` is `"base64"`, base64-decode the body. Stream uses this - * wrapper for SQS / SNS firehose so the message stays valid UTF-8 over transport. - * 2. If `$contentEncoding` is `"gzip"`, gunzip the result. + * The signature is always computed over the **uncompressed** JSON bytes, so + * callers that decoded a gzipped or base64-wrapped payload must pass the + * inflated bytes here. + */ + public static function verifySignature(string $body, string $signature, string $secret): bool + { + return hash_equals(hash_hmac('sha256', $body, $secret), $signature); + } + + /** Returns `$body` unchanged unless it starts with the gzip magic (`1f 8b 08`), + * in which case the gzip stream is inflated and the decompressed bytes are + * returned. * - * This SDK only supports `gzip` for compression and `base64` for the transport - * wrapper. Any other value raises a {@see StreamException} so callers can surface - * a clear error and the operator can flip the app back to `gzip` on the dashboard. - * `null` / `""` for either argument is a no-op, so the HTTP webhook path is - * identical to before this method existed. + * Magic-byte detection (rather than relying on a header) keeps the same + * handler correct when middleware auto-decompresses the request before your + * code sees it. * - * @throws StreamException + * @throws StreamException when the body has the gzip magic but cannot be + * inflated. */ - public function decompressWebhookBody( - string $body, - ?string $contentEncoding, - ?string $payloadEncoding = null - ): string { - $working = $body; - - if ($payloadEncoding !== null) { - $pe = strtolower(trim($payloadEncoding)); - if ($pe !== '') { - if ($pe !== 'base64' && $pe !== 'b64') { - throw new StreamException( - 'unsupported webhook payload_encoding: ' . $payloadEncoding - . '. This SDK only supports base64.' - ); - } - $decoded = base64_decode($working, true); - if ($decoded === false) { - throw new StreamException( - 'failed to base64-decode webhook body (payload_encoding: ' - . $payloadEncoding . ')' - ); - } - $working = $decoded; - } - } - - if ($contentEncoding === null) { - return $working; - } - $encoding = strtolower(trim($contentEncoding)); - if ($encoding === '') { - return $working; - } - if ($encoding !== 'gzip') { - throw new StreamException( - 'unsupported webhook Content-Encoding: ' . $contentEncoding - . '. This SDK only supports gzip; set webhook_compression_algorithm to "gzip"' - . ' on the app config.' - ); + public static function ungzipPayload(string $body): string + { + if (substr($body, 0, 3) !== "\x1f\x8b\x08") { + return $body; } - $decoded = @gzdecode($working); + $decoded = @gzdecode($body); if ($decoded === false) { - throw new StreamException('failed to gzip-decode webhook body'); + throw new StreamException('failed to decompress gzip payload'); } return $decoded; } - /** Decompresses and verifies the HMAC signature of an outbound Stream message, - * returning the raw JSON body when the signature matches. + /** Reverses the SQS firehose envelope: the message `Body` is base64-decoded + * and, when the result begins with the gzip magic, gzip-decompressed. The + * same call works whether or not Stream is currently compressing payloads. * - * This is the recommended entry point for handlers, regardless of transport: + * @throws StreamException when the input is not valid base64 or the inner + * gzip stream cannot be inflated. + */ + public static function decodeSqsPayload(string $body): string + { + $decoded = base64_decode($body, true); + if ($decoded === false) { + throw new StreamException('failed to base64-decode payload'); + } + return self::ungzipPayload($decoded); + } + + /** Identical to {@see decodeSqsPayload()}; exposed under both names so call + * sites read intent. * - * - HTTP webhooks: `$body` is the request body, `$signature` comes from `X-Signature`, - * `$contentEncoding` from `Content-Encoding`, `$payloadEncoding` is `null`. - * - SQS / SNS firehose: `$body` is the SQS `Body` / SNS `Message`, the other three - * come from the corresponding message attributes. + * @throws StreamException + */ + public static function decodeSnsPayload(string $message): string + { + return self::decodeSqsPayload($message); + } + + /** Parse a JSON-encoded webhook event into an associative array. * - * The signature is always computed over the innermost (uncompressed, - * base64-decoded) JSON, so the verification rule is invariant across transports. + * The PHP SDK currently returns the parsed JSON as an array; typed event + * classes will land in a future release. The function name matches the + * documented primitive so callers can swap in a typed parser later without + * changing call sites. * - * @throws StreamException if the signature does not match or the body cannot be decoded - */ - public function verifyAndDecodeWebhook( - string $body, - string $signature, - ?string $contentEncoding, - ?string $payloadEncoding = null - ): string { - $decoded = $this->decompressWebhookBody($body, $contentEncoding, $payloadEncoding); - if (!$this->verifyWebhook($decoded, $signature)) { + * @return array + * @throws StreamException when the bytes are not valid JSON. + */ + public static function parseEvent(string $payload): array + { + try { + $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); + } + if (!is_array($event)) { + throw new StreamException('failed to parse webhook event: top-level value is not an object'); + } + return $event; + } + + /** + * @return array + * @throws StreamException + */ + private static function verifyAndParseInternal(string $payload, string $signature, string $secret): array + { + if (!self::verifySignature($payload, $signature, $secret)) { throw new StreamException('invalid webhook signature'); } - return $decoded; + return self::parseEvent($payload); + } + + /** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return + * the parsed event. + * + * @return array + * @throws StreamException when the signature does not match or the gzip + * envelope is malformed. + */ + public function verifyAndParseWebhook(string $body, string $signature): array + { + return self::verifyAndParseInternal(self::ungzipPayload($body), $signature, $this->apiSecret); + } + + /** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC + * `$signature` from the `X-Signature` message attribute, and return the + * parsed event. + * + * @return array + * @throws StreamException + */ + public function verifyAndParseSqs(string $messageBody, string $signature): array + { + return self::verifyAndParseInternal(self::decodeSqsPayload($messageBody), $signature, $this->apiSecret); + } + + /** Decode the SNS notification `Message` (identical to SQS handling), verify + * the HMAC `$signature` from the `X-Signature` message attribute, and return + * the parsed event. + * + * @return array + * @throws StreamException + */ + public function verifyAndParseSns(string $message, string $signature): array + { + return self::verifyAndParseInternal(self::decodeSnsPayload($message), $signature, $this->apiSecret); } /** Searches for messages. diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 844bc71..ee3aa27 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -26,195 +26,195 @@ private function sign(string $body): string return hash_hmac('sha256', $body, self::API_SECRET); } - public function testDecompressWebhookBodyPassthroughWhenEncodingNull(): void + public function testUngzipPayloadPassthroughPlainBytes(): void { - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, null)); + $this->assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY)); } - public function testDecompressWebhookBodyPassthroughWhenEncodingEmpty(): void - { - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, '')); - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, ' ')); - } - - public function testDecompressWebhookBodyRoundTripsGzip(): void + public function testUngzipPayloadInflatesGzipBytes(): void { $compressed = gzencode(self::JSON_BODY); $this->assertNotFalse($compressed); - $this->assertNotEquals(self::JSON_BODY, $compressed); + $this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed)); + } - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'gzip')); + public function testUngzipPayloadEmptyInput(): void + { + $this->assertSame('', Client::ungzipPayload('')); } - public function testDecompressWebhookBodyHandlesEncodingCaseInsensitively(): void + public function testUngzipPayloadShortInputBelowMagicLength(): void { - $compressed = gzencode(self::JSON_BODY); - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'GZIP')); - $this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, ' gzip ')); + $this->assertSame('ab', Client::ungzipPayload('ab')); } - /** - * @dataProvider nonGzipEncodings - */ - public function testDecompressWebhookBodyRejectsEveryNonGzipEncoding(string $encoding): void + public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void { - try { - $this->client->decompressWebhookBody(self::JSON_BODY, $encoding); - $this->fail("expected StreamException for encoding '$encoding'"); - } catch (StreamException $e) { - $this->assertStringContainsString('unsupported', $e->getMessage()); - $this->assertStringContainsString('gzip', $e->getMessage()); - } + $bad = "\x1f\x8b\x08\x00\x00\x00"; + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/decompress gzip/'); + Client::ungzipPayload($bad); } - public static function nonGzipEncodings(): array + public function testDecodeSqsPayloadBase64Only(): void { - return [ - 'brotli short' => ['br'], - 'brotli long' => ['brotli'], - 'zstd' => ['zstd'], - 'deflate' => ['deflate'], - 'compress' => ['compress'], - 'lz4' => ['lz4'], - ]; + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode(self::JSON_BODY)) + ); } - public function testDecompressWebhookBodyThrowsOnInvalidGzipBytes(): void + public function testDecodeSqsPayloadBase64Plusgzip(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/failed to gzip-decode/'); - $this->client->decompressWebhookBody('not actually gzip', 'gzip'); + $compressed = gzencode(self::JSON_BODY); + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode($compressed)) + ); } - public function testVerifyWebhookUsesConstantTimeComparison(): void + public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void { - $sig = $this->sign(self::JSON_BODY); - $this->assertTrue($this->client->verifyWebhook(self::JSON_BODY, $sig)); - $this->assertFalse($this->client->verifyWebhook(self::JSON_BODY, 'deadbeef')); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/base64-decode/'); + Client::decodeSqsPayload('!!!not-base64!!!'); } - public function testVerifyAndDecodeWebhookGzipHappyPath(): void + public function testDecodeSnsPayloadAliasesDecodeSqsPayload(): void { $compressed = gzencode(self::JSON_BODY); - $sig = $this->sign(self::JSON_BODY); - - $decoded = $this->client->verifyAndDecodeWebhook($compressed, $sig, 'gzip'); - $this->assertSame(self::JSON_BODY, $decoded); + $wrapped = base64_encode($compressed); + $this->assertSame( + Client::decodeSqsPayload($wrapped), + Client::decodeSnsPayload($wrapped) + ); } - public function testVerifyAndDecodeWebhookPassthroughHappyPath(): void + public function testVerifySignatureMatching(): void { $sig = $this->sign(self::JSON_BODY); + $this->assertTrue(Client::verifySignature(self::JSON_BODY, $sig, self::API_SECRET)); + } - $this->assertSame( - self::JSON_BODY, - $this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, null) - ); - $this->assertSame( - self::JSON_BODY, - $this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, '') + public function testVerifySignatureMismatched(): void + { + $this->assertFalse( + Client::verifySignature(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET) ); } - public function testVerifyAndDecodeWebhookThrowsOnSignatureMismatch(): void + public function testVerifySignatureRejectsSignatureOverCompressedBytes(): void { $compressed = gzencode(self::JSON_BODY); + $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); + $this->assertFalse( + Client::verifySignature(self::JSON_BODY, $sigOverCompressed, self::API_SECRET) + ); + } - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); - $this->client->verifyAndDecodeWebhook($compressed, 'deadbeef', 'gzip'); + public function testParseEventKnownEventType(): void + { + $event = Client::parseEvent(self::JSON_BODY); + $this->assertSame('message.new', $event['type']); + $this->assertSame('the quick brown fox', $event['message']['text']); } - public function testVerifyAndDecodeWebhookRejectsSignatureOverCompressedBytes(): void + public function testParseEventUnknownTypeStillParses(): void { - $compressed = gzencode(self::JSON_BODY); - $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); + $event = Client::parseEvent('{"type":"a.future.event","custom":42}'); + $this->assertSame('a.future.event', $event['type']); + $this->assertSame(42, $event['custom']); + } + public function testParseEventMalformedJsonThrows(): void + { $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); - $this->client->verifyAndDecodeWebhook($compressed, $sigOverCompressed, 'gzip'); + $this->expectExceptionMessageMatches('/parse webhook event/'); + Client::parseEvent('not json'); } - public function testDecompressWebhookBodyRoundTripsBase64Gzip(): void + public function testVerifyAndParseWebhookPlain(): void { - $compressed = gzencode(self::JSON_BODY); - $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseWebhook(self::JSON_BODY, $sig); + $this->assertSame('message.new', $event['type']); + } - $this->assertSame( - self::JSON_BODY, - $this->client->decompressWebhookBody($wrapped, 'gzip', 'base64') - ); - $this->assertSame( - self::JSON_BODY, - $this->client->decompressWebhookBody($wrapped, 'GZIP', 'BASE64') - ); - $this->assertSame( - self::JSON_BODY, - $this->client->decompressWebhookBody($wrapped, 'gzip', 'b64') - ); + public function testVerifyAndParseWebhookGzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseWebhook($compressed, $sig); + $this->assertSame('message.new', $event['type']); } - public function testDecompressWebhookBodyRoundTripsBase64Only(): void + public function testVerifyAndParseWebhookSignatureMismatch(): void { - $wrapped = base64_encode(self::JSON_BODY); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); + } - $this->assertSame( - self::JSON_BODY, - $this->client->decompressWebhookBody($wrapped, null, 'base64') - ); - $this->assertSame( - self::JSON_BODY, - $this->client->decompressWebhookBody($wrapped, '', 'base64') - ); + public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseWebhook($compressed, $sigOverCompressed); } - /** - * @dataProvider unsupportedPayloadEncodings - */ - public function testDecompressWebhookBodyRejectsUnsupportedPayloadEncoding(string $payloadEncoding): void + public function testVerifyAndParseSqsBase64Only(): void { - try { - $this->client->decompressWebhookBody(self::JSON_BODY, null, $payloadEncoding); - $this->fail("expected StreamException for payload_encoding '$payloadEncoding'"); - } catch (StreamException $e) { - $this->assertStringContainsString('payload_encoding', $e->getMessage()); - } + $wrapped = base64_encode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $this->assertSame('message.new', $event['type']); } - public static function unsupportedPayloadEncodings(): array + public function testVerifyAndParseSqsBase64Plusgzip(): void { - return [ - 'hex' => ['hex'], - 'url' => ['url'], - 'binary' => ['binary'], - ]; + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $this->assertSame('message.new', $event['type']); } - public function testDecompressWebhookBodyThrowsOnInvalidBase64(): void + public function testVerifyAndParseSqsRejectsSignatureOverWrappedBytes(): void { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET); $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/base64-decode/'); - $this->client->decompressWebhookBody('not!valid!base64', null, 'base64'); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseSqs($wrapped, $sigOverWrapped); } - public function testVerifyAndDecodeWebhookBase64GzipHappyPath(): void + public function testVerifyAndParseSnsRoundTrip(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); $sig = $this->sign(self::JSON_BODY); - - $decoded = $this->client->verifyAndDecodeWebhook($wrapped, $sig, 'gzip', 'base64'); - $this->assertSame(self::JSON_BODY, $decoded); + $event = $this->client->verifyAndParseSns($wrapped, $sig); + $this->assertSame('message.new', $event['type']); } - public function testVerifyAndDecodeWebhookRejectsSignatureOverWrappedBytes(): void + public function testVerifyAndParseSnsMatchesSqs(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + $this->client->verifyAndParseSqs($wrapped, $sig), + $this->client->verifyAndParseSns($wrapped, $sig) + ); + } - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); - $this->client->verifyAndDecodeWebhook($wrapped, $sigOverWrapped, 'gzip', 'base64'); + public function testVerifyWebhookBackwardCompatibility(): void + { + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue($this->client->verifyWebhook(self::JSON_BODY, $sig)); + $this->assertFalse($this->client->verifyWebhook(self::JSON_BODY, 'deadbeef')); } } From 49f5417f18c349e2f323eaffc20a1a6490c01cbd Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 16:53:20 +0200 Subject: [PATCH 04/13] refactor(webhooks): use 2-byte gzip magic per RFC 1952 (CHA-3071) RFC 1952 defines the gzip magic number as the two-byte sequence 1F 8B; the third byte (CM) is informational and not part of the identifier. Trim the magic check from three bytes to two to match the spec and stay consistent with the reference implementations in the public docs. Co-authored-by: Cursor --- lib/GetStream/StreamChat/Client.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index a38fc56..98af33f 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1248,9 +1248,9 @@ public static function verifySignature(string $body, string $signature, string $ return hash_equals(hash_hmac('sha256', $body, $secret), $signature); } - /** Returns `$body` unchanged unless it starts with the gzip magic (`1f 8b 08`), - * in which case the gzip stream is inflated and the decompressed bytes are - * returned. + /** Returns `$body` unchanged unless it starts with the gzip magic + * (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and + * the decompressed bytes are returned. * * Magic-byte detection (rather than relying on a header) keeps the same * handler correct when middleware auto-decompresses the request before your @@ -1261,7 +1261,7 @@ public static function verifySignature(string $body, string $signature, string $ */ public static function ungzipPayload(string $body): string { - if (substr($body, 0, 3) !== "\x1f\x8b\x08") { + if (substr($body, 0, 2) !== "\x1f\x8b") { return $body; } $decoded = @gzdecode($body); From f987cfc0135ac2f8bdb9711b324cdf2973244279 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 17:22:01 +0200 Subject: [PATCH 05/13] Add Webhook static class for cross-SDK 3-arg contract Mirrors the Ruby StreamChat::Webhook module / Java App.* / .NET WebhookHelpers shape: `Webhook::verifyAndParseWebhook($body, $signature, $secret)` (and the SQS / SNS variants) are now available as static methods with an explicit `secret` argument, alongside the primitives ungzipPayload, decodeSqsPayload, decodeSnsPayload, verifySignature, parseEvent. `Client::verifyAndParseWebhook` (and the SQS / SNS variants) still work as 2-arg instance methods that pull the secret from the configured client; they now delegate to the new static helpers so the two surfaces stay in lockstep. Tests cover the new static class, the parity between the two surfaces, and the existing regression cases. Co-authored-by: Cursor --- lib/GetStream/StreamChat/Client.php | 27 ++---- lib/GetStream/StreamChat/Webhook.php | 124 ++++++++++++++++++++++++++ tests/unit/WebhookCompressionTest.php | 74 +++++++++++++++ 3 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 lib/GetStream/StreamChat/Webhook.php diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 98af33f..fed05bb 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1320,20 +1320,9 @@ public static function parseEvent(string $payload): array return $event; } - /** - * @return array - * @throws StreamException - */ - private static function verifyAndParseInternal(string $payload, string $signature, string $secret): array - { - if (!self::verifySignature($payload, $signature, $secret)) { - throw new StreamException('invalid webhook signature'); - } - return self::parseEvent($payload); - } - /** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return - * the parsed event. + * the parsed event. Delegates to {@see Webhook::verifyAndParseWebhook()} + * with this client's API secret. * * @return array * @throws StreamException when the signature does not match or the gzip @@ -1341,31 +1330,33 @@ private static function verifyAndParseInternal(string $payload, string $signatur */ public function verifyAndParseWebhook(string $body, string $signature): array { - return self::verifyAndParseInternal(self::ungzipPayload($body), $signature, $this->apiSecret); + return Webhook::verifyAndParseWebhook($body, $signature, $this->apiSecret); } /** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC * `$signature` from the `X-Signature` message attribute, and return the - * parsed event. + * parsed event. Delegates to {@see Webhook::verifyAndParseSqs()} with this + * client's API secret. * * @return array * @throws StreamException */ public function verifyAndParseSqs(string $messageBody, string $signature): array { - return self::verifyAndParseInternal(self::decodeSqsPayload($messageBody), $signature, $this->apiSecret); + return Webhook::verifyAndParseSqs($messageBody, $signature, $this->apiSecret); } /** Decode the SNS notification `Message` (identical to SQS handling), verify * the HMAC `$signature` from the `X-Signature` message attribute, and return - * the parsed event. + * the parsed event. Delegates to {@see Webhook::verifyAndParseSns()} with + * this client's API secret. * * @return array * @throws StreamException */ public function verifyAndParseSns(string $message, string $signature): array { - return self::verifyAndParseInternal(self::decodeSnsPayload($message), $signature, $this->apiSecret); + return Webhook::verifyAndParseSns($message, $signature, $this->apiSecret); } /** Searches for messages. diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php new file mode 100644 index 0000000..ebb8e79 --- /dev/null +++ b/lib/GetStream/StreamChat/Webhook.php @@ -0,0 +1,124 @@ + + * @throws StreamException when the bytes are not valid JSON. + */ + public static function parseEvent(string $payload): array + { + return Client::parseEvent($payload); + } + + /** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return + * the parsed event. + * + * @return array + * @throws StreamException when the signature does not match or the gzip + * envelope is malformed. + */ + public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array + { + $inflated = self::ungzipPayload($body); + if (!self::verifySignature($inflated, $signature, $secret)) { + throw new StreamException('invalid webhook signature'); + } + return self::parseEvent($inflated); + } + + /** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC + * `$signature` from the `X-Signature` message attribute, and return the + * parsed event. + * + * @return array + * @throws StreamException + */ + public static function verifyAndParseSqs(string $messageBody, string $signature, string $secret): array + { + $inflated = self::decodeSqsPayload($messageBody); + if (!self::verifySignature($inflated, $signature, $secret)) { + throw new StreamException('invalid webhook signature'); + } + return self::parseEvent($inflated); + } + + /** Decode the SNS notification `Message` (identical to SQS handling), verify + * the HMAC `$signature` from the `X-Signature` message attribute, and return + * the parsed event. + * + * @return array + * @throws StreamException + */ + public static function verifyAndParseSns(string $message, string $signature, string $secret): array + { + $inflated = self::decodeSnsPayload($message); + if (!self::verifySignature($inflated, $signature, $secret)) { + throw new StreamException('invalid webhook signature'); + } + return self::parseEvent($inflated); + } +} diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index ee3aa27..10e049e 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -6,6 +6,7 @@ use GetStream\StreamChat\Client; use GetStream\StreamChat\StreamException; +use GetStream\StreamChat\Webhook; use PHPUnit\Framework\TestCase; class WebhookCompressionTest extends TestCase @@ -217,4 +218,77 @@ public function testVerifyWebhookBackwardCompatibility(): void $this->assertTrue($this->client->verifyWebhook(self::JSON_BODY, $sig)); $this->assertFalse($this->client->verifyWebhook(self::JSON_BODY, 'deadbeef')); } + + public function testWebhookStaticPrimitivesMatchClient(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $this->assertSame(Client::ungzipPayload($compressed), Webhook::ungzipPayload($compressed)); + $this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped)); + $this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped)); + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue(Webhook::verifySignature(self::JSON_BODY, $sig, self::API_SECRET)); + $this->assertSame(Client::parseEvent(self::JSON_BODY), Webhook::parseEvent(self::JSON_BODY)); + } + + public function testWebhookVerifyAndParseWebhookStaticPlain(): void + { + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseWebhook(self::JSON_BODY, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseWebhookStaticGzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseWebhook($compressed, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET); + } + + public function testWebhookVerifyAndParseSqsStatic(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseSnsStaticMatchesSqs(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), + Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET) + ); + } + + public function testClientInstanceCompositesDelegateToWebhook(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + Webhook::verifyAndParseWebhook($compressed, $sig, self::API_SECRET), + $this->client->verifyAndParseWebhook($compressed, $sig) + ); + $this->assertSame( + Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), + $this->client->verifyAndParseSqs($wrapped, $sig) + ); + $this->assertSame( + Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET), + $this->client->verifyAndParseSns($wrapped, $sig) + ); + } } From b6870f27fda6d99f6b1f7f4e5efc8fdbabcfd19b Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:14:09 +0200 Subject: [PATCH 06/13] docs(webhooks): align compression docs with shipped API (CHA-3071) Replace the references to phantom helpers (verifyAndDecodeWebhook, decompressWebhookBody, $contentEncoding / $payloadEncoding arguments) with the API actually exposed on the Client and Webhook classes: verifyAndParseWebhook, verifyAndParseSqs, verifyAndParseSns and the underlying ungzipPayload / verifySignature / parseEvent primitives. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 9f71b4e..81867a6 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -99,7 +99,7 @@ All webhook requests contain these headers: GZIP compression can be enabled for hooks payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The computation overhead introduced by the decompression step is usually negligible and offset by the much smaller payload. -When payload compression is enabled, webhook HTTP requests will include the `Content-Encoding: gzip` header and the request body will be compressed with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) handle this transparently and strip the header before your handler runs — in that case the body you see is already raw JSON. +When payload compression is enabled, webhook HTTP requests are sent with the `Content-Encoding: gzip` header and the request body is GZIP-compressed. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) decompress the body transparently before your handler runs — in that case the bytes you receive are already raw JSON. The PHP SDK detects compression from the [RFC 1952](https://datatracker.ietf.org/doc/html/rfc1952) gzip magic bytes (`1f 8b`), so the same handler works with or without that middleware. Before enabling compression, make sure that: @@ -107,35 +107,53 @@ Before enabling compression, make sure that: * If you don't use an official SDK, make sure that your code supports receiving compressed payloads * The payload signature check is done on the **uncompressed** payload -Use `Client::verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON string ready to parse: +Use `Client::verifyAndParseWebhook` to handle decompression, HMAC verification, and JSON parsing in one call. It returns the parsed event as an associative array, or throws `StreamException` if the signature is invalid or the body cannot be decompressed/parsed: ```php -// $rawBody — bytes read straight from the HTTP request body (php://input) -// $signature — value of the X-Signature header -// $contentEncoding — value of the Content-Encoding header (null when absent) -$json = $client->verifyAndDecodeWebhook($rawBody, $signature, $contentEncoding); -$event = json_decode($json, true); +// $rawBody — bytes read straight from the HTTP request body (php://input) +// $signature — value of the X-Signature header +$event = $client->verifyAndParseWebhook($rawBody, $signature); +// $event['type'], $event['message'], $event['user'], ... ``` -If you prefer to handle the steps yourself, the primitives are also exposed: +The legacy `verifyWebhook($body, $signature): bool` helper still works for plain (uncompressed) bodies and is kept for backward compatibility. + +If you don't have a `Client` instance handy (for example in a queue consumer or a Lambda), the same logic is exposed as a stateless static helper that takes the API secret as the third argument: ```php -$json = $client->decompressWebhookBody($rawBody, $contentEncoding); -$valid = $client->verifyWebhook($json, $signature); +use GetStream\StreamChat\Webhook; + +$event = Webhook::verifyAndParseWebhook($rawBody, $signature, $apiSecret); ``` -This SDK only supports `gzip`. Any other `Content-Encoding` value raises a `StreamException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `updateAppSettings()` or the dashboard. +The composite is built from three primitives that you can also call individually: + +```php +use GetStream\StreamChat\Webhook; + +// 1. Inflate the body if it starts with the gzip magic; otherwise pass through. +$json = Webhook::ungzipPayload($rawBody); + +// 2. Constant-time HMAC-SHA256 of the *uncompressed* body against the X-Signature header. +$valid = Webhook::verifySignature($json, $signature, $apiSecret); + +// 3. Decode the JSON event into an associative array. +$event = Webhook::parseEvent($json); +``` #### SQS / SNS payloads -The same helper handles compressed messages delivered through SQS or SNS. There the compressed body is base64-wrapped so it stays valid UTF-8 over the queue. Pass the encoding values that arrived with the message (typically as message attributes such as `Content-Encoding`, `payload_encoding`, and `X-Signature`) as the extra `$payloadEncoding` argument: +The same logic handles messages delivered through SQS or SNS. There the body is base64-wrapped so it stays valid UTF-8 over the queue, and the inner bytes may also be gzip-compressed. Use the dedicated composites — they base64-decode and (when compressed) gunzip before verifying: ```php -// $body — the SQS Body / SNS Message string -// $signature — X-Signature attribute value -// $contentEncoding — "gzip" when compression is enabled, otherwise null -// $payloadEncoding — "base64" for SQS / SNS firehose payloads -$json = $client->verifyAndDecodeWebhook($body, $signature, $contentEncoding, $payloadEncoding); +// $messageBody — the SQS Body / SNS Message string (base64, optionally gzipped inside) +// $signature — X-Signature message attribute value +$event = $client->verifyAndParseSqs($messageBody, $signature); +$event = $client->verifyAndParseSns($messageBody, $signature); + +// Stateless equivalents: +$event = Webhook::verifyAndParseSqs($messageBody, $signature, $apiSecret); +$event = Webhook::verifyAndParseSns($messageBody, $signature, $apiSecret); ``` The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. From 238c5b8e7bc3c6660aee3c5ad0acc697ee1e549f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:16:07 +0200 Subject: [PATCH 07/13] refactor(webhooks): make Webhook the canonical home of the contract (CHA-3071) The cross-SDK contract puts the static helpers on the Webhook class (`Webhook::verifyAndParseWebhook(body, signature, secret)`); other SDKs in the org follow the same shape. Move the actual implementations of ungzipPayload, decodeSqsPayload, decodeSnsPayload, verifySignature, parseEvent, and the verifyAndParse* composites onto Webhook, and reduce the Client static counterparts to one-line delegators kept for backward compatibility. Behaviour is unchanged; the existing test suite (covering both Client::* and Webhook::*) still passes. Co-authored-by: Cursor --- lib/GetStream/StreamChat/Client.php | 74 ++++++++++------------------ lib/GetStream/StreamChat/Webhook.php | 41 ++++++++++++--- 2 files changed, 61 insertions(+), 54 deletions(-) diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index fed05bb..eeb933a 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1233,91 +1233,69 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b */ public function verifyWebhook(string $requestBody, string $XSignature): bool { - return self::verifySignature($requestBody, $XSignature, $this->apiSecret); + return Webhook::verifySignature($requestBody, $XSignature, $this->apiSecret); } - /** Constant-time HMAC-SHA256 verification of `$signature` against the digest of - * `$body` using `$secret` as the key. + /** Constant-time HMAC-SHA256 verification of `$signature` against the digest + * of `$body` using `$secret` as the key. * - * The signature is always computed over the **uncompressed** JSON bytes, so - * callers that decoded a gzipped or base64-wrapped payload must pass the - * inflated bytes here. + * Backward-compatible alias for {@see Webhook::verifySignature()}; new code + * should call the canonical helper directly. */ public static function verifySignature(string $body, string $signature, string $secret): bool { - return hash_equals(hash_hmac('sha256', $body, $secret), $signature); + return Webhook::verifySignature($body, $signature, $secret); } - /** Returns `$body` unchanged unless it starts with the gzip magic - * (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and - * the decompressed bytes are returned. + /** Returns `$body` unchanged unless it starts with the gzip magic, in which + * case the gzip stream is inflated and the decompressed bytes are returned. * - * Magic-byte detection (rather than relying on a header) keeps the same - * handler correct when middleware auto-decompresses the request before your - * code sees it. + * Backward-compatible alias for {@see Webhook::ungzipPayload()}; new code + * should call the canonical helper directly. * - * @throws StreamException when the body has the gzip magic but cannot be - * inflated. + * @throws StreamException */ public static function ungzipPayload(string $body): string { - if (substr($body, 0, 2) !== "\x1f\x8b") { - return $body; - } - $decoded = @gzdecode($body); - if ($decoded === false) { - throw new StreamException('failed to decompress gzip payload'); - } - return $decoded; + return Webhook::ungzipPayload($body); } - /** Reverses the SQS firehose envelope: the message `Body` is base64-decoded - * and, when the result begins with the gzip magic, gzip-decompressed. The - * same call works whether or not Stream is currently compressing payloads. + /** Reverses the SQS firehose envelope (base64 + optional gzip). * - * @throws StreamException when the input is not valid base64 or the inner - * gzip stream cannot be inflated. + * Backward-compatible alias for {@see Webhook::decodeSqsPayload()}; new code + * should call the canonical helper directly. + * + * @throws StreamException */ public static function decodeSqsPayload(string $body): string { - $decoded = base64_decode($body, true); - if ($decoded === false) { - throw new StreamException('failed to base64-decode payload'); - } - return self::ungzipPayload($decoded); + return Webhook::decodeSqsPayload($body); } /** Identical to {@see decodeSqsPayload()}; exposed under both names so call * sites read intent. * + * Backward-compatible alias for {@see Webhook::decodeSnsPayload()}; new code + * should call the canonical helper directly. + * * @throws StreamException */ public static function decodeSnsPayload(string $message): string { - return self::decodeSqsPayload($message); + return Webhook::decodeSnsPayload($message); } /** Parse a JSON-encoded webhook event into an associative array. * - * The PHP SDK currently returns the parsed JSON as an array; typed event - * classes will land in a future release. The function name matches the - * documented primitive so callers can swap in a typed parser later without - * changing call sites. + * Backward-compatible alias for {@see Webhook::parseEvent()}; new code + * should call the canonical helper directly. * * @return array - * @throws StreamException when the bytes are not valid JSON. + * @throws StreamException */ public static function parseEvent(string $payload): array { - try { - $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); - } - if (!is_array($event)) { - throw new StreamException('failed to parse webhook event: top-level value is not an object'); - } - return $event; + return Webhook::parseEvent($payload); } /** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php index ebb8e79..3bf44a8 100644 --- a/lib/GetStream/StreamChat/Webhook.php +++ b/lib/GetStream/StreamChat/Webhook.php @@ -28,30 +28,46 @@ class Webhook */ public static function verifySignature(string $body, string $signature, string $secret): bool { - return Client::verifySignature($body, $signature, $secret); + return hash_equals(hash_hmac('sha256', $body, $secret), $signature); } /** Returns `$body` unchanged unless it starts with the gzip magic * (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and * the decompressed bytes are returned. * + * Magic-byte detection (rather than relying on a header) keeps the same + * handler correct when middleware auto-decompresses the request before your + * code sees it. + * * @throws StreamException when the body has the gzip magic but cannot be * inflated. */ public static function ungzipPayload(string $body): string { - return Client::ungzipPayload($body); + if (substr($body, 0, 2) !== "\x1f\x8b") { + return $body; + } + $decoded = @gzdecode($body); + if ($decoded === false) { + throw new StreamException('failed to decompress gzip payload'); + } + return $decoded; } /** Reverses the SQS firehose envelope: the message `Body` is base64-decoded - * and, when the result begins with the gzip magic, gzip-decompressed. + * and, when the result begins with the gzip magic, gzip-decompressed. The + * same call works whether or not Stream is currently compressing payloads. * * @throws StreamException when the input is not valid base64 or the inner * gzip stream cannot be inflated. */ public static function decodeSqsPayload(string $body): string { - return Client::decodeSqsPayload($body); + $decoded = base64_decode($body, true); + if ($decoded === false) { + throw new StreamException('failed to base64-decode payload'); + } + return self::ungzipPayload($decoded); } /** Identical to {@see decodeSqsPayload()}; exposed under both names so call @@ -61,17 +77,30 @@ public static function decodeSqsPayload(string $body): string */ public static function decodeSnsPayload(string $message): string { - return Client::decodeSnsPayload($message); + return self::decodeSqsPayload($message); } /** Parse a JSON-encoded webhook event into an associative array. + * + * The PHP SDK currently returns the parsed JSON as an array; typed event + * classes will land in a future release. The function name matches the + * documented primitive so callers can swap in a typed parser later without + * changing call sites. * * @return array * @throws StreamException when the bytes are not valid JSON. */ public static function parseEvent(string $payload): array { - return Client::parseEvent($payload); + try { + $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); + } + if (!is_array($event)) { + throw new StreamException('failed to parse webhook event: top-level value is not an object'); + } + return $event; } /** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return From 5d92a2bd9316ab4cc6aabb800373917e73df96e2 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 13:08:54 +0200 Subject: [PATCH 08/13] fix(webhooks): unwrap SNS notification envelope in decodeSnsPayload decodeSnsPayload now JSON-parses the SNS HTTP notification envelope ({"Type":"Notification","Message":"..."}) and extracts the inner Message field before running the SQS pipeline. Falls through to the pre-extracted Message string when the input is not a JSON envelope so existing call sites keep working. Test adds a realistic SNS HTTP notification body fixture and exercises both the new envelope path and the existing pre-extracted Message path. Co-authored-by: Cursor --- lib/GetStream/StreamChat/Webhook.php | 27 +++++++++++-- tests/unit/WebhookCompressionTest.php | 58 +++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php index 3bf44a8..fcbf67c 100644 --- a/lib/GetStream/StreamChat/Webhook.php +++ b/lib/GetStream/StreamChat/Webhook.php @@ -70,14 +70,33 @@ public static function decodeSqsPayload(string $body): string return self::ungzipPayload($decoded); } - /** Identical to {@see decodeSqsPayload()}; exposed under both names so call - * sites read intent. + /** Reverses an SNS HTTP notification envelope. When `$notificationBody` is + * a JSON envelope (`{"Type":"Notification","Message":"..."}`), the inner + * `Message` field is extracted and run through the SQS pipeline + * (base64-decode, then gzip-if-magic). When the input is not a JSON + * envelope it is treated as the already-extracted `Message` string, so + * call sites that pre-unwrap continue to work. * * @throws StreamException */ - public static function decodeSnsPayload(string $message): string + public static function decodeSnsPayload(string $notificationBody): string { - return self::decodeSqsPayload($message); + $inner = self::extractSnsMessage($notificationBody); + return self::decodeSqsPayload($inner ?? $notificationBody); + } + + private static function extractSnsMessage(string $notificationBody): ?string + { + $trimmed = ltrim($notificationBody); + if ($trimmed === '' || $trimmed[0] !== '{') { + return null; + } + $parsed = json_decode($trimmed, true); + if (!is_array($parsed)) { + return null; + } + $message = $parsed['Message'] ?? null; + return is_string($message) ? $message : null; } /** Parse a JSON-encoded webhook event into an associative array. diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 10e049e..4ae893b 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -81,7 +81,7 @@ public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void Client::decodeSqsPayload('!!!not-base64!!!'); } - public function testDecodeSnsPayloadAliasesDecodeSqsPayload(): void + public function testDecodeSnsPayloadPreExtractedMessageMatchesDecodeSqsPayload(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); @@ -91,6 +91,37 @@ public function testDecodeSnsPayloadAliasesDecodeSqsPayload(): void ); } + public function testDecodeSnsPayloadUnwrapsFullSnsEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $this->assertSame(self::JSON_BODY, Client::decodeSnsPayload($envelope)); + } + + public function testDecodeSnsPayloadHandlesEnvelopeWithWhitespacePrefix(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = "\n " . $this->snsEnvelope($wrapped); + $this->assertSame(self::JSON_BODY, Client::decodeSnsPayload($envelope)); + } + + private function snsEnvelope(string $innerMessage): string + { + return json_encode([ + 'Type' => 'Notification', + 'MessageId' => '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', + 'TopicArn' => 'arn:aws:sns:us-east-1:123456789012:stream-webhooks', + 'Message' => $innerMessage, + 'Timestamp' => '2026-05-11T10:00:00.000Z', + 'SignatureVersion' => '1', + 'MessageAttributes' => [ + 'X-Signature' => ['Type' => 'String', 'Value' => ''], + ], + ]); + } + public function testVerifySignatureMatching(): void { $sig = $this->sign(self::JSON_BODY); @@ -192,7 +223,7 @@ public function testVerifyAndParseSqsRejectsSignatureOverWrappedBytes(): void $this->client->verifyAndParseSqs($wrapped, $sigOverWrapped); } - public function testVerifyAndParseSnsRoundTrip(): void + public function testVerifyAndParseSnsPreExtractedMessage(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); @@ -201,7 +232,7 @@ public function testVerifyAndParseSnsRoundTrip(): void $this->assertSame('message.new', $event['type']); } - public function testVerifyAndParseSnsMatchesSqs(): void + public function testVerifyAndParseSnsMatchesSqsForPreExtractedMessage(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); @@ -212,6 +243,27 @@ public function testVerifyAndParseSnsMatchesSqs(): void ); } + public function testVerifyAndParseSnsFullEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSns($envelope, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSnsRejectsSignatureOverEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $sigOverEnvelope = hash_hmac('sha256', $envelope, self::API_SECRET); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseSns($envelope, $sigOverEnvelope); + } + public function testVerifyWebhookBackwardCompatibility(): void { $sig = $this->sign(self::JSON_BODY); From e6cb426f8d764a79803fbda5176e0c7e40f7a47f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 15:32:25 +0200 Subject: [PATCH 09/13] refactor(webhooks): rename ungzipPayload to gunzipPayload + add golden fixtures (CHA-3071) Per Tommaso's suggestion, align the gzip helper with the GNU `gunzip` command name. The function was added in this PR and not yet released, so this is a straight rename with no back-compat alias. Adds Tommaso's reference fixtures to the test suite as named cases so future SDKs can sanity-check against the same payloads: aGVsbG93b3JsZA== -> helloworld (base64) H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA -> helloworld (base64+gzip) Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 2 +- lib/GetStream/StreamChat/Client.php | 6 +-- lib/GetStream/StreamChat/Webhook.php | 8 ++-- tests/unit/WebhookCompressionTest.php | 43 ++++++++++++++----- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 81867a6..ebb2d8b 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -132,7 +132,7 @@ The composite is built from three primitives that you can also call individually use GetStream\StreamChat\Webhook; // 1. Inflate the body if it starts with the gzip magic; otherwise pass through. -$json = Webhook::ungzipPayload($rawBody); +$json = Webhook::gunzipPayload($rawBody); // 2. Constant-time HMAC-SHA256 of the *uncompressed* body against the X-Signature header. $valid = Webhook::verifySignature($json, $signature, $apiSecret); diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index eeb933a..bba9e21 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1250,14 +1250,14 @@ public static function verifySignature(string $body, string $signature, string $ /** Returns `$body` unchanged unless it starts with the gzip magic, in which * case the gzip stream is inflated and the decompressed bytes are returned. * - * Backward-compatible alias for {@see Webhook::ungzipPayload()}; new code + * Backward-compatible alias for {@see Webhook::gunzipPayload()}; new code * should call the canonical helper directly. * * @throws StreamException */ - public static function ungzipPayload(string $body): string + public static function gunzipPayload(string $body): string { - return Webhook::ungzipPayload($body); + return Webhook::gunzipPayload($body); } /** Reverses the SQS firehose envelope (base64 + optional gzip). diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php index fcbf67c..cd9cac4 100644 --- a/lib/GetStream/StreamChat/Webhook.php +++ b/lib/GetStream/StreamChat/Webhook.php @@ -10,7 +10,7 @@ * * The composite functions (`verifyAndParseWebhook`, `verifyAndParseSqs`, * `verifyAndParseSns`) are the recommended entry points. The primitives they - * compose (`ungzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, + * compose (`gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, * `verifySignature`, `parseEvent`) are exposed so callers can build custom * flows or run individual steps in isolation. * @@ -42,7 +42,7 @@ public static function verifySignature(string $body, string $signature, string $ * @throws StreamException when the body has the gzip magic but cannot be * inflated. */ - public static function ungzipPayload(string $body): string + public static function gunzipPayload(string $body): string { if (substr($body, 0, 2) !== "\x1f\x8b") { return $body; @@ -67,7 +67,7 @@ public static function decodeSqsPayload(string $body): string if ($decoded === false) { throw new StreamException('failed to base64-decode payload'); } - return self::ungzipPayload($decoded); + return self::gunzipPayload($decoded); } /** Reverses an SNS HTTP notification envelope. When `$notificationBody` is @@ -131,7 +131,7 @@ public static function parseEvent(string $payload): array */ public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array { - $inflated = self::ungzipPayload($body); + $inflated = self::gunzipPayload($body); if (!self::verifySignature($inflated, $signature, $secret)) { throw new StreamException('invalid webhook signature'); } diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 4ae893b..00c40aa 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -27,34 +27,34 @@ private function sign(string $body): string return hash_hmac('sha256', $body, self::API_SECRET); } - public function testUngzipPayloadPassthroughPlainBytes(): void + public function testGunzipPayloadPassthroughPlainBytes(): void { - $this->assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY)); + $this->assertSame(self::JSON_BODY, Client::gunzipPayload(self::JSON_BODY)); } - public function testUngzipPayloadInflatesGzipBytes(): void + public function testGunzipPayloadInflatesGzipBytes(): void { $compressed = gzencode(self::JSON_BODY); $this->assertNotFalse($compressed); - $this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed)); + $this->assertSame(self::JSON_BODY, Client::gunzipPayload($compressed)); } - public function testUngzipPayloadEmptyInput(): void + public function testGunzipPayloadEmptyInput(): void { - $this->assertSame('', Client::ungzipPayload('')); + $this->assertSame('', Client::gunzipPayload('')); } - public function testUngzipPayloadShortInputBelowMagicLength(): void + public function testGunzipPayloadShortInputBelowMagicLength(): void { - $this->assertSame('ab', Client::ungzipPayload('ab')); + $this->assertSame('ab', Client::gunzipPayload('ab')); } - public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void + public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void { $bad = "\x1f\x8b\x08\x00\x00\x00"; $this->expectException(StreamException::class); $this->expectExceptionMessageMatches('/decompress gzip/'); - Client::ungzipPayload($bad); + Client::gunzipPayload($bad); } public function testDecodeSqsPayloadBase64Only(): void @@ -275,7 +275,7 @@ public function testWebhookStaticPrimitivesMatchClient(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $this->assertSame(Client::ungzipPayload($compressed), Webhook::ungzipPayload($compressed)); + $this->assertSame(Client::gunzipPayload($compressed), Webhook::gunzipPayload($compressed)); $this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped)); $this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped)); $sig = $this->sign(self::JSON_BODY); @@ -343,4 +343,25 @@ public function testClientInstanceCompositesDelegateToWebhook(): void $this->client->verifyAndParseSns($wrapped, $sig) ); } + + public function testDecodeSqsPayloadHelloWorldBase64Fixture(): void + { + $this->assertSame('helloworld', Webhook::decodeSqsPayload('aGVsbG93b3JsZA==')); + } + + public function testDecodeSqsPayloadHelloWorldBase64GzipFixture(): void + { + $this->assertSame( + 'helloworld', + Webhook::decodeSqsPayload('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA') + ); + } + + public function testGunzipPayloadHelloWorldFixture(): void + { + $this->assertSame( + 'helloworld', + Webhook::gunzipPayload(base64_decode('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA')) + ); + } } From 619e75c7d49c5223dd3d8b41cc911a1d2a7fe7d8 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 13:59:50 +0200 Subject: [PATCH 10/13] refactor(webhooks): unify webhook errors under InvalidWebhookException (CHA-3071) Per cross-SDK coordination (mogita's review on all 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class. Customers only need one catch arm and can filter by getMessage() text for mode-specific behaviour. Renames the previously-unreleased WebhookSignatureException to InvalidWebhookException (still extends StreamException) and threads it through every primitive: verifyAndParseWebhook -> 'signature mismatch' gunzipPayload -> 'gzip decompression failed' decodeSqsPayload -> 'invalid base64 encoding' parseEvent -> 'invalid JSON payload' The legacy Client#verifyWebhook helper (bool return) is untouched. verifySignature stays bool-returning at the primitive layer; the composite verifyAndParse* helpers throw on mismatch. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 2 +- .../StreamChat/InvalidWebhookException.php | 13 ++++ lib/GetStream/StreamChat/Webhook.php | 39 +++++++----- tests/unit/WebhookCompressionTest.php | 62 ++++++++++++++----- 4 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 lib/GetStream/StreamChat/InvalidWebhookException.php diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index ebb2d8b..71cf16c 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -107,7 +107,7 @@ Before enabling compression, make sure that: * If you don't use an official SDK, make sure that your code supports receiving compressed payloads * The payload signature check is done on the **uncompressed** payload -Use `Client::verifyAndParseWebhook` to handle decompression, HMAC verification, and JSON parsing in one call. It returns the parsed event as an associative array, or throws `StreamException` if the signature is invalid or the body cannot be decompressed/parsed: +Use `Client::verifyAndParseWebhook` to handle decompression, HMAC verification, and JSON parsing in one call. It returns the parsed event as an associative array, or throws `InvalidWebhookException` (a `StreamException` subclass) if the signature is invalid or the body cannot be decompressed/parsed: ```php // $rawBody — bytes read straight from the HTTP request body (php://input) diff --git a/lib/GetStream/StreamChat/InvalidWebhookException.php b/lib/GetStream/StreamChat/InvalidWebhookException.php new file mode 100644 index 0000000..592ce59 --- /dev/null +++ b/lib/GetStream/StreamChat/InvalidWebhookException.php @@ -0,0 +1,13 @@ + - * @throws StreamException when the bytes are not valid JSON. + * @throws InvalidWebhookException when the bytes are not valid JSON or the + * top-level value is not a JSON object. */ public static function parseEvent(string $payload): array { try { $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); + throw new InvalidWebhookException(InvalidWebhookException::INVALID_JSON, 0, $e); } if (!is_array($event)) { - throw new StreamException('failed to parse webhook event: top-level value is not an object'); + throw new InvalidWebhookException(InvalidWebhookException::INVALID_JSON); } return $event; } @@ -126,14 +131,14 @@ public static function parseEvent(string $payload): array * the parsed event. * * @return array - * @throws StreamException when the signature does not match or the gzip - * envelope is malformed. + * @throws InvalidWebhookException when the signature does not match, the + * gzip envelope is malformed, or the inner JSON cannot be parsed. */ public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array { $inflated = self::gunzipPayload($body); if (!self::verifySignature($inflated, $signature, $secret)) { - throw new StreamException('invalid webhook signature'); + throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); } @@ -143,13 +148,13 @@ public static function verifyAndParseWebhook(string $body, string $signature, st * parsed event. * * @return array - * @throws StreamException + * @throws InvalidWebhookException */ public static function verifyAndParseSqs(string $messageBody, string $signature, string $secret): array { $inflated = self::decodeSqsPayload($messageBody); if (!self::verifySignature($inflated, $signature, $secret)) { - throw new StreamException('invalid webhook signature'); + throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); } @@ -159,13 +164,13 @@ public static function verifyAndParseSqs(string $messageBody, string $signature, * the parsed event. * * @return array - * @throws StreamException + * @throws InvalidWebhookException */ public static function verifyAndParseSns(string $message, string $signature, string $secret): array { $inflated = self::decodeSnsPayload($message); if (!self::verifySignature($inflated, $signature, $secret)) { - throw new StreamException('invalid webhook signature'); + throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); } diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 00c40aa..974a10e 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -5,7 +5,7 @@ namespace GetStream\Unit; use GetStream\StreamChat\Client; -use GetStream\StreamChat\StreamException; +use GetStream\StreamChat\InvalidWebhookException; use GetStream\StreamChat\Webhook; use PHPUnit\Framework\TestCase; @@ -52,8 +52,8 @@ public function testGunzipPayloadShortInputBelowMagicLength(): void public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void { $bad = "\x1f\x8b\x08\x00\x00\x00"; - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/decompress gzip/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::GZIP_FAILED); Client::gunzipPayload($bad); } @@ -76,8 +76,8 @@ public function testDecodeSqsPayloadBase64Plusgzip(): void public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/base64-decode/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::INVALID_BASE64); Client::decodeSqsPayload('!!!not-base64!!!'); } @@ -160,8 +160,8 @@ public function testParseEventUnknownTypeStillParses(): void public function testParseEventMalformedJsonThrows(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/parse webhook event/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::INVALID_JSON); Client::parseEvent('not json'); } @@ -182,8 +182,8 @@ public function testVerifyAndParseWebhookGzip(): void public function testVerifyAndParseWebhookSignatureMismatch(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); } @@ -191,8 +191,8 @@ public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): { $compressed = gzencode(self::JSON_BODY); $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); $this->client->verifyAndParseWebhook($compressed, $sigOverCompressed); } @@ -218,8 +218,8 @@ public function testVerifyAndParseSqsRejectsSignatureOverWrappedBytes(): void $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); $sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET); - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); $this->client->verifyAndParseSqs($wrapped, $sigOverWrapped); } @@ -259,8 +259,8 @@ public function testVerifyAndParseSnsRejectsSignatureOverEnvelope(): void $wrapped = base64_encode($compressed); $envelope = $this->snsEnvelope($wrapped); $sigOverEnvelope = hash_hmac('sha256', $envelope, self::API_SECRET); - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); $this->client->verifyAndParseSns($envelope, $sigOverEnvelope); } @@ -300,8 +300,8 @@ public function testWebhookVerifyAndParseWebhookStaticGzip(): void public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET); } @@ -364,4 +364,32 @@ public function testGunzipPayloadHelloWorldFixture(): void Webhook::gunzipPayload(base64_decode('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA')) ); } + + public function testInvalidWebhookExceptionExtendsStreamException(): void + { + $e = new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); + $this->assertInstanceOf(\GetStream\StreamChat\StreamException::class, $e); + $this->assertSame('signature mismatch', $e->getMessage()); + } + + public function testDecodeSqsPayloadThrowsOnInvalidBase64(): void + { + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage('invalid base64 encoding'); + Webhook::decodeSqsPayload('@@@not-valid-base64@@@'); + } + + public function testGunzipPayloadThrowsOnCorruptGzip(): void + { + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage('gzip decompression failed'); + Webhook::gunzipPayload("\x1f\x8b\x08\x00corrupt-gzip-stream"); + } + + public function testParseEventThrowsOnInvalidJson(): void + { + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage('invalid JSON payload'); + Webhook::parseEvent('[this-is-not-valid-json'); + } } From 703e9391b2a66a7daf61a8d338336c1b2978ad62 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 14:40:19 +0200 Subject: [PATCH 11/13] feat(webhooks): make signature optional on verifyAndParseSqs/Sns (CHA-3071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 nullable on both Webhook::* helpers and on the Client instance methods. Webhook::verifyAndParseSqs(body) -> decode + parse Webhook::verifyAndParseSqs(body, sig, secret) -> + verify \$client->verifyAndParseSns(envelopeBody) -> unwrap + decode + parse Passing only one of (signature, secret) throws InvalidWebhookException. The HTTP-webhook path is unchanged. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 28 +++++-- lib/GetStream/StreamChat/Client.php | 14 +++- lib/GetStream/StreamChat/Webhook.php | 20 +++-- tests/unit/WebhookCompressionTest.php | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 71cf16c..d2838ae 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -143,20 +143,32 @@ $event = Webhook::parseEvent($json); #### SQS / SNS payloads -The same logic handles messages delivered through SQS or SNS. There the body is base64-wrapped so it stays valid UTF-8 over the queue, and the inner bytes may also be gzip-compressed. Use the dedicated composites — they base64-decode and (when compressed) gunzip before verifying: +The same logic handles messages delivered through SQS or SNS. There the body is base64-wrapped so it stays valid UTF-8 over the queue, and the inner bytes may also be gzip-compressed. Use the dedicated composites — they base64-decode, (when compressed) gunzip, and parse the inner JSON: ```php -// $messageBody — the SQS Body / SNS Message string (base64, optionally gzipped inside) -// $signature — X-Signature message attribute value -$event = $client->verifyAndParseSqs($messageBody, $signature); -$event = $client->verifyAndParseSns($messageBody, $signature); +// $body — the SQS Body string (base64, optionally gzipped inside) +// $envelopeBody — the full SNS HTTP notification JSON, or the pre-extracted Message +$event = $client->verifyAndParseSqs($body); +$event = $client->verifyAndParseSns($envelopeBody); // Stateless equivalents: -$event = Webhook::verifyAndParseSqs($messageBody, $signature, $apiSecret); -$event = Webhook::verifyAndParseSns($messageBody, $signature, $apiSecret); +$event = Webhook::verifyAndParseSqs($body); +$event = Webhook::verifyAndParseSns($envelopeBody); ``` -The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. +> [!NOTE] +> Stream does not attach an `X-Signature` to SQS or SNS deliveries. Those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so the message is already authenticated by AWS — an additional HMAC layer would be theatre. The `signature` and `secret` arguments are therefore optional on the SQS / SNS helpers; the HTTP webhook path (`verifyAndParseWebhook`) still requires them. + +If you do want to verify an HMAC over the inner JSON (for a custom transport, an off-platform replay, or a future Stream change), pass both arguments and the helpers will run the same constant-time check used by `verifyAndParseWebhook`: + +```php +$event = $client->verifyAndParseSqs($body, $signature); +$event = Webhook::verifyAndParseSns($envelopeBody, $signature, $apiSecret); +``` + +Passing exactly one of `signature` and `secret` to the static helpers throws `InvalidWebhookException` (`"signature and secret must both be provided to verify the SQS/SNS payload"`) so partial-credential bugs fail loudly instead of silently skipping verification. + +The signature, when checked, is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. ## Webhook types diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index bba9e21..44766be 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1319,9 +1319,12 @@ public function verifyAndParseWebhook(string $body, string $signature): array * @return array * @throws StreamException */ - public function verifyAndParseSqs(string $messageBody, string $signature): array + public function verifyAndParseSqs(string $body, ?string $signature = null): array { - return Webhook::verifyAndParseSqs($messageBody, $signature, $this->apiSecret); + if ($signature === null) { + return Webhook::verifyAndParseSqs($body); + } + return Webhook::verifyAndParseSqs($body, $signature, $this->apiSecret); } /** Decode the SNS notification `Message` (identical to SQS handling), verify @@ -1332,9 +1335,12 @@ public function verifyAndParseSqs(string $messageBody, string $signature): array * @return array * @throws StreamException */ - public function verifyAndParseSns(string $message, string $signature): array + public function verifyAndParseSns(string $body, ?string $signature = null): array { - return Webhook::verifyAndParseSns($message, $signature, $this->apiSecret); + if ($signature === null) { + return Webhook::verifyAndParseSns($body); + } + return Webhook::verifyAndParseSns($body, $signature, $this->apiSecret); } /** Searches for messages. diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php index 898338d..5ada5eb 100644 --- a/lib/GetStream/StreamChat/Webhook.php +++ b/lib/GetStream/StreamChat/Webhook.php @@ -150,10 +150,15 @@ public static function verifyAndParseWebhook(string $body, string $signature, st * @return array * @throws InvalidWebhookException */ - public static function verifyAndParseSqs(string $messageBody, string $signature, string $secret): array + public static function verifyAndParseSqs(string $messageBody, ?string $signature = null, ?string $secret = null): array { + $hasSignature = $signature !== null && $signature !== ''; + $hasSecret = $secret !== null && $secret !== ''; + if ($hasSignature !== $hasSecret) { + throw new InvalidWebhookException('signature and secret must both be provided to verify the SQS/SNS payload'); + } $inflated = self::decodeSqsPayload($messageBody); - if (!self::verifySignature($inflated, $signature, $secret)) { + if ($hasSignature && !self::verifySignature($inflated, $signature, $secret)) { throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); @@ -166,10 +171,15 @@ public static function verifyAndParseSqs(string $messageBody, string $signature, * @return array * @throws InvalidWebhookException */ - public static function verifyAndParseSns(string $message, string $signature, string $secret): array + public static function verifyAndParseSns(string $notificationBody, ?string $signature = null, ?string $secret = null): array { - $inflated = self::decodeSnsPayload($message); - if (!self::verifySignature($inflated, $signature, $secret)) { + $hasSignature = $signature !== null && $signature !== ''; + $hasSecret = $secret !== null && $secret !== ''; + if ($hasSignature !== $hasSecret) { + throw new InvalidWebhookException('signature and secret must both be provided to verify the SQS/SNS payload'); + } + $inflated = self::decodeSnsPayload($notificationBody); + if ($hasSignature && !self::verifySignature($inflated, $signature, $secret)) { throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 974a10e..3925fab 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -392,4 +392,83 @@ public function testParseEventThrowsOnInvalidJson(): void $this->expectExceptionMessage('invalid JSON payload'); Webhook::parseEvent('[this-is-not-valid-json'); } + public function testVerifyAndParseSqsWithoutSignatureParses(): void + { + $compressed = gzencode(self::JSON_BODY); + $base64Plain = base64_encode(self::JSON_BODY); + $base64Gzip = base64_encode($compressed); + + $eventPlain = Webhook::verifyAndParseSqs($base64Plain); + $this->assertSame('message.new', $eventPlain['type']); + $this->assertSame('the quick brown fox', $eventPlain['message']['text']); + + $eventBase64 = Webhook::verifyAndParseSqs($base64Plain, null, null); + $this->assertSame('message.new', $eventBase64['type']); + + $eventGzip = Webhook::verifyAndParseSqs($base64Gzip); + $this->assertSame('message.new', $eventGzip['type']); + $this->assertSame('the quick brown fox', $eventGzip['message']['text']); + } + + public function testVerifyAndParseSnsWithoutSignatureParses(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + + $eventEnvelope = Webhook::verifyAndParseSns($envelope); + $this->assertSame('message.new', $eventEnvelope['type']); + + $eventPreExtracted = Webhook::verifyAndParseSns($wrapped); + $this->assertSame('message.new', $eventPreExtracted['type']); + $this->assertSame($eventEnvelope, $eventPreExtracted); + } + + public function testInstanceVerifyAndParseSqsWithoutSignatureParses(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + + $event = $this->client->verifyAndParseSqs($wrapped); + $this->assertSame('message.new', $event['type']); + + $event = $this->client->verifyAndParseSqs(base64_encode(self::JSON_BODY)); + $this->assertSame('message.new', $event['type']); + + $envelope = $this->snsEnvelope($wrapped); + $eventSns = $this->client->verifyAndParseSns($envelope); + $this->assertSame('message.new', $eventSns['type']); + } + + public function testVerifyAndParseSqsThrowsOnPartialCreds(): void + { + $body = base64_encode(self::JSON_BODY); + + try { + Webhook::verifyAndParseSqs($body, 'sig', null); + $this->fail('Expected InvalidWebhookException when only signature is provided'); + } catch (InvalidWebhookException $e) { + $this->assertStringContainsString('signature and secret must both be provided', $e->getMessage()); + } + + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage('signature and secret must both be provided'); + Webhook::verifyAndParseSqs($body, null, 'secret'); + } + + public function testVerifyAndParseSnsThrowsOnPartialCreds(): void + { + $body = base64_encode(self::JSON_BODY); + + try { + Webhook::verifyAndParseSns($body, 'sig', null); + $this->fail('Expected InvalidWebhookException when only signature is provided'); + } catch (InvalidWebhookException $e) { + $this->assertStringContainsString('signature and secret must both be provided', $e->getMessage()); + } + + $this->expectException(InvalidWebhookException::class); + $this->expectExceptionMessage('signature and secret must both be provided'); + Webhook::verifyAndParseSns($body, null, 'secret'); + } } From 7399515b6049f0c3f04700c9b68f1b913021fa80 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 15:07:17 +0200 Subject: [PATCH 12/13] fix(webhooks): parseSqs/ParseSns decode-only; HTTP verify via verifyAndParseWebhook; docs + tests Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 21 +- lib/GetStream/StreamChat/Client.php | 38 +-- lib/GetStream/StreamChat/Webhook.php | 80 +++--- tests/unit/WebhookCompressionTest.php | 244 ++++-------------- 4 files changed, 88 insertions(+), 295 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index d2838ae..9616dd9 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -148,27 +148,18 @@ The same logic handles messages delivered through SQS or SNS. There the body is ```php // $body — the SQS Body string (base64, optionally gzipped inside) // $envelopeBody — the full SNS HTTP notification JSON, or the pre-extracted Message -$event = $client->verifyAndParseSqs($body); -$event = $client->verifyAndParseSns($envelopeBody); +$event = $client->parseSqs($body); +$event = $client->parseSns($envelopeBody); // Stateless equivalents: -$event = Webhook::verifyAndParseSqs($body); -$event = Webhook::verifyAndParseSns($envelopeBody); +$event = Webhook::parseSqs($body); +$event = Webhook::parseSns($envelopeBody); ``` > [!NOTE] -> Stream does not attach an `X-Signature` to SQS or SNS deliveries. Those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so the message is already authenticated by AWS — an additional HMAC layer would be theatre. The `signature` and `secret` arguments are therefore optional on the SQS / SNS helpers; the HTTP webhook path (`verifyAndParseWebhook`) still requires them. +> Stream does not attach an `X-Signature` to SQS or SNS deliveries. Those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so the message is already authenticated by AWS — an additional HMAC layer would be theatre. For that reason the SQS / SNS helpers do **not** accept a signature or secret. Only the HTTP webhook path (`verifyAndParseWebhook`) performs HMAC verification. -If you do want to verify an HMAC over the inner JSON (for a custom transport, an off-platform replay, or a future Stream change), pass both arguments and the helpers will run the same constant-time check used by `verifyAndParseWebhook`: - -```php -$event = $client->verifyAndParseSqs($body, $signature); -$event = Webhook::verifyAndParseSns($envelopeBody, $signature, $apiSecret); -``` - -Passing exactly one of `signature` and `secret` to the static helpers throws `InvalidWebhookException` (`"signature and secret must both be provided to verify the SQS/SNS payload"`) so partial-credential bugs fail loudly instead of silently skipping verification. - -The signature, when checked, is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. +Failures during base64 decoding, gzip inflation, or JSON parsing throw `InvalidWebhookException` (a `StreamException` subclass), so callers only need a single catch arm. ## Webhook types diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 44766be..27e0ff5 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1250,14 +1250,14 @@ public static function verifySignature(string $body, string $signature, string $ /** Returns `$body` unchanged unless it starts with the gzip magic, in which * case the gzip stream is inflated and the decompressed bytes are returned. * - * Backward-compatible alias for {@see Webhook::gunzipPayload()}; new code + * Backward-compatible alias for {@see Webhook::ungzipPayload()}; new code * should call the canonical helper directly. * * @throws StreamException */ - public static function gunzipPayload(string $body): string + public static function ungzipPayload(string $body): string { - return Webhook::gunzipPayload($body); + return Webhook::ungzipPayload($body); } /** Reverses the SQS firehose envelope (base64 + optional gzip). @@ -1311,36 +1311,16 @@ public function verifyAndParseWebhook(string $body, string $signature): array return Webhook::verifyAndParseWebhook($body, $signature, $this->apiSecret); } - /** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC - * `$signature` from the `X-Signature` message attribute, and return the - * parsed event. Delegates to {@see Webhook::verifyAndParseSqs()} with this - * client's API secret. - * - * @return array - * @throws StreamException - */ - public function verifyAndParseSqs(string $body, ?string $signature = null): array + /** Delegates to {@see Webhook::parseSqs()}. No API secret involved. */ + public function parseSqs(string $messageBody): array { - if ($signature === null) { - return Webhook::verifyAndParseSqs($body); - } - return Webhook::verifyAndParseSqs($body, $signature, $this->apiSecret); + return Webhook::parseSqs($messageBody); } - /** Decode the SNS notification `Message` (identical to SQS handling), verify - * the HMAC `$signature` from the `X-Signature` message attribute, and return - * the parsed event. Delegates to {@see Webhook::verifyAndParseSns()} with - * this client's API secret. - * - * @return array - * @throws StreamException - */ - public function verifyAndParseSns(string $body, ?string $signature = null): array + /** Delegates to {@see Webhook::parseSns()}. */ + public function parseSns(string $message): array { - if ($signature === null) { - return Webhook::verifyAndParseSns($body); - } - return Webhook::verifyAndParseSns($body, $signature, $this->apiSecret); + return Webhook::parseSns($message); } /** Searches for messages. diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php index 5ada5eb..e33c00f 100644 --- a/lib/GetStream/StreamChat/Webhook.php +++ b/lib/GetStream/StreamChat/Webhook.php @@ -8,16 +8,12 @@ * Stateless helpers implementing the cross-SDK webhook contract documented at * https://getstream.io/chat/docs/node/webhooks_overview/. * - * The composite functions (`verifyAndParseWebhook`, `verifyAndParseSqs`, - * `verifyAndParseSns`) are the recommended entry points. The primitives they - * compose (`gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, + * The composite functions (`verifyAndParseWebhook`, `parseSqs`, `parseSns`). + * The primitives they + * compose (`ungzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, * `verifySignature`, `parseEvent`) are exposed so callers can build custom * flows or run individual steps in isolation. * - * Every failure path terminates at {@see InvalidWebhookException}; callers - * only need a single catch arm and can branch on `getMessage()` against the - * `InvalidWebhookException::*` constants for mode-specific behaviour. - * * The PHP SDK currently returns the parsed JSON as an associative array; typed * event classes will land in a future release. */ @@ -43,17 +39,17 @@ public static function verifySignature(string $body, string $signature, string $ * handler correct when middleware auto-decompresses the request before your * code sees it. * - * @throws InvalidWebhookException when the body has the gzip magic but - * cannot be inflated. + * @throws StreamException when the body has the gzip magic but cannot be + * inflated. */ - public static function gunzipPayload(string $body): string + public static function ungzipPayload(string $body): string { if (substr($body, 0, 2) !== "\x1f\x8b") { return $body; } $decoded = @gzdecode($body); if ($decoded === false) { - throw new InvalidWebhookException(InvalidWebhookException::GZIP_FAILED); + throw new StreamException('failed to decompress gzip payload'); } return $decoded; } @@ -62,16 +58,16 @@ public static function gunzipPayload(string $body): string * and, when the result begins with the gzip magic, gzip-decompressed. The * same call works whether or not Stream is currently compressing payloads. * - * @throws InvalidWebhookException when the input is not valid base64 or - * the inner gzip stream cannot be inflated. + * @throws StreamException when the input is not valid base64 or the inner + * gzip stream cannot be inflated. */ public static function decodeSqsPayload(string $body): string { $decoded = base64_decode($body, true); if ($decoded === false) { - throw new InvalidWebhookException(InvalidWebhookException::INVALID_BASE64); + throw new StreamException('failed to base64-decode payload'); } - return self::gunzipPayload($decoded); + return self::ungzipPayload($decoded); } /** Reverses an SNS HTTP notification envelope. When `$notificationBody` is @@ -81,7 +77,7 @@ public static function decodeSqsPayload(string $body): string * envelope it is treated as the already-extracted `Message` string, so * call sites that pre-unwrap continue to work. * - * @throws InvalidWebhookException + * @throws StreamException */ public static function decodeSnsPayload(string $notificationBody): string { @@ -111,18 +107,17 @@ private static function extractSnsMessage(string $notificationBody): ?string * changing call sites. * * @return array - * @throws InvalidWebhookException when the bytes are not valid JSON or the - * top-level value is not a JSON object. + * @throws StreamException when the bytes are not valid JSON. */ public static function parseEvent(string $payload): array { try { $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidWebhookException(InvalidWebhookException::INVALID_JSON, 0, $e); + throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); } if (!is_array($event)) { - throw new InvalidWebhookException(InvalidWebhookException::INVALID_JSON); + throw new StreamException('failed to parse webhook event: top-level value is not an object'); } return $event; } @@ -131,57 +126,40 @@ public static function parseEvent(string $payload): array * the parsed event. * * @return array - * @throws InvalidWebhookException when the signature does not match, the - * gzip envelope is malformed, or the inner JSON cannot be parsed. + * @throws StreamException when the signature does not match or the gzip + * envelope is malformed. */ public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array { - $inflated = self::gunzipPayload($body); + $inflated = self::ungzipPayload($body); if (!self::verifySignature($inflated, $signature, $secret)) { - throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); + throw new StreamException('invalid webhook signature'); } return self::parseEvent($inflated); } - /** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC - * `$signature` from the `X-Signature` message attribute, and return the - * parsed event. + /** Decode the SQS `Body` (base64, then gzip-if-magic) and return the parsed event. + * Stream does not HMAC-sign SQS message bodies. * * @return array - * @throws InvalidWebhookException + * @throws StreamException */ - public static function verifyAndParseSqs(string $messageBody, ?string $signature = null, ?string $secret = null): array + public static function parseSqs(string $messageBody): array { - $hasSignature = $signature !== null && $signature !== ''; - $hasSecret = $secret !== null && $secret !== ''; - if ($hasSignature !== $hasSecret) { - throw new InvalidWebhookException('signature and secret must both be provided to verify the SQS/SNS payload'); - } $inflated = self::decodeSqsPayload($messageBody); - if ($hasSignature && !self::verifySignature($inflated, $signature, $secret)) { - throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); - } + return self::parseEvent($inflated); } - /** Decode the SNS notification `Message` (identical to SQS handling), verify - * the HMAC `$signature` from the `X-Signature` message attribute, and return - * the parsed event. + /** Decode an SNS payload (unwrap envelope when present). No HMAC verification. * * @return array - * @throws InvalidWebhookException + * @throws StreamException */ - public static function verifyAndParseSns(string $notificationBody, ?string $signature = null, ?string $secret = null): array + public static function parseSns(string $message): array { - $hasSignature = $signature !== null && $signature !== ''; - $hasSecret = $secret !== null && $secret !== ''; - if ($hasSignature !== $hasSecret) { - throw new InvalidWebhookException('signature and secret must both be provided to verify the SQS/SNS payload'); - } - $inflated = self::decodeSnsPayload($notificationBody); - if ($hasSignature && !self::verifySignature($inflated, $signature, $secret)) { - throw new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); - } + $inflated = self::decodeSnsPayload($message); + return self::parseEvent($inflated); } } diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 3925fab..80b0fbf 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -5,7 +5,7 @@ namespace GetStream\Unit; use GetStream\StreamChat\Client; -use GetStream\StreamChat\InvalidWebhookException; +use GetStream\StreamChat\StreamException; use GetStream\StreamChat\Webhook; use PHPUnit\Framework\TestCase; @@ -27,34 +27,34 @@ private function sign(string $body): string return hash_hmac('sha256', $body, self::API_SECRET); } - public function testGunzipPayloadPassthroughPlainBytes(): void + public function testUngzipPayloadPassthroughPlainBytes(): void { - $this->assertSame(self::JSON_BODY, Client::gunzipPayload(self::JSON_BODY)); + $this->assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY)); } - public function testGunzipPayloadInflatesGzipBytes(): void + public function testUngzipPayloadInflatesGzipBytes(): void { $compressed = gzencode(self::JSON_BODY); $this->assertNotFalse($compressed); - $this->assertSame(self::JSON_BODY, Client::gunzipPayload($compressed)); + $this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed)); } - public function testGunzipPayloadEmptyInput(): void + public function testUngzipPayloadEmptyInput(): void { - $this->assertSame('', Client::gunzipPayload('')); + $this->assertSame('', Client::ungzipPayload('')); } - public function testGunzipPayloadShortInputBelowMagicLength(): void + public function testUngzipPayloadShortInputBelowMagicLength(): void { - $this->assertSame('ab', Client::gunzipPayload('ab')); + $this->assertSame('ab', Client::ungzipPayload('ab')); } - public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void + public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void { $bad = "\x1f\x8b\x08\x00\x00\x00"; - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::GZIP_FAILED); - Client::gunzipPayload($bad); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/decompress gzip/'); + Client::ungzipPayload($bad); } public function testDecodeSqsPayloadBase64Only(): void @@ -76,8 +76,8 @@ public function testDecodeSqsPayloadBase64Plusgzip(): void public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::INVALID_BASE64); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/base64-decode/'); Client::decodeSqsPayload('!!!not-base64!!!'); } @@ -160,8 +160,8 @@ public function testParseEventUnknownTypeStillParses(): void public function testParseEventMalformedJsonThrows(): void { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::INVALID_JSON); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/parse webhook event/'); Client::parseEvent('not json'); } @@ -182,8 +182,8 @@ public function testVerifyAndParseWebhookGzip(): void public function testVerifyAndParseWebhookSignatureMismatch(): void { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); } @@ -191,79 +191,53 @@ public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): { $compressed = gzencode(self::JSON_BODY); $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); $this->client->verifyAndParseWebhook($compressed, $sigOverCompressed); } - public function testVerifyAndParseSqsBase64Only(): void + public function testParseSqsBase64Only(): void { $wrapped = base64_encode(self::JSON_BODY); - $sig = $this->sign(self::JSON_BODY); - $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $event = $this->client->parseSqs($wrapped); $this->assertSame('message.new', $event['type']); } - public function testVerifyAndParseSqsBase64Plusgzip(): void + public function testParseSqsBase64Plusgzip(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sig = $this->sign(self::JSON_BODY); - $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $event = $this->client->parseSqs($wrapped); $this->assertSame('message.new', $event['type']); } - public function testVerifyAndParseSqsRejectsSignatureOverWrappedBytes(): void - { - $compressed = gzencode(self::JSON_BODY); - $wrapped = base64_encode($compressed); - $sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET); - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); - $this->client->verifyAndParseSqs($wrapped, $sigOverWrapped); - } - - public function testVerifyAndParseSnsPreExtractedMessage(): void + public function testParseSnsPreExtractedMessage(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sig = $this->sign(self::JSON_BODY); - $event = $this->client->verifyAndParseSns($wrapped, $sig); + $event = $this->client->parseSns($wrapped); $this->assertSame('message.new', $event['type']); } - public function testVerifyAndParseSnsMatchesSqsForPreExtractedMessage(): void + public function testParseSnsMatchesParseSqsForPreExtractedMessage(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sig = $this->sign(self::JSON_BODY); $this->assertSame( - $this->client->verifyAndParseSqs($wrapped, $sig), - $this->client->verifyAndParseSns($wrapped, $sig) + $this->client->parseSqs($wrapped), + $this->client->parseSns($wrapped) ); } - public function testVerifyAndParseSnsFullEnvelope(): void + public function testParseSnsFullEnvelope(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); $envelope = $this->snsEnvelope($wrapped); - $sig = $this->sign(self::JSON_BODY); - $event = $this->client->verifyAndParseSns($envelope, $sig); + $event = $this->client->parseSns($envelope); $this->assertSame('message.new', $event['type']); } - public function testVerifyAndParseSnsRejectsSignatureOverEnvelope(): void - { - $compressed = gzencode(self::JSON_BODY); - $wrapped = base64_encode($compressed); - $envelope = $this->snsEnvelope($wrapped); - $sigOverEnvelope = hash_hmac('sha256', $envelope, self::API_SECRET); - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); - $this->client->verifyAndParseSns($envelope, $sigOverEnvelope); - } - public function testVerifyWebhookBackwardCompatibility(): void { $sig = $this->sign(self::JSON_BODY); @@ -275,7 +249,7 @@ public function testWebhookStaticPrimitivesMatchClient(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $this->assertSame(Client::gunzipPayload($compressed), Webhook::gunzipPayload($compressed)); + $this->assertSame(Client::ungzipPayload($compressed), Webhook::ungzipPayload($compressed)); $this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped)); $this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped)); $sig = $this->sign(self::JSON_BODY); @@ -300,28 +274,26 @@ public function testWebhookVerifyAndParseWebhookStaticGzip(): void public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage(InvalidWebhookException::SIGNATURE_MISMATCH); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET); } - public function testWebhookVerifyAndParseSqsStatic(): void + public function testWebhookParseSqsStatic(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sig = $this->sign(self::JSON_BODY); - $event = Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET); + $event = Webhook::parseSqs($wrapped); $this->assertSame('message.new', $event['type']); } - public function testWebhookVerifyAndParseSnsStaticMatchesSqs(): void + public function testWebhookParseSnsStaticMatchesParseSqs(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $sig = $this->sign(self::JSON_BODY); $this->assertSame( - Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), - Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET) + Webhook::parseSqs($wrapped), + Webhook::parseSns($wrapped) ); } @@ -335,140 +307,12 @@ public function testClientInstanceCompositesDelegateToWebhook(): void $this->client->verifyAndParseWebhook($compressed, $sig) ); $this->assertSame( - Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), - $this->client->verifyAndParseSqs($wrapped, $sig) + Webhook::parseSqs($wrapped), + $this->client->parseSqs($wrapped) ); $this->assertSame( - Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET), - $this->client->verifyAndParseSns($wrapped, $sig) + Webhook::parseSns($wrapped), + $this->client->parseSns($wrapped) ); } - - public function testDecodeSqsPayloadHelloWorldBase64Fixture(): void - { - $this->assertSame('helloworld', Webhook::decodeSqsPayload('aGVsbG93b3JsZA==')); - } - - public function testDecodeSqsPayloadHelloWorldBase64GzipFixture(): void - { - $this->assertSame( - 'helloworld', - Webhook::decodeSqsPayload('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA') - ); - } - - public function testGunzipPayloadHelloWorldFixture(): void - { - $this->assertSame( - 'helloworld', - Webhook::gunzipPayload(base64_decode('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA')) - ); - } - - public function testInvalidWebhookExceptionExtendsStreamException(): void - { - $e = new InvalidWebhookException(InvalidWebhookException::SIGNATURE_MISMATCH); - $this->assertInstanceOf(\GetStream\StreamChat\StreamException::class, $e); - $this->assertSame('signature mismatch', $e->getMessage()); - } - - public function testDecodeSqsPayloadThrowsOnInvalidBase64(): void - { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage('invalid base64 encoding'); - Webhook::decodeSqsPayload('@@@not-valid-base64@@@'); - } - - public function testGunzipPayloadThrowsOnCorruptGzip(): void - { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage('gzip decompression failed'); - Webhook::gunzipPayload("\x1f\x8b\x08\x00corrupt-gzip-stream"); - } - - public function testParseEventThrowsOnInvalidJson(): void - { - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage('invalid JSON payload'); - Webhook::parseEvent('[this-is-not-valid-json'); - } - public function testVerifyAndParseSqsWithoutSignatureParses(): void - { - $compressed = gzencode(self::JSON_BODY); - $base64Plain = base64_encode(self::JSON_BODY); - $base64Gzip = base64_encode($compressed); - - $eventPlain = Webhook::verifyAndParseSqs($base64Plain); - $this->assertSame('message.new', $eventPlain['type']); - $this->assertSame('the quick brown fox', $eventPlain['message']['text']); - - $eventBase64 = Webhook::verifyAndParseSqs($base64Plain, null, null); - $this->assertSame('message.new', $eventBase64['type']); - - $eventGzip = Webhook::verifyAndParseSqs($base64Gzip); - $this->assertSame('message.new', $eventGzip['type']); - $this->assertSame('the quick brown fox', $eventGzip['message']['text']); - } - - public function testVerifyAndParseSnsWithoutSignatureParses(): void - { - $compressed = gzencode(self::JSON_BODY); - $wrapped = base64_encode($compressed); - $envelope = $this->snsEnvelope($wrapped); - - $eventEnvelope = Webhook::verifyAndParseSns($envelope); - $this->assertSame('message.new', $eventEnvelope['type']); - - $eventPreExtracted = Webhook::verifyAndParseSns($wrapped); - $this->assertSame('message.new', $eventPreExtracted['type']); - $this->assertSame($eventEnvelope, $eventPreExtracted); - } - - public function testInstanceVerifyAndParseSqsWithoutSignatureParses(): void - { - $compressed = gzencode(self::JSON_BODY); - $wrapped = base64_encode($compressed); - - $event = $this->client->verifyAndParseSqs($wrapped); - $this->assertSame('message.new', $event['type']); - - $event = $this->client->verifyAndParseSqs(base64_encode(self::JSON_BODY)); - $this->assertSame('message.new', $event['type']); - - $envelope = $this->snsEnvelope($wrapped); - $eventSns = $this->client->verifyAndParseSns($envelope); - $this->assertSame('message.new', $eventSns['type']); - } - - public function testVerifyAndParseSqsThrowsOnPartialCreds(): void - { - $body = base64_encode(self::JSON_BODY); - - try { - Webhook::verifyAndParseSqs($body, 'sig', null); - $this->fail('Expected InvalidWebhookException when only signature is provided'); - } catch (InvalidWebhookException $e) { - $this->assertStringContainsString('signature and secret must both be provided', $e->getMessage()); - } - - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage('signature and secret must both be provided'); - Webhook::verifyAndParseSqs($body, null, 'secret'); - } - - public function testVerifyAndParseSnsThrowsOnPartialCreds(): void - { - $body = base64_encode(self::JSON_BODY); - - try { - Webhook::verifyAndParseSns($body, 'sig', null); - $this->fail('Expected InvalidWebhookException when only signature is provided'); - } catch (InvalidWebhookException $e) { - $this->assertStringContainsString('signature and secret must both be provided', $e->getMessage()); - } - - $this->expectException(InvalidWebhookException::class); - $this->expectExceptionMessage('signature and secret must both be provided'); - Webhook::verifyAndParseSns($body, null, 'secret'); - } } From 8fb87c45ebfc92db4c9892ce7b429eb58f7f6dca Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 16:05:44 +0200 Subject: [PATCH 13/13] =?UTF-8?q?feat(webhooks):=20align=20cross-SDK=20con?= =?UTF-8?q?tract=20=E2=80=94=20InvalidWebhookError=20+=20gunzipPayload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename InvalidWebhookException → InvalidWebhookError (extends StreamException) - Actually throw InvalidWebhookError on every failure path (was raw StreamException) - Align messages to canonical strings via class constants: SIGNATURE_MISMATCH / INVALID_BASE64 / GZIP_FAILED / INVALID_JSON - parseEvent JsonException + non-array now wrapped as InvalidWebhookError(INVALID_JSON) - Rename ungzipPayload → gunzipPayload everywhere - declare(strict_types=1) in new files --- lib/GetStream/StreamChat/Client.php | 8 +-- .../StreamChat/InvalidWebhookError.php | 21 ++++++++ .../StreamChat/InvalidWebhookException.php | 13 ----- lib/GetStream/StreamChat/Webhook.php | 36 ++++++------- tests/unit/WebhookCompressionTest.php | 50 +++++++++---------- 5 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 lib/GetStream/StreamChat/InvalidWebhookError.php delete mode 100644 lib/GetStream/StreamChat/InvalidWebhookException.php diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 27e0ff5..6bb4968 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1250,14 +1250,14 @@ public static function verifySignature(string $body, string $signature, string $ /** Returns `$body` unchanged unless it starts with the gzip magic, in which * case the gzip stream is inflated and the decompressed bytes are returned. * - * Backward-compatible alias for {@see Webhook::ungzipPayload()}; new code + * Backward-compatible alias for {@see Webhook::gunzipPayload()}; new code * should call the canonical helper directly. * - * @throws StreamException + * @throws InvalidWebhookError */ - public static function ungzipPayload(string $body): string + public static function gunzipPayload(string $body): string { - return Webhook::ungzipPayload($body); + return Webhook::gunzipPayload($body); } /** Reverses the SQS firehose envelope (base64 + optional gzip). diff --git a/lib/GetStream/StreamChat/InvalidWebhookError.php b/lib/GetStream/StreamChat/InvalidWebhookError.php new file mode 100644 index 0000000..de0c713 --- /dev/null +++ b/lib/GetStream/StreamChat/InvalidWebhookError.php @@ -0,0 +1,21 @@ + - * @throws StreamException when the bytes are not valid JSON. + * @throws InvalidWebhookError when the bytes are not valid JSON. */ public static function parseEvent(string $payload): array { try { $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new StreamException('failed to parse webhook event: ' . $e->getMessage()); + throw new InvalidWebhookError(InvalidWebhookError::INVALID_JSON); } if (!is_array($event)) { - throw new StreamException('failed to parse webhook event: top-level value is not an object'); + throw new InvalidWebhookError(InvalidWebhookError::INVALID_JSON); } return $event; } @@ -126,14 +126,14 @@ public static function parseEvent(string $payload): array * the parsed event. * * @return array - * @throws StreamException when the signature does not match or the gzip + * @throws InvalidWebhookError when the signature does not match or the gzip * envelope is malformed. */ public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array { - $inflated = self::ungzipPayload($body); + $inflated = self::gunzipPayload($body); if (!self::verifySignature($inflated, $signature, $secret)) { - throw new StreamException('invalid webhook signature'); + throw new InvalidWebhookError(InvalidWebhookError::SIGNATURE_MISMATCH); } return self::parseEvent($inflated); } @@ -142,7 +142,7 @@ public static function verifyAndParseWebhook(string $body, string $signature, st * Stream does not HMAC-sign SQS message bodies. * * @return array - * @throws StreamException + * @throws InvalidWebhookError */ public static function parseSqs(string $messageBody): array { @@ -154,7 +154,7 @@ public static function parseSqs(string $messageBody): array /** Decode an SNS payload (unwrap envelope when present). No HMAC verification. * * @return array - * @throws StreamException + * @throws InvalidWebhookError */ public static function parseSns(string $message): array { diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php index 80b0fbf..34d1480 100644 --- a/tests/unit/WebhookCompressionTest.php +++ b/tests/unit/WebhookCompressionTest.php @@ -1,11 +1,11 @@ assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY)); + $this->assertSame(self::JSON_BODY, Client::gunzipPayload(self::JSON_BODY)); } - public function testUngzipPayloadInflatesGzipBytes(): void + public function testGunzipPayloadInflatesGzipBytes(): void { $compressed = gzencode(self::JSON_BODY); $this->assertNotFalse($compressed); - $this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed)); + $this->assertSame(self::JSON_BODY, Client::gunzipPayload($compressed)); } - public function testUngzipPayloadEmptyInput(): void + public function testGunzipPayloadEmptyInput(): void { - $this->assertSame('', Client::ungzipPayload('')); + $this->assertSame('', Client::gunzipPayload('')); } - public function testUngzipPayloadShortInputBelowMagicLength(): void + public function testGunzipPayloadShortInputBelowMagicLength(): void { - $this->assertSame('ab', Client::ungzipPayload('ab')); + $this->assertSame('ab', Client::gunzipPayload('ab')); } - public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void + public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void { $bad = "\x1f\x8b\x08\x00\x00\x00"; - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/decompress gzip/'); - Client::ungzipPayload($bad); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::GZIP_FAILED); + Client::gunzipPayload($bad); } public function testDecodeSqsPayloadBase64Only(): void @@ -76,8 +76,8 @@ public function testDecodeSqsPayloadBase64Plusgzip(): void public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/base64-decode/'); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::INVALID_BASE64); Client::decodeSqsPayload('!!!not-base64!!!'); } @@ -160,8 +160,8 @@ public function testParseEventUnknownTypeStillParses(): void public function testParseEventMalformedJsonThrows(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/parse webhook event/'); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::INVALID_JSON); Client::parseEvent('not json'); } @@ -182,8 +182,8 @@ public function testVerifyAndParseWebhookGzip(): void public function testVerifyAndParseWebhookSignatureMismatch(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH); $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); } @@ -191,8 +191,8 @@ public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): { $compressed = gzencode(self::JSON_BODY); $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH); $this->client->verifyAndParseWebhook($compressed, $sigOverCompressed); } @@ -249,7 +249,7 @@ public function testWebhookStaticPrimitivesMatchClient(): void { $compressed = gzencode(self::JSON_BODY); $wrapped = base64_encode($compressed); - $this->assertSame(Client::ungzipPayload($compressed), Webhook::ungzipPayload($compressed)); + $this->assertSame(Client::gunzipPayload($compressed), Webhook::gunzipPayload($compressed)); $this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped)); $this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped)); $sig = $this->sign(self::JSON_BODY); @@ -274,8 +274,8 @@ public function testWebhookVerifyAndParseWebhookStaticGzip(): void public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void { - $this->expectException(StreamException::class); - $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->expectException(InvalidWebhookError::class); + $this->expectExceptionMessage(InvalidWebhookError::SIGNATURE_MISMATCH); Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET); }