Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion lib/GetStream/StreamChat/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/WebhookCompressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=0);

namespace GetStream\Unit;

use GetStream\StreamChat\Client;
use GetStream\StreamChat\StreamException;
use PHPUnit\Framework\TestCase;

class WebhookCompressionTest extends TestCase
{
private const API_KEY = 'key';
private const API_SECRET = 'tsec2';
private const JSON_BODY = '{"type":"message.new","message":{"text":"the quick brown fox"}}';

private Client $client;

public function setUp(): void
{
$this->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');
}
}
Loading