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'); + } +}