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 4464fbf..fed05bb 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -1224,13 +1224,139 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b } /** Verify the signature added to a webhook event. + * + * 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 self::verifySignature($requestBody, $XSignature, $this->apiSecret); + } + + /** 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. + */ + 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`, 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 + { + 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. 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 + { + $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. + * + * @throws StreamException + */ + public static function decodeSnsPayload(string $message): string + { + 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 + { + 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 + * 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 + * envelope is malformed. + */ + public function verifyAndParseWebhook(string $body, string $signature): array + { + return Webhook::verifyAndParseWebhook($body, $signature, $this->apiSecret); + } - return $signature === $XSignature; + /** 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 $messageBody, string $signature): array + { + 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. Delegates to {@see Webhook::verifyAndParseSns()} with + * this client's API secret. + * + * @return array + * @throws StreamException + */ + public function verifyAndParseSns(string $message, string $signature): array + { + 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 new file mode 100644 index 0000000..10e049e --- /dev/null +++ b/tests/unit/WebhookCompressionTest.php @@ -0,0 +1,294 @@ +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 testUngzipPayloadPassthroughPlainBytes(): void + { + $this->assertSame(self::JSON_BODY, Client::ungzipPayload(self::JSON_BODY)); + } + + public function testUngzipPayloadInflatesGzipBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertNotFalse($compressed); + $this->assertSame(self::JSON_BODY, Client::ungzipPayload($compressed)); + } + + public function testUngzipPayloadEmptyInput(): void + { + $this->assertSame('', Client::ungzipPayload('')); + } + + public function testUngzipPayloadShortInputBelowMagicLength(): void + { + $this->assertSame('ab', Client::ungzipPayload('ab')); + } + + public function testUngzipPayloadThrowsOnTruncatedGzipMagic(): void + { + $bad = "\x1f\x8b\x08\x00\x00\x00"; + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/decompress gzip/'); + Client::ungzipPayload($bad); + } + + public function testDecodeSqsPayloadBase64Only(): void + { + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode(self::JSON_BODY)) + ); + } + + public function testDecodeSqsPayloadBase64Plusgzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode($compressed)) + ); + } + + public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/base64-decode/'); + Client::decodeSqsPayload('!!!not-base64!!!'); + } + + public function testDecodeSnsPayloadAliasesDecodeSqsPayload(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $this->assertSame( + Client::decodeSqsPayload($wrapped), + Client::decodeSnsPayload($wrapped) + ); + } + + public function testVerifySignatureMatching(): void + { + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue(Client::verifySignature(self::JSON_BODY, $sig, self::API_SECRET)); + } + + public function testVerifySignatureMismatched(): void + { + $this->assertFalse( + Client::verifySignature(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET) + ); + } + + 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) + ); + } + + 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 testParseEventUnknownTypeStillParses(): void + { + $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('/parse webhook event/'); + Client::parseEvent('not json'); + } + + public function testVerifyAndParseWebhookPlain(): void + { + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseWebhook(self::JSON_BODY, $sig); + $this->assertSame('message.new', $event['type']); + } + + 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 testVerifyAndParseWebhookSignatureMismatch(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); + } + + 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); + } + + public function testVerifyAndParseSqsBase64Only(): void + { + $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 function testVerifyAndParseSqsBase64Plusgzip(): void + { + $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 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->client->verifyAndParseSqs($wrapped, $sigOverWrapped); + } + + public function testVerifyAndParseSnsRoundTrip(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSns($wrapped, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSnsMatchesSqs(): 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) + ); + } + + 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')); + } + + 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) + ); + } +}