diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 274e3643..0308d9dd 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -85,6 +85,32 @@ 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 + +When webhook compression is enabled on your app (`webhook_compression_algorithm` set to `gzip`), Stream sends the request body gzipped and adds `Content-Encoding: gzip`. The `X-Signature` value is always computed over the **uncompressed** JSON, so handlers must decompress before verifying the signature. + +Use `App.verifyAndDecodeWebhook` to do both in one call. It decompresses (when needed), verifies the HMAC, and returns the raw JSON bytes ready to parse: + +```java +// rawBody — bytes read straight from the HTTP request body +// signature — value of the X-Signature header +// contentEncoding — value of the Content-Encoding header (null when absent) +byte[] json = App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding); +// json now contains the uncompressed JSON; parse it as usual. +``` + +If you prefer to handle the steps yourself, the primitives are also exposed: + +```java +byte[] json = App.decompressWebhookBody(rawBody, contentEncoding); +boolean valid = App.verifyWebhookSignature(apiSecret, json, signature); +``` + +This SDK supports `gzip` only — gzip uses the JDK and adds no external dependencies. Any other `Content-Encoding` value raises an `IllegalStateException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `App.update()` or the dashboard. + +Webservers and frameworks that auto-decompress request bodies (for example nginx with `gunzip on;`, Cloud Run, Spring Boot with `server.compression.enabled`, ASP.NET `RequestDecompression`) typically strip the `Content-Encoding` header before your handler runs. In that case the body you see is already raw JSON and the existing `App.verifyWebhook(body, signature)` call works unchanged. ## Webhook types diff --git a/src/main/java/io/getstream/chat/java/models/App.java b/src/main/java/io/getstream/chat/java/models/App.java index c57eab00..d9eb9eb5 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -22,14 +22,20 @@ import io.getstream.chat.java.models.framework.StreamResponseObject; import io.getstream.chat.java.services.AppService; import io.getstream.chat.java.services.framework.Client; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.Key; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.zip.GZIPInputStream; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import lombok.*; @@ -1460,12 +1466,41 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) { */ public static boolean verifyWebhookSignature( @NotNull String apiSecret, @NotNull String body, @NotNull String signature) { + return verifyWebhookSignature(apiSecret, body.getBytes(StandardCharsets.UTF_8), signature); + } + + /** + * Validates if hmac signature is correct for message body. + * + * @param body the message body + * @param signature the signature provided in X-Signature header + * @return true if the signature is valid + */ + public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) { + String apiSecret = Client.getInstance().getApiSecret(); + return verifyWebhookSignature(apiSecret, body, signature); + } + + /** + * Validates if hmac signature is correct for the raw (uncompressed) body bytes. + * + *

Stream computes {@code X-Signature} over the uncompressed JSON, so when webhook compression + * is enabled callers must decompress the request body first (see {@link + * #decompressWebhookBody(byte[], String)}) and pass the resulting bytes here. + * + * @param apiSecret the app's API secret + * @param body the uncompressed JSON body bytes + * @param signature the signature provided in {@code X-Signature} header + * @return true if the signature matches + */ + public static boolean verifyWebhookSignature( + @NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature) { try { - Key sk = new SecretKeySpec(apiSecret.getBytes(), "HmacSHA256"); + Key sk = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); Mac mac = Mac.getInstance(sk.getAlgorithm()); mac.init(sk); - final byte[] hmac = mac.doFinal(body.getBytes(StandardCharsets.UTF_8)); - return bytesToHex(hmac).equals(signature); + final byte[] hmac = mac.doFinal(body); + return constantTimeEquals(bytesToHex(hmac), signature); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e); } catch (InvalidKeyException e) { @@ -1474,15 +1509,97 @@ public static boolean verifyWebhookSignature( } /** - * Validates if hmac signature is correct for message body. + * Decompresses an outbound webhook body according to the {@code Content-Encoding} header. * - * @param body the message body - * @param signature the signature provided in X-Signature header - * @return true if the signature is valid + *

This SDK only supports {@code gzip} compression. A {@code null} or empty encoding returns + * the body unchanged. Any other value (including {@code br} / {@code zstd}) raises an {@link + * IllegalStateException} so callers can surface a clear error and the operator can flip the app + * back to {@code gzip} on the dashboard. + * + * @param body raw HTTP request body + * @param contentEncoding value of the {@code Content-Encoding} header (case-insensitive); pass + * {@code null} or {@code ""} when no encoding was set + * @return uncompressed body bytes */ - public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) { - String apiSecret = Client.getInstance().getApiSecret(); - return verifyWebhookSignature(apiSecret, body, signature); + public static byte[] decompressWebhookBody( + @NotNull byte[] body, @Nullable String contentEncoding) { + if (contentEncoding == null || contentEncoding.isEmpty()) { + return body; + } + String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT); + if (encoding.isEmpty()) { + return body; + } + if (!"gzip".equals(encoding)) { + throw new IllegalStateException( + "unsupported webhook Content-Encoding: " + + contentEncoding + + ". This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on" + + " the app config."); + } + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) { + return readAll(in); + } catch (IOException e) { + throw new IllegalStateException( + "failed to decompress webhook body (Content-Encoding: " + contentEncoding + ")", e); + } + } + + /** + * Decompresses (when {@code Content-Encoding} is set) and verifies the HMAC signature of an + * outbound webhook request, returning the raw JSON bytes when the signature matches. + * + *

This is the recommended entry point for webhook handlers: it handles every value of {@code + * Content-Encoding} Stream may send and keeps signature verification on the uncompressed body. + * + * @param apiSecret the app's API secret + * @param body raw HTTP request body bytes + * @param signature value of the {@code X-Signature} header + * @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent + * @return the uncompressed JSON body bytes + * @throws SecurityException if the signature does not match + */ + public static byte[] verifyAndDecodeWebhook( + @NotNull String apiSecret, + @NotNull byte[] body, + @NotNull String signature, + @Nullable String contentEncoding) { + byte[] decompressed = decompressWebhookBody(body, contentEncoding); + if (!verifyWebhookSignature(apiSecret, decompressed, signature)) { + throw new SecurityException("invalid webhook signature"); + } + return decompressed; + } + + /** + * Decompresses and verifies a webhook using the API secret of the configured singleton {@link + * Client}. + * + * @param body raw HTTP request body bytes + * @param signature value of the {@code X-Signature} header + * @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent + * @return the uncompressed JSON body bytes + * @throws SecurityException if the signature does not match + */ + public static byte[] verifyAndDecodeWebhook( + @NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) { + return verifyAndDecodeWebhook( + Client.getInstance().getApiSecret(), body, signature, contentEncoding); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + + private static boolean constantTimeEquals(@NotNull String a, @NotNull String b) { + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); } private static String bytesToHex(byte[] hash) { diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java new file mode 100644 index 00000000..a5bc3a56 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -0,0 +1,159 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.App; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class WebhookCompressionTest { + + private static final String API_SECRET = "tsec2"; + private static final String JSON_BODY = + "{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}"; + + private static byte[] gzip(byte[] raw) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(out)) { + gz.write(raw); + } + return out.toByteArray(); + } + + private static String hmacSHA256Hex(String secret, byte[] body) throws Exception { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init( + new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hmac = mac.doFinal(body); + StringBuilder hex = new StringBuilder(2 * hmac.length); + for (byte b : hmac) { + String h = Integer.toHexString(0xff & b); + if (h.length() == 1) { + hex.append('0'); + } + hex.append(h); + } + return hex.toString(); + } + + @Test + @DisplayName("decompressWebhookBody returns body unchanged when Content-Encoding is empty") + void decompressWebhookBody_passthroughWhenEncodingEmpty() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, null)); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, "")); + } + + @Test + @DisplayName("decompressWebhookBody round-trips gzip bytes") + void decompressWebhookBody_gzipRoundTrip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + Assertions.assertTrue( + compressed.length > 0 && compressed.length != raw.length, + "fixture sanity: gzipped bytes should differ from raw"); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "gzip")); + } + + @Test + @DisplayName("decompressWebhookBody handles Content-Encoding case-insensitively") + void decompressWebhookBody_caseInsensitiveEncoding() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "GZIP")); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, " gzip ")); + } + + @Test + @DisplayName("decompressWebhookBody rejects every non-gzip Content-Encoding") + void decompressWebhookBody_nonGzipRejected() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + for (String encoding : new String[] {"br", "brotli", "zstd", "deflate", "compress", "lz4"}) { + IllegalStateException ex = + Assertions.assertThrows( + IllegalStateException.class, + () -> App.decompressWebhookBody(raw, encoding), + "encoding " + encoding + " should be rejected"); + Assertions.assertTrue( + ex.getMessage().contains("unsupported"), + "error for " + encoding + " should mention 'unsupported'; got: " + ex.getMessage()); + Assertions.assertTrue( + ex.getMessage().contains("gzip"), + "error for " + + encoding + + " should point operators back to gzip; got: " + + ex.getMessage()); + } + } + + @Test + @DisplayName("decompressWebhookBody throws when the payload is not actually gzip") + void decompressWebhookBody_invalidGzipBytes() { + byte[] notGzip = "not actually gzip".getBytes(StandardCharsets.UTF_8); + IllegalStateException ex = + Assertions.assertThrows( + IllegalStateException.class, () -> App.decompressWebhookBody(notGzip, "gzip")); + Assertions.assertTrue(ex.getMessage().contains("failed to decompress")); + } + + @Test + @DisplayName("verifyWebhookSignature accepts byte[] body and matches the string overload") + void verifyWebhookSignature_bytesOverload() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, raw, sig)); + Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig)); + Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, raw, "deadbeef")); + } + + @Test + @DisplayName( + "verifyAndDecodeWebhook decompresses gzip and returns raw JSON when signature matches") + void verifyAndDecodeWebhook_gzipHappyPath() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + String sig = hmacSHA256Hex(API_SECRET, raw); + + byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, compressed, sig, "gzip"); + Assertions.assertArrayEquals(raw, decoded); + } + + @Test + @DisplayName("verifyAndDecodeWebhook works for uncompressed bodies (no Content-Encoding)") + void verifyAndDecodeWebhook_passthroughHappyPath() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + + byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, null); + Assertions.assertArrayEquals(raw, decoded); + + byte[] decodedEmpty = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, ""); + Assertions.assertArrayEquals(raw, decodedEmpty); + } + + @Test + @DisplayName("verifyAndDecodeWebhook throws SecurityException on signature mismatch") + void verifyAndDecodeWebhook_badSignature() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndDecodeWebhook(API_SECRET, compressed, "00", "gzip")); + } + + @Test + @DisplayName( + "verifyAndDecodeWebhook rejects gzip body when signature was computed over compressed bytes") + void verifyAndDecodeWebhook_signatureMustBeOverUncompressed() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndDecodeWebhook(API_SECRET, compressed, sigOverCompressed, "gzip")); + } +}