From d983247cd8a95cbccdec99fbf2a02f15d23df4db Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 6 May 2026 16:38:31 +0200 Subject: [PATCH] [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'); + } +}