From a96bf2fd2de057d0ce2539a5be4c8dd7d0c0729f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 6 May 2026 16:38:37 +0200 Subject: [PATCH 01/10] [CHA-3071] feat: decode gzip-compressed webhook bodies Adds App.decompressWebhookBody and App.verifyAndDecodeWebhook so handlers can accept the new outbound webhook compression (GetStream/chat#13222) without changing how X-Signature is verified. decompressWebhookBody returns the body unchanged when Content-Encoding is null or empty, gunzips with java.util.zip.GZIPInputStream when the header is gzip (case-insensitive, trimmed), and throws IllegalStateException for any other value with a message that points the operator at the app's webhook_compression_algorithm setting. verifyWebhookSignature gains a byte[] overload so the existing String overload no longer round-trips through UTF-8 unnecessarily, and the equality check moves to MessageDigest.isEqual so comparison is constant-time. verifyAndDecodeWebhook chains decompression with the HMAC check and returns the raw JSON when the signature matches; SecurityException is thrown otherwise. The signature is always computed over the uncompressed bytes, matching the server. The webhook docs are updated with the new Content-Encoding header row and a worked example using verifyAndDecodeWebhook. Tests cover gzip round-trip, null/empty/whitespace passthrough, case- insensitive Content-Encoding, invalid gzip bytes, every non-gzip encoding rejected with a clear hint, byte[] / String HMAC overload parity, signature mismatch, and the regression case where the signature was computed over the compressed bytes. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 26 +++ .../io/getstream/chat/java/models/App.java | 137 +++++++++++++-- .../chat/java/WebhookCompressionTest.java | 159 ++++++++++++++++++ 3 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 src/test/java/io/getstream/chat/java/WebhookCompressionTest.java 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")); + } +} From 0c69036042b235df4684487d64d7f656b5d38122 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 7 May 2026 12:33:04 +0200 Subject: [PATCH 02/10] [CHA-3071] feat: support base64 payload_encoding for SQS / SNS Extends `decompressWebhookBody` and `verifyAndDecodeWebhook` with an optional `payloadEncoding` argument. When set to "base64" (the wrapper Stream applies for SQS / SNS firehose so the message stays valid UTF-8 over the queue), the body is base64-decoded before gzip decompression. The HMAC signature continues to be computed over the innermost (uncompressed, base64-decoded) JSON, so the verification rule is invariant across HTTP webhooks and SQS / SNS. `null` / `""` for payloadEncoding is a no-op, so the HTTP webhook path is byte-identical to before this change. The existing 3-argument overloads of `decompressWebhookBody` and `verifyAndDecodeWebhook` are preserved for backward compatibility. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 26 ++- .../io/getstream/chat/java/models/App.java | 166 +++++++++++++----- .../chat/java/WebhookCompressionTest.java | 76 ++++++++ 3 files changed, 221 insertions(+), 47 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 0308d9dd..cbb59f9d 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -89,9 +89,17 @@ All webhook requests contain these headers: ### 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. +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. -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: +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 `App.verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON bytes ready to parse: ```java // rawBody — bytes read straight from the HTTP request body @@ -110,7 +118,19 @@ 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. +#### 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: + +```java +// body — the SQS Body / SNS Message string, decoded to bytes +// signature — X-Signature attribute value +// contentEncoding — "gzip" when compression is enabled, otherwise null +// payloadEncoding — "base64" for SQS / SNS firehose payloads +byte[] json = App.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/src/main/java/io/getstream/chat/java/models/App.java b/src/main/java/io/getstream/chat/java/models/App.java index d9eb9eb5..3a6ea3a2 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -31,6 +31,7 @@ import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.Date; import java.util.List; import java.util.Locale; @@ -592,7 +593,7 @@ public static class DeviceError { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationCallback { @Nullable @JsonProperty("mode") @@ -605,7 +606,7 @@ public static class AsyncModerationCallback { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationConfigRequestObject { @Nullable @JsonProperty("callback") @@ -618,7 +619,7 @@ public static class AsyncModerationConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FileUploadConfigRequestObject { @Nullable @@ -650,7 +651,7 @@ public static FileUploadConfigRequestObject buildFrom( @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class APNConfigRequestObject { @Nullable @JsonProperty("development") @@ -696,7 +697,7 @@ public static APNConfigRequestObject buildFrom(@Nullable APNConfig aPNConfig) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FirebaseConfigRequestObject { @Nullable @JsonProperty("server_key") @@ -726,7 +727,7 @@ public static FirebaseConfigRequestObject buildFrom(@Nullable FirebaseConfig fir @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class HuaweiConfigRequestObject { @Nullable @JsonProperty("id") @@ -739,7 +740,7 @@ public static class HuaweiConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class PushConfigRequestObject { @Nullable @JsonProperty("version") @@ -770,7 +771,7 @@ protected Call generateCall(Client client) { } @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class DeletePushProviderRequest extends StreamRequest { private String providerType; private String name; @@ -791,7 +792,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppUpdateRequestData { @Nullable @JsonProperty("disable_auth_checks") @@ -982,7 +983,7 @@ public boolean equals(Object o) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode @NoArgsConstructor @AllArgsConstructor public static class AppGetRateLimitsRequest extends StreamRequest { @@ -1014,7 +1015,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckSqsRequestData { @Nullable @JsonProperty("sqs_url") @@ -1041,7 +1042,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckSnsRequestData { @Nullable @JsonProperty("sns_topic_arn") @@ -1068,7 +1069,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckPushRequestData { @Nullable @JsonProperty("message_id") @@ -1116,7 +1117,7 @@ protected Call generateCall(Client client) { @AllArgsConstructor @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppRevokeTokensRequest extends StreamRequest { @Nullable private Date revokeTokensIssuedBefore; @@ -1509,26 +1510,69 @@ public static boolean verifyWebhookSignature( } /** - * Decompresses an outbound webhook body according to the {@code Content-Encoding} header. + * Decompresses an outbound webhook body. Equivalent to {@link #decompressWebhookBody(byte[], + * String, String)} with {@code payloadEncoding == null}. + */ + public static byte[] decompressWebhookBody( + @NotNull byte[] body, @Nullable String contentEncoding) { + return decompressWebhookBody(body, contentEncoding, null); + } + + /** + * Decompresses an outbound webhook body, optionally undoing a transport-level wrapper first. * - *

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 + *

The returned bytes are the uncompressed JSON the server signed. Decode order matches the + * inverse of how the server built the message: + * + *

    + *
  1. If {@code payloadEncoding} is {@code "base64"}, base64-decode the body. This is the + * wrapper Stream uses for SQS / SNS firehose so the message stays valid UTF-8 over + * transport. + *
  2. If {@code contentEncoding} is {@code "gzip"}, gunzip the result. + *
+ * + *

This SDK only supports {@code gzip} for compression and {@code base64} for the transport + * wrapper. Any other value (including {@code br} / {@code zstd}) raises {@link * IllegalStateException} so callers can surface a clear error and the operator can flip the app - * back to {@code gzip} on the dashboard. + * back to {@code gzip} on the dashboard. {@code null} / {@code ""} for either argument is a + * no-op, which keeps the HTTP webhook path identical to before this method existed. * - * @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 + * @param body raw HTTP request body / SQS message body / SNS notification message + * @param contentEncoding value of the {@code Content-Encoding} header / message attribute + * (case-insensitive); {@code null} when absent + * @param payloadEncoding transport wrapper applied after compression (today: {@code "base64"} for + * SQS / SNS firehose, {@code null} for HTTP webhooks) + * @return uncompressed body bytes (the JSON Stream signed) */ public static byte[] decompressWebhookBody( - @NotNull byte[] body, @Nullable String contentEncoding) { + @NotNull byte[] body, @Nullable String contentEncoding, @Nullable String payloadEncoding) { + byte[] working = body; + + if (payloadEncoding != null) { + String pe = payloadEncoding.trim().toLowerCase(Locale.ROOT); + if (!pe.isEmpty()) { + if (!"base64".equals(pe) && !"b64".equals(pe)) { + throw new IllegalStateException( + "unsupported webhook payload_encoding: " + + payloadEncoding + + ". This SDK only supports base64."); + } + try { + working = Base64.getDecoder().decode(working); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "failed to base64-decode webhook body (payload_encoding: " + payloadEncoding + ")", + e); + } + } + } + if (contentEncoding == null || contentEncoding.isEmpty()) { - return body; + return working; } String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT); if (encoding.isEmpty()) { - return body; + return working; } if (!"gzip".equals(encoding)) { throw new IllegalStateException( @@ -1537,7 +1581,7 @@ public static byte[] decompressWebhookBody( + ". This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on" + " the app config."); } - try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(working))) { return readAll(in); } catch (IOException e) { throw new IllegalStateException( @@ -1546,16 +1590,42 @@ public static byte[] decompressWebhookBody( } /** - * 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. + * Convenience overload of {@link #verifyAndDecodeWebhook(String, byte[], String, String, String)} + * for HTTP webhooks (no transport wrapper). + */ + public static byte[] verifyAndDecodeWebhook( + @NotNull String apiSecret, + @NotNull byte[] body, + @NotNull String signature, + @Nullable String contentEncoding) { + return verifyAndDecodeWebhook(apiSecret, body, signature, contentEncoding, null); + } + + /** + * Decompresses (when {@code Content-Encoding} / {@code payload_encoding} are set) and verifies + * the HMAC signature of an outbound Stream message, returning the raw JSON bytes when the + * signature matches. + * + *

This is the recommended entry point for handlers, regardless of transport: * - *

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. + *

+ * + * The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, so the + * verification rule is invariant across transports. * * @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 + * @param body raw transport bytes + * @param signature value of the {@code X-Signature} header / message attribute + * @param contentEncoding compression applied before transport ({@code "gzip"} or {@code null}) + * @param payloadEncoding transport wrapper applied after compression ({@code "base64"} or {@code + * null}) * @return the uncompressed JSON body bytes * @throws SecurityException if the signature does not match */ @@ -1563,8 +1633,9 @@ public static byte[] verifyAndDecodeWebhook( @NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature, - @Nullable String contentEncoding) { - byte[] decompressed = decompressWebhookBody(body, contentEncoding); + @Nullable String contentEncoding, + @Nullable String payloadEncoding) { + byte[] decompressed = decompressWebhookBody(body, contentEncoding, payloadEncoding); if (!verifyWebhookSignature(apiSecret, decompressed, signature)) { throw new SecurityException("invalid webhook signature"); } @@ -1572,19 +1643,26 @@ public static byte[] verifyAndDecodeWebhook( } /** - * 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 + * Convenience overload of {@link #verifyAndDecodeWebhook(byte[], String, String, String)} for + * HTTP webhooks (no transport wrapper). Uses the configured singleton {@link Client} secret. */ public static byte[] verifyAndDecodeWebhook( @NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) { return verifyAndDecodeWebhook( - Client.getInstance().getApiSecret(), body, signature, contentEncoding); + Client.getInstance().getApiSecret(), body, signature, contentEncoding, null); + } + + /** + * Verifies and decodes a Stream message using the API secret of the configured singleton {@link + * Client}, supporting both HTTP webhooks and SQS / SNS envelopes via {@code payloadEncoding}. + */ + public static byte[] verifyAndDecodeWebhook( + @NotNull byte[] body, + @NotNull String signature, + @Nullable String contentEncoding, + @Nullable String payloadEncoding) { + return verifyAndDecodeWebhook( + Client.getInstance().getApiSecret(), body, signature, contentEncoding, payloadEncoding); } private static byte[] readAll(InputStream in) throws IOException { diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java index a5bc3a56..a69b777b 100644 --- a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -3,6 +3,7 @@ import io.getstream.chat.java.models.App; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.zip.GZIPOutputStream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -156,4 +157,79 @@ void verifyAndDecodeWebhook_signatureMustBeOverUncompressed() throws Exception { SecurityException.class, () -> App.verifyAndDecodeWebhook(API_SECRET, compressed, sigOverCompressed, "gzip")); } + + @Test + @DisplayName("decompressWebhookBody round-trips base64+gzip (SQS / SNS firehose envelope)") + void decompressWebhookBody_base64GzipRoundTrip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + byte[] wrapped = Base64.getEncoder().encode(compressed); + + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "gzip", "base64")); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "GZIP", "BASE64")); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "gzip", "b64")); + } + + @Test + @DisplayName("decompressWebhookBody round-trips base64-only payloads (no compression)") + void decompressWebhookBody_base64OnlyRoundTrip() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] wrapped = Base64.getEncoder().encode(raw); + + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, null, "base64")); + Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "", "base64")); + } + + @Test + @DisplayName("decompressWebhookBody rejects unsupported payload_encoding values") + void decompressWebhookBody_unsupportedPayloadEncoding() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + for (String pe : new String[] {"hex", "url", "ascii85", "binary"}) { + IllegalStateException ex = + Assertions.assertThrows( + IllegalStateException.class, + () -> App.decompressWebhookBody(raw, null, pe), + "payload_encoding " + pe + " should be rejected"); + Assertions.assertTrue( + ex.getMessage().contains("payload_encoding"), + "error for " + pe + " should mention payload_encoding; got: " + ex.getMessage()); + } + } + + @Test + @DisplayName("decompressWebhookBody throws when base64 input is malformed") + void decompressWebhookBody_invalidBase64() { + byte[] notBase64 = "not!valid!base64".getBytes(StandardCharsets.UTF_8); + IllegalStateException ex = + Assertions.assertThrows( + IllegalStateException.class, + () -> App.decompressWebhookBody(notBase64, null, "base64")); + Assertions.assertTrue(ex.getMessage().contains("base64-decode")); + } + + @Test + @DisplayName("verifyAndDecodeWebhook decodes SQS/SNS-style base64+gzip payloads end-to-end") + void verifyAndDecodeWebhook_base64GzipHappyPath() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + byte[] wrapped = Base64.getEncoder().encode(compressed); + String sig = hmacSHA256Hex(API_SECRET, raw); + + byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, wrapped, sig, "gzip", "base64"); + Assertions.assertArrayEquals(raw, decoded); + } + + @Test + @DisplayName( + "verifyAndDecodeWebhook rejects base64+gzip body when signature is computed over wrapped bytes") + void verifyAndDecodeWebhook_signatureMustBeOverInnermost() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + byte[] wrapped = Base64.getEncoder().encode(compressed); + String sigOverWrapped = hmacSHA256Hex(API_SECRET, wrapped); + + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndDecodeWebhook(API_SECRET, wrapped, sigOverWrapped, "gzip", "base64")); + } } From f8682fce34b06820fa5b3adbe26cbbdb8ee03ec9 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 15:52:23 +0200 Subject: [PATCH 03/10] refactor(webhooks): switch to verifyAndParse* API (CHA-3071) Replaces verifyAndDecodeWebhook / decompressWebhookBody on App with the cross-SDK contract documented at https://getstream.io/chat/docs/node/webhooks_overview/. Static helpers on App: Primitives: ungzipPayload - gzip magic-byte detection + inflate decodeSqsPayload - base64 then ungzip-if-magic (String -> byte[]) decodeSnsPayload - alias for decodeSqsPayload verifySignature - constant-time HMAC-SHA256 comparison (parameter order matches the cross-SDK spec) parseEvent - JSON -> typed Event via Jackson Composite (return Event): verifyAndParseWebhook verifyAndParseSqs verifyAndParseSns Each composite has a singleton-secret overload that pulls the API secret from Client.getInstance(), so handler code stays terse. The composite functions auto-detect compression from body bytes, keeping the same handler correct whether or not Stream is currently compressing payloads, and behind middleware that auto-decompresses. Backward compatibility: * App.verifyWebhook(body, signature) -> bool kept unchanged. * App.verifyWebhookSignature(...) overloads kept; they now delegate to verifySignature internally. Co-authored-by: Cursor --- .../io/getstream/chat/java/models/App.java | 270 ++++++++---------- .../chat/java/WebhookCompressionTest.java | 262 +++++++++-------- 2 files changed, 256 insertions(+), 276 deletions(-) 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 3a6ea3a2..acdf5da0 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -34,7 +34,6 @@ import java.util.Base64; 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; @@ -1447,7 +1446,11 @@ public static DeletePushProviderRequest deletePushProvider( } /** - * Validates if hmac signature is correct for message body. + * Validates if hmac signature is correct for the message body. + * + *

Kept for backward compatibility. New integrations should call {@link + * #verifyAndParseWebhook(byte[], String)} (or the SQS / SNS variants), which also handle gzip + * payload compression. * * @param body raw body from http request converted to a string. * @param signature the signature provided in X-Signature header @@ -1458,7 +1461,8 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) { } /** - * Validates if hmac signature is correct for message body. + * Validates if hmac signature is correct for message body. Backward-compatible alias for {@link + * #verifySignature(byte[], String, String)}. * * @param apiSecret the secret key * @param body raw body from http request converted to a string. @@ -1467,41 +1471,44 @@ 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); + return verifySignature(body.getBytes(StandardCharsets.UTF_8), signature, apiSecret); } /** - * Validates if hmac signature is correct for message body. + * Validates if hmac signature is correct for the message body using the singleton client's API + * secret. * * @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); + return verifySignature( + body.getBytes(StandardCharsets.UTF_8), signature, Client.getInstance().getApiSecret()); } /** - * Validates if hmac signature is correct for the raw (uncompressed) body bytes. + * Constant-time HMAC-SHA256 verification of {@code signature} against the digest of {@code body} + * using {@code secret} as the key. * - *

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. + *

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. * - * @param apiSecret the app's API secret - * @param body the uncompressed JSON body bytes - * @param signature the signature provided in {@code X-Signature} header + * @param body the uncompressed body bytes + * @param signature the signature provided in {@code X-Signature} + * @param secret the app's API secret * @return true if the signature matches */ - public static boolean verifyWebhookSignature( - @NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature) { + public static boolean verifySignature( + @NotNull byte[] body, @NotNull String signature, @NotNull String secret) { try { - Key sk = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + Key sk = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); Mac mac = Mac.getInstance(sk.getAlgorithm()); mac.init(sk); final byte[] hmac = mac.doFinal(body); - return constantTimeEquals(bytesToHex(hmac), signature); + return MessageDigest.isEqual( + bytesToHex(hmac).getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e); } catch (InvalidKeyException e) { @@ -1509,160 +1516,128 @@ public static boolean verifyWebhookSignature( } } - /** - * Decompresses an outbound webhook body. Equivalent to {@link #decompressWebhookBody(byte[], - * String, String)} with {@code payloadEncoding == null}. - */ - public static byte[] decompressWebhookBody( - @NotNull byte[] body, @Nullable String contentEncoding) { - return decompressWebhookBody(body, contentEncoding, null); - } + private static final byte[] GZIP_MAGIC = new byte[] {0x1f, (byte) 0x8b, 0x08}; /** - * Decompresses an outbound webhook body, optionally undoing a transport-level wrapper first. - * - *

The returned bytes are the uncompressed JSON the server signed. Decode order matches the - * inverse of how the server built the message: + * Returns {@code body} unchanged unless it starts with the gzip magic ({@code 1f 8b 08}), in + * which case the gzip stream is inflated and the decompressed bytes are returned. * - *

    - *
  1. If {@code payloadEncoding} is {@code "base64"}, base64-decode the body. This is the - * wrapper Stream uses for SQS / SNS firehose so the message stays valid UTF-8 over - * transport. - *
  2. If {@code contentEncoding} is {@code "gzip"}, gunzip the result. - *
- * - *

This SDK only supports {@code gzip} for compression and {@code base64} for the transport - * wrapper. Any other value (including {@code br} / {@code zstd}) raises {@link - * IllegalStateException} so callers can surface a clear error and the operator can flip the app - * back to {@code gzip} on the dashboard. {@code null} / {@code ""} for either argument is a - * no-op, which keeps the HTTP webhook path identical to before this method existed. - * - * @param body raw HTTP request body / SQS message body / SNS notification message - * @param contentEncoding value of the {@code Content-Encoding} header / message attribute - * (case-insensitive); {@code null} when absent - * @param payloadEncoding transport wrapper applied after compression (today: {@code "base64"} for - * SQS / SNS firehose, {@code null} for HTTP webhooks) - * @return uncompressed body bytes (the JSON Stream signed) + *

Magic-byte detection (rather than relying on a header) lets the same handler stay correct + * when middleware auto-decompresses the request before your code sees it. */ - public static byte[] decompressWebhookBody( - @NotNull byte[] body, @Nullable String contentEncoding, @Nullable String payloadEncoding) { - byte[] working = body; - - if (payloadEncoding != null) { - String pe = payloadEncoding.trim().toLowerCase(Locale.ROOT); - if (!pe.isEmpty()) { - if (!"base64".equals(pe) && !"b64".equals(pe)) { - throw new IllegalStateException( - "unsupported webhook payload_encoding: " - + payloadEncoding - + ". This SDK only supports base64."); - } - try { - working = Base64.getDecoder().decode(working); - } catch (IllegalArgumentException e) { - throw new IllegalStateException( - "failed to base64-decode webhook body (payload_encoding: " + payloadEncoding + ")", - e); - } - } - } - - if (contentEncoding == null || contentEncoding.isEmpty()) { - return working; + public static byte[] ungzipPayload(@NotNull byte[] body) { + if (body.length < 3 + || body[0] != GZIP_MAGIC[0] + || body[1] != GZIP_MAGIC[1] + || body[2] != GZIP_MAGIC[2]) { + return body; } - String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT); - if (encoding.isEmpty()) { - return working; - } - 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(working))) { + 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); + throw new IllegalStateException("failed to decompress gzip payload", e); } } /** - * Convenience overload of {@link #verifyAndDecodeWebhook(String, byte[], String, String, String)} - * for HTTP webhooks (no transport wrapper). + * Reverses the SQS firehose envelope: the message {@code Body} is base64-decoded and, when the + * result begins with the gzip magic, it is gzip-decompressed. The same call works whether or not + * Stream is currently compressing payloads. + * + * @param body the SQS message {@code Body} + * @return the raw JSON bytes Stream signed */ - public static byte[] verifyAndDecodeWebhook( - @NotNull String apiSecret, - @NotNull byte[] body, - @NotNull String signature, - @Nullable String contentEncoding) { - return verifyAndDecodeWebhook(apiSecret, body, signature, contentEncoding, null); + public static byte[] decodeSqsPayload(@NotNull String body) { + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(body); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("failed to base64-decode payload", e); + } + return ungzipPayload(decoded); } /** - * Decompresses (when {@code Content-Encoding} / {@code payload_encoding} are set) and verifies - * the HMAC signature of an outbound Stream message, returning the raw JSON bytes when the - * signature matches. - * - *

This is the recommended entry point for handlers, regardless of transport: - * - *

    - *
  • HTTP webhooks: {@code body} is the request body, {@code signature} comes from - * {@code X-Signature}, {@code contentEncoding} from {@code Content-Encoding}, {@code - * payloadEncoding} is {@code null}. - *
  • SQS / SNS firehose: {@code body} is the SQS {@code Body} or SNS {@code Message}, - * {@code signature} / {@code contentEncoding} / {@code payloadEncoding} come from the - * corresponding message attributes. - *
- * - * The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, so the - * verification rule is invariant across transports. + * Byte-for-byte identical to {@link #decodeSqsPayload(String)}; exposed under both names so call + * sites read intent. + */ + public static byte[] decodeSnsPayload(@NotNull String message) { + return decodeSqsPayload(message); + } + + /** + * Parse a JSON-encoded webhook event into a typed {@link Event}. Unknown event types still parse + * successfully because {@link Event#getType()} is a free-form string. * - * @param apiSecret the app's API secret - * @param body raw transport bytes - * @param signature value of the {@code X-Signature} header / message attribute - * @param contentEncoding compression applied before transport ({@code "gzip"} or {@code null}) - * @param payloadEncoding transport wrapper applied after compression ({@code "base64"} or {@code - * null}) - * @return the uncompressed JSON body bytes - * @throws SecurityException if the signature does not match + * @throws IllegalStateException when the bytes are not valid JSON */ - public static byte[] verifyAndDecodeWebhook( - @NotNull String apiSecret, - @NotNull byte[] body, - @NotNull String signature, - @Nullable String contentEncoding, - @Nullable String payloadEncoding) { - byte[] decompressed = decompressWebhookBody(body, contentEncoding, payloadEncoding); - if (!verifyWebhookSignature(apiSecret, decompressed, signature)) { + public static @NotNull Event parseEvent(@NotNull byte[] payload) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().readValue(payload, Event.class); + } catch (IOException e) { + throw new IllegalStateException("failed to parse webhook event", e); + } + } + + private static @NotNull Event verifyAndParseInternal( + @NotNull byte[] payload, @NotNull String signature, @NotNull String secret) { + if (!verifySignature(payload, signature, secret)) { throw new SecurityException("invalid webhook signature"); } - return decompressed; + return parseEvent(payload); + } + + /** + * Decompresses {@code body} when gzipped, verifies the HMAC {@code signature}, and returns the + * parsed {@link Event}. Works for HTTP webhooks regardless of whether payload compression is + * enabled. + * + * @param body raw HTTP request body bytes Stream signed + * @param signature value of the {@code X-Signature} header + * @param secret the app's API secret + * @return the parsed event + * @throws SecurityException when the signature does not match + * @throws IllegalStateException when the gzip envelope is malformed or the payload is not JSON + */ + public static @NotNull Event verifyAndParseWebhook( + @NotNull byte[] body, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(ungzipPayload(body), signature, secret); + } + + /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */ + public static @NotNull Event verifyAndParseWebhook( + @NotNull byte[] body, @NotNull String signature) { + return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret()); } /** - * Convenience overload of {@link #verifyAndDecodeWebhook(byte[], String, String, String)} for - * HTTP webhooks (no transport wrapper). Uses the configured singleton {@link Client} secret. + * Decode the SQS {@code Body} (base64, then gzip-if-magic), verify the HMAC {@code signature} + * from the {@code X-Signature} message attribute, and return the parsed {@link Event}. */ - public static byte[] verifyAndDecodeWebhook( - @NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) { - return verifyAndDecodeWebhook( - Client.getInstance().getApiSecret(), body, signature, contentEncoding, null); + public static @NotNull Event verifyAndParseSqs( + @NotNull String messageBody, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret); + } + + /** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */ + public static @NotNull Event verifyAndParseSqs( + @NotNull String messageBody, @NotNull String signature) { + return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret()); } /** - * Verifies and decodes a Stream message using the API secret of the configured singleton {@link - * Client}, supporting both HTTP webhooks and SQS / SNS envelopes via {@code payloadEncoding}. + * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code + * signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}. */ - public static byte[] verifyAndDecodeWebhook( - @NotNull byte[] body, - @NotNull String signature, - @Nullable String contentEncoding, - @Nullable String payloadEncoding) { - return verifyAndDecodeWebhook( - Client.getInstance().getApiSecret(), body, signature, contentEncoding, payloadEncoding); + public static @NotNull Event verifyAndParseSns( + @NotNull String message, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(decodeSnsPayload(message), signature, secret); + } + + /** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */ + public static @NotNull Event verifyAndParseSns( + @NotNull String message, @NotNull String signature) { + return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret()); } private static byte[] readAll(InputStream in) throws IOException { @@ -1675,11 +1650,6 @@ private static byte[] readAll(InputStream in) throws IOException { 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) { StringBuilder hexString = new StringBuilder(2 * hash.length); for (byte b : hash) { diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java index a69b777b..03cab9d9 100644 --- a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -1,6 +1,7 @@ package io.getstream.chat.java; import io.getstream.chat.java.models.App; +import io.getstream.chat.java.models.Event; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -23,6 +24,10 @@ private static byte[] gzip(byte[] raw) throws Exception { return out.toByteArray(); } + private static String base64(byte[] raw) { + return Base64.getEncoder().encodeToString(raw); + } + private static String hmacSHA256Hex(String secret, byte[] body) throws Exception { javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); mac.init( @@ -40,196 +45,201 @@ private static String hmacSHA256Hex(String secret, byte[] body) throws Exception } @Test - @DisplayName("decompressWebhookBody returns body unchanged when Content-Encoding is empty") - void decompressWebhookBody_passthroughWhenEncodingEmpty() { + @DisplayName("ungzipPayload passes through plain bytes unchanged") + void ungzipPayload_passthroughPlainBytes() { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, null)); - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, "")); + Assertions.assertArrayEquals(raw, App.ungzipPayload(raw)); } @Test - @DisplayName("decompressWebhookBody round-trips gzip bytes") - void decompressWebhookBody_gzipRoundTrip() throws Exception { + @DisplayName("ungzipPayload inflates gzip-magic bytes") + void ungzipPayload_inflatesGzip() 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")); + Assertions.assertArrayEquals(raw, App.ungzipPayload(gzip(raw))); } @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()); - } + @DisplayName("ungzipPayload returns empty input unchanged") + void ungzipPayload_emptyInput() { + Assertions.assertArrayEquals(new byte[0], App.ungzipPayload(new byte[0])); } @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")); + @DisplayName("ungzipPayload throws on truncated gzip with magic") + void ungzipPayload_truncatedGzipThrows() { + byte[] bad = new byte[] {0x1f, (byte) 0x8b, 0x08, 0, 0, 0}; + Assertions.assertThrows(IllegalStateException.class, () -> App.ungzipPayload(bad)); } @Test - @DisplayName("verifyWebhookSignature accepts byte[] body and matches the string overload") - void verifyWebhookSignature_bytesOverload() throws Exception { + @DisplayName("decodeSqsPayload decodes base64 only when no compression") + void decodeSqsPayload_base64Only() { 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")); + Assertions.assertArrayEquals(raw, App.decodeSqsPayload(base64(raw))); } @Test - @DisplayName( - "verifyAndDecodeWebhook decompresses gzip and returns raw JSON when signature matches") - void verifyAndDecodeWebhook_gzipHappyPath() throws Exception { + @DisplayName("decodeSqsPayload decodes base64 + gzip") + void decodeSqsPayload_base64Gzip() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - byte[] compressed = gzip(raw); - String sig = hmacSHA256Hex(API_SECRET, raw); + Assertions.assertArrayEquals(raw, App.decodeSqsPayload(base64(gzip(raw)))); + } - byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, compressed, sig, "gzip"); - Assertions.assertArrayEquals(raw, decoded); + @Test + @DisplayName("decodeSqsPayload throws on malformed base64") + void decodeSqsPayload_malformedBase64() { + Assertions.assertThrows( + IllegalStateException.class, () -> App.decodeSqsPayload("!!!not-base64!!!")); } @Test - @DisplayName("verifyAndDecodeWebhook works for uncompressed bodies (no Content-Encoding)") - void verifyAndDecodeWebhook_passthroughHappyPath() throws Exception { + @DisplayName("decodeSnsPayload aliases decodeSqsPayload") + void decodeSnsPayload_aliasesSqs() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - String sig = hmacSHA256Hex(API_SECRET, raw); + String wrapped = base64(gzip(raw)); + Assertions.assertArrayEquals(App.decodeSqsPayload(wrapped), App.decodeSnsPayload(wrapped)); + } - byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, null); - Assertions.assertArrayEquals(raw, decoded); + @Test + @DisplayName("verifySignature returns true for matching HMAC") + void verifySignature_matching() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Assertions.assertTrue(App.verifySignature(raw, sig, API_SECRET)); + } - byte[] decodedEmpty = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, ""); - Assertions.assertArrayEquals(raw, decodedEmpty); + @Test + @DisplayName("verifySignature returns false for mismatched signature") + void verifySignature_mismatched() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertFalse(App.verifySignature(raw, "0".repeat(64), API_SECRET)); } @Test - @DisplayName("verifyAndDecodeWebhook throws SecurityException on signature mismatch") - void verifyAndDecodeWebhook_badSignature() throws Exception { + @DisplayName("verifySignature returns false when computed over compressed bytes") + void verifySignature_overCompressedRejected() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); byte[] compressed = gzip(raw); + String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + Assertions.assertFalse(App.verifySignature(raw, sigOverCompressed, API_SECRET)); + } + @Test + @DisplayName("parseEvent parses known event type into typed Event") + void parseEvent_known() { + Event ev = App.parseEvent(JSON_BODY.getBytes(StandardCharsets.UTF_8)); + Assertions.assertEquals("message.new", ev.getType()); + Assertions.assertNotNull(ev.getMessage()); + Assertions.assertEquals("the quick brown fox", ev.getMessage().getText()); + } + + @Test + @DisplayName("parseEvent handles unknown event types") + void parseEvent_unknownType() { + Event ev = + App.parseEvent( + "{\"type\":\"a.future.event\",\"custom\":42}".getBytes(StandardCharsets.UTF_8)); + Assertions.assertEquals("a.future.event", ev.getType()); + } + + @Test + @DisplayName("parseEvent throws on malformed JSON") + void parseEvent_malformed() { Assertions.assertThrows( - SecurityException.class, - () -> App.verifyAndDecodeWebhook(API_SECRET, compressed, "00", "gzip")); + IllegalStateException.class, + () -> App.parseEvent("not json".getBytes(StandardCharsets.UTF_8))); } @Test - @DisplayName( - "verifyAndDecodeWebhook rejects gzip body when signature was computed over compressed bytes") - void verifyAndDecodeWebhook_signatureMustBeOverUncompressed() throws Exception { + @DisplayName("verifyAndParseWebhook parses plain JSON body with valid signature") + void verifyAndParseWebhook_plain() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - byte[] compressed = gzip(raw); - String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseWebhook(raw, sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseWebhook parses gzip-compressed body") + void verifyAndParseWebhook_gzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseWebhook(gzip(raw), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + @Test + @DisplayName("verifyAndParseWebhook throws SecurityException on signature mismatch") + void verifyAndParseWebhook_signatureMismatch() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); Assertions.assertThrows( - SecurityException.class, - () -> App.verifyAndDecodeWebhook(API_SECRET, compressed, sigOverCompressed, "gzip")); + SecurityException.class, () -> App.verifyAndParseWebhook(raw, "0".repeat(64), API_SECRET)); } @Test - @DisplayName("decompressWebhookBody round-trips base64+gzip (SQS / SNS firehose envelope)") - void decompressWebhookBody_base64GzipRoundTrip() throws Exception { + @DisplayName("verifyAndParseWebhook rejects signature computed over compressed bytes") + void verifyAndParseWebhook_signatureOverCompressed() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); byte[] compressed = gzip(raw); - byte[] wrapped = Base64.getEncoder().encode(compressed); - - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "gzip", "base64")); - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "GZIP", "BASE64")); - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "gzip", "b64")); + String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndParseWebhook(compressed, sigOverCompressed, API_SECRET)); } @Test - @DisplayName("decompressWebhookBody round-trips base64-only payloads (no compression)") - void decompressWebhookBody_base64OnlyRoundTrip() { + @DisplayName("verifyAndParseSqs parses base64-only message body") + void verifyAndParseSqs_base64Only() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - byte[] wrapped = Base64.getEncoder().encode(raw); - - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, null, "base64")); - Assertions.assertArrayEquals(raw, App.decompressWebhookBody(wrapped, "", "base64")); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseSqs(base64(raw), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); } @Test - @DisplayName("decompressWebhookBody rejects unsupported payload_encoding values") - void decompressWebhookBody_unsupportedPayloadEncoding() { + @DisplayName("verifyAndParseSqs parses base64 + gzip message body") + void verifyAndParseSqs_base64Gzip() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - for (String pe : new String[] {"hex", "url", "ascii85", "binary"}) { - IllegalStateException ex = - Assertions.assertThrows( - IllegalStateException.class, - () -> App.decompressWebhookBody(raw, null, pe), - "payload_encoding " + pe + " should be rejected"); - Assertions.assertTrue( - ex.getMessage().contains("payload_encoding"), - "error for " + pe + " should mention payload_encoding; got: " + ex.getMessage()); - } + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseSqs(base64(gzip(raw)), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); } @Test - @DisplayName("decompressWebhookBody throws when base64 input is malformed") - void decompressWebhookBody_invalidBase64() { - byte[] notBase64 = "not!valid!base64".getBytes(StandardCharsets.UTF_8); - IllegalStateException ex = - Assertions.assertThrows( - IllegalStateException.class, - () -> App.decompressWebhookBody(notBase64, null, "base64")); - Assertions.assertTrue(ex.getMessage().contains("base64-decode")); + @DisplayName("verifyAndParseSqs rejects signature over wrapped bytes") + void verifyAndParseSqs_signatureOverWrapped() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String sigOverWrapped = hmacSHA256Hex(API_SECRET, wrapped.getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows( + SecurityException.class, () -> App.verifyAndParseSqs(wrapped, sigOverWrapped, API_SECRET)); } @Test - @DisplayName("verifyAndDecodeWebhook decodes SQS/SNS-style base64+gzip payloads end-to-end") - void verifyAndDecodeWebhook_base64GzipHappyPath() throws Exception { + @DisplayName("verifyAndParseSns parses base64 + gzip notification") + void verifyAndParseSns_base64Gzip() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - byte[] compressed = gzip(raw); - byte[] wrapped = Base64.getEncoder().encode(compressed); String sig = hmacSHA256Hex(API_SECRET, raw); - - byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, wrapped, sig, "gzip", "base64"); - Assertions.assertArrayEquals(raw, decoded); + Event ev = App.verifyAndParseSns(base64(gzip(raw)), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); } @Test - @DisplayName( - "verifyAndDecodeWebhook rejects base64+gzip body when signature is computed over wrapped bytes") - void verifyAndDecodeWebhook_signatureMustBeOverInnermost() throws Exception { + @DisplayName("verifyAndParseSns and verifyAndParseSqs return identical events") + void verifyAndParseSns_matchesSqs() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - byte[] compressed = gzip(raw); - byte[] wrapped = Base64.getEncoder().encode(compressed); - String sigOverWrapped = hmacSHA256Hex(API_SECRET, wrapped); + String sig = hmacSHA256Hex(API_SECRET, raw); + String wrapped = base64(gzip(raw)); + Event sns = App.verifyAndParseSns(wrapped, sig, API_SECRET); + Event sqs = App.verifyAndParseSqs(wrapped, sig, API_SECRET); + Assertions.assertEquals(sqs.getType(), sns.getType()); + } - Assertions.assertThrows( - SecurityException.class, - () -> App.verifyAndDecodeWebhook(API_SECRET, wrapped, sigOverWrapped, "gzip", "base64")); + @Test + @DisplayName("verifyWebhookSignature backward compatibility still validates HMAC") + void verifyWebhookSignature_backwardCompat() throws Exception { + String sig = hmacSHA256Hex(API_SECRET, JSON_BODY.getBytes(StandardCharsets.UTF_8)); + Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig)); + Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, JSON_BODY, "0".repeat(64))); } } From 64ead457e47035c0b7749efbe7dc6f882d1ba310 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 16:53:12 +0200 Subject: [PATCH 04/10] refactor(webhooks): use 2-byte gzip magic per RFC 1952 (CHA-3071) RFC 1952 defines the gzip magic number as the two-byte sequence 1F 8B; the third byte (CM) is informational and not part of the identifier. Trim the magic check from three bytes to two to match the spec and stay consistent with the reference implementations in the public docs. Co-authored-by: Cursor --- .../io/getstream/chat/java/models/App.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 acdf5da0..e529c01a 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -1516,20 +1516,17 @@ public static boolean verifySignature( } } - private static final byte[] GZIP_MAGIC = new byte[] {0x1f, (byte) 0x8b, 0x08}; + private static final byte[] GZIP_MAGIC = new byte[] {0x1f, (byte) 0x8b}; /** - * Returns {@code body} unchanged unless it starts with the gzip magic ({@code 1f 8b 08}), in - * which case the gzip stream is inflated and the decompressed bytes are returned. + * Returns {@code body} unchanged unless it starts with the gzip magic ({@code 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) lets the same handler stay correct * when middleware auto-decompresses the request before your code sees it. */ public static byte[] ungzipPayload(@NotNull byte[] body) { - if (body.length < 3 - || body[0] != GZIP_MAGIC[0] - || body[1] != GZIP_MAGIC[1] - || body[2] != GZIP_MAGIC[2]) { + if (body.length < 2 || body[0] != GZIP_MAGIC[0] || body[1] != GZIP_MAGIC[1]) { return body; } try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) { @@ -1604,7 +1601,9 @@ public static byte[] decodeSnsPayload(@NotNull String message) { return verifyAndParseInternal(ungzipPayload(body), signature, secret); } - /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */ + /** + * Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. + */ public static @NotNull Event verifyAndParseWebhook( @NotNull byte[] body, @NotNull String signature) { return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret()); @@ -1619,22 +1618,27 @@ public static byte[] decodeSnsPayload(@NotNull String message) { return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret); } - /** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */ + /** + * Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. + */ public static @NotNull Event verifyAndParseSqs( @NotNull String messageBody, @NotNull String signature) { return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret()); } /** - * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code - * signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}. + * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC + * {@code signature} from the {@code X-Signature} message attribute, and return the parsed {@link + * Event}. */ public static @NotNull Event verifyAndParseSns( @NotNull String message, @NotNull String signature, @NotNull String secret) { return verifyAndParseInternal(decodeSnsPayload(message), signature, secret); } - /** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */ + /** + * Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. + */ public static @NotNull Event verifyAndParseSns( @NotNull String message, @NotNull String signature) { return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret()); From e43c3eb3659e1fde4fbd0bcb9ec94aceff7e57bc Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 17:03:41 +0200 Subject: [PATCH 05/10] style(webhooks): apply spotless javadoc formatting Co-authored-by: Cursor --- .../java/io/getstream/chat/java/models/App.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) 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 e529c01a..19fe94b0 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -1601,9 +1601,7 @@ public static byte[] decodeSnsPayload(@NotNull String message) { return verifyAndParseInternal(ungzipPayload(body), signature, secret); } - /** - * Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. - */ + /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */ public static @NotNull Event verifyAndParseWebhook( @NotNull byte[] body, @NotNull String signature) { return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret()); @@ -1618,27 +1616,22 @@ public static byte[] decodeSnsPayload(@NotNull String message) { return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret); } - /** - * Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. - */ + /** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */ public static @NotNull Event verifyAndParseSqs( @NotNull String messageBody, @NotNull String signature) { return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret()); } /** - * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC - * {@code signature} from the {@code X-Signature} message attribute, and return the parsed {@link - * Event}. + * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code + * signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}. */ public static @NotNull Event verifyAndParseSns( @NotNull String message, @NotNull String signature, @NotNull String secret) { return verifyAndParseInternal(decodeSnsPayload(message), signature, secret); } - /** - * Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. - */ + /** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */ public static @NotNull Event verifyAndParseSns( @NotNull String message, @NotNull String signature) { return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret()); From 44dcbb4b5139064fcd82cf950d15b0f0cd1a303f Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 10:46:11 +0200 Subject: [PATCH 06/10] feat(webhooks): add Client instance methods for verifyAndParse* Surface the dual-API pattern at the level callers actually hold a reference to: the Client interface now ships default instance methods (verifyAndParseWebhook, verifyAndParseSqs, verifyAndParseSns) that delegate to the existing App static helpers using the client's configured API secret. Mirrors the dual-API surface in stream-chat-go and the other server SDKs and matches the call shape documented at getstream.io/chat/docs/$framework/webhooks_overview/. The static App helpers and their singleton overloads remain available for stateless callers (Lambdas, queue consumers, tests). Co-authored-by: Cursor --- .../chat/java/services/framework/Client.java | 42 +++++++++++ .../chat/java/WebhookCompressionTest.java | 70 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/main/java/io/getstream/chat/java/services/framework/Client.java b/src/main/java/io/getstream/chat/java/services/framework/Client.java index f73b3ab3..ddbd554f 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/Client.java +++ b/src/main/java/io/getstream/chat/java/services/framework/Client.java @@ -1,5 +1,7 @@ package io.getstream.chat.java.services.framework; +import io.getstream.chat.java.models.App; +import io.getstream.chat.java.models.Event; import java.time.Duration; import org.jetbrains.annotations.NotNull; @@ -19,6 +21,46 @@ public interface Client { void setTimeout(@NotNull Duration timeoutDuration); + /** + * Verify and parse an HTTP webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseWebhook(byte[], String, String)}; + * the call site stays a two-argument one-liner because the secret comes from the client. + * + * @param body raw HTTP request body bytes Stream signed + * @param signature value of the {@code X-Signature} header + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseWebhook(@NotNull byte[] body, @NotNull String signature) { + return App.verifyAndParseWebhook(body, signature, getApiSecret()); + } + + /** + * Verify and parse an SQS firehose webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseSqs(String, String, String)}. + * + * @param messageBody SQS message {@code Body} (UTF-8 string) + * @param signature value of the {@code X-Signature} message attribute + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseSqs(@NotNull String messageBody, @NotNull String signature) { + return App.verifyAndParseSqs(messageBody, signature, getApiSecret()); + } + + /** + * Verify and parse an SNS firehose webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseSns(String, String, String)}. + * + * @param message SNS notification {@code Message} field (UTF-8 string) + * @param signature value of the {@code X-Signature} message attribute + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseSns(@NotNull String message, @NotNull String signature) { + return App.verifyAndParseSns(message, signature, getApiSecret()); + } + static Client getInstance() { return DefaultClient.getInstance(); } diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java index 03cab9d9..53dc3e98 100644 --- a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -2,10 +2,13 @@ import io.getstream.chat.java.models.App; import io.getstream.chat.java.models.Event; +import io.getstream.chat.java.services.framework.Client; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Base64; import java.util.zip.GZIPOutputStream; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -242,4 +245,71 @@ void verifyWebhookSignature_backwardCompat() throws Exception { Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig)); Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, JSON_BODY, "0".repeat(64))); } + + private static final class StubClient implements Client { + private final String apiSecret; + + StubClient(String apiSecret) { + this.apiSecret = apiSecret; + } + + @Override + public @NotNull TService create(Class svcClass) { + throw new UnsupportedOperationException("stub client does not create services"); + } + + @Override + public @NotNull String getApiKey() { + return "stub-key"; + } + + @Override + public @NotNull String getApiSecret() { + return apiSecret; + } + + @Override + public void setTimeout(@NotNull Duration timeoutDuration) {} + } + + @Test + @DisplayName("Client.verifyAndParseWebhook delegates to static helper with client secret") + void clientInstance_verifyAndParseWebhook() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event viaInstance = client.verifyAndParseWebhook(gzip(raw), sig); + Event viaStatic = App.verifyAndParseWebhook(gzip(raw), sig, API_SECRET); + Assertions.assertEquals(viaStatic.getType(), viaInstance.getType()); + Assertions.assertEquals("message.new", viaInstance.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseSqs delegates to static helper with client secret") + void clientInstance_verifyAndParseSqs() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = client.verifyAndParseSqs(base64(gzip(raw)), sig); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseSns delegates to static helper with client secret") + void clientInstance_verifyAndParseSns() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = client.verifyAndParseSns(base64(gzip(raw)), sig); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseWebhook rejects mismatched signature") + void clientInstance_verifyAndParseWebhook_rejectsMismatch() { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertThrows( + SecurityException.class, () -> client.verifyAndParseWebhook(raw, "0".repeat(64))); + } } From 1a32ec47ca5ff4989f2fcf2d98677517f8c80707 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:43:41 +0200 Subject: [PATCH 07/10] fix(webhooks): share configured ObjectMapper in App.parseEvent The default ObjectMapper fails on unknown fields, can't parse Stream's ISO date formats, and rejects unknown enum values. That made webhook parsing diverge from the rest of the SDK and would break the first time Stream added a new field, enum value, or date variant server-side. Reuse a single mapper configured like DefaultClient's Retrofit mapper: - FAIL_ON_UNKNOWN_PROPERTIES = false - READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE = true - StdDateFormat with UTC + colon-in-timezone Also avoids allocating a fresh mapper on every webhook delivery. Co-authored-by: Cursor --- .../io/getstream/chat/java/models/App.java | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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 19fe94b0..576e0c6c 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -1,14 +1,19 @@ package io.getstream.chat.java.models; +import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.util.StdDateFormat; import io.getstream.chat.java.models.App.AppCheckPushRequestData.AppCheckPushRequest; import io.getstream.chat.java.models.App.AppCheckSnsRequestData.AppCheckSnsRequest; import io.getstream.chat.java.models.App.AppCheckSqsRequestData.AppCheckSqsRequest; @@ -35,6 +40,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.zip.GZIPInputStream; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -1562,15 +1568,37 @@ public static byte[] decodeSnsPayload(@NotNull String message) { return decodeSqsPayload(message); } + /** + * Shared {@link ObjectMapper} for webhook payload deserialization. Configured to match the + * Retrofit mapper in {@code DefaultClient}: tolerant of unknown JSON properties and unknown enum + * values, with the Stream-side ISO-8601 date format. This makes a webhook handler accept new + * fields / enum values added server-side without redeploys, and gives {@link Event} the same date + * parsing behavior as the rest of the SDK. + */ + private static final ObjectMapper WEBHOOK_OBJECT_MAPPER = buildWebhookObjectMapper(); + + private static ObjectMapper buildWebhookObjectMapper() { + final ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE); + mapper.setDateFormat( + new StdDateFormat().withColonInTimeZone(true).withTimeZone(TimeZone.getTimeZone("UTC"))); + return mapper; + } + /** * Parse a JSON-encoded webhook event into a typed {@link Event}. Unknown event types still parse - * successfully because {@link Event#getType()} is a free-form string. + * successfully because {@link Event#getType()} is a free-form string; unknown nested fields and + * unknown enum values are tolerated so the handler stays forward-compatible with new Stream + * server releases. * * @throws IllegalStateException when the bytes are not valid JSON */ public static @NotNull Event parseEvent(@NotNull byte[] payload) { try { - return new com.fasterxml.jackson.databind.ObjectMapper().readValue(payload, Event.class); + return WEBHOOK_OBJECT_MAPPER.readValue(payload, Event.class); } catch (IOException e) { throw new IllegalStateException("failed to parse webhook event", e); } From b933793a93df281a0a59fc0342a9320a10cd2422 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:43:46 +0200 Subject: [PATCH 08/10] docs(webhooks): align compression examples with shipped API The merged docs referenced App.verifyAndDecodeWebhook and App.decompressWebhookBody, neither of which exists. Rewrite the Java snippets against the actual API (verifyAndParseWebhook, ungzipPayload, verifySignature, parseEvent, plus the SQS / SNS variants and the Client.* instance overloads) so the examples compile. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index cbb59f9d..6dcf5da2 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -99,37 +99,46 @@ Before enabling compression, make sure that: * 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 `App.verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON bytes ready to parse: +Use `App.verifyAndParseWebhook` to handle decompression, signature verification, and JSON parsing in a single call. It returns a typed `Event`: ```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. +// apiSecret — your app's API secret +Event event = App.verifyAndParseWebhook(rawBody, signature, apiSecret); +``` + +Or, if you already have a configured client, call the instance overload (it picks up the secret from the client): + +```java +Event event = client.verifyAndParseWebhook(rawBody, signature); ``` 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); +byte[] json = App.ungzipPayload(rawBody); // pass-through when the bytes aren't gzipped +boolean valid = App.verifySignature(json, signature, apiSecret); +Event event = App.parseEvent(json); ``` -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. +Detection is done via the gzip magic bytes (`1f 8b`, per RFC 1952), so the same helper stays correct whether or not your HTTP server already decompressed the body for you. Any non-gzip body is passed through unchanged. Malformed gzip envelopes raise an `IllegalStateException`. #### 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: +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 SQS `Body` (or SNS `Message`) string directly — no manual base64 decoding required: ```java -// body — the SQS Body / SNS Message string, decoded to bytes -// signature — X-Signature attribute value -// contentEncoding — "gzip" when compression is enabled, otherwise null -// payloadEncoding — "base64" for SQS / SNS firehose payloads -byte[] json = App.verifyAndDecodeWebhook(body, signature, contentEncoding, payloadEncoding); +// messageBody — the SQS Body / SNS Message string (base64-encoded) +// signature — X-Signature message attribute value +// apiSecret — your app's API secret +Event event = App.verifyAndParseSqs(messageBody, signature, apiSecret); +// or, for SNS: +Event event = App.verifyAndParseSns(messageBody, signature, apiSecret); ``` +Instance-method counterparts on `client` (`client.verifyAndParseSqs(...)`, `client.verifyAndParseSns(...)`) work the same way, using the configured client's API secret. + The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. ## Webhook types From 2f492ed79ac8dff5d6c58a5e31b3fbb8f1ff9528 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 13:09:00 +0200 Subject: [PATCH 09/10] fix(webhooks): unwrap SNS notification envelope in App.decodeSnsPayload decodeSnsPayload now JSON-parses the SNS HTTP notification envelope ({"Type":"Notification","Message":"..."}) and extracts the inner Message field before running the SQS pipeline. Falls through to the pre-extracted Message string when the input is not a JSON envelope so existing call sites keep working. Test adds a realistic SNS HTTP notification body fixture and exercises both the new envelope path and the existing pre-extracted Message path. Co-authored-by: Cursor --- .../io/getstream/chat/java/models/App.java | 37 ++++++++++-- .../chat/java/WebhookCompressionTest.java | 60 ++++++++++++++++++- 2 files changed, 90 insertions(+), 7 deletions(-) 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 576e0c6c..a3c02a3e 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.util.StdDateFormat; @@ -1561,11 +1562,39 @@ public static byte[] decodeSqsPayload(@NotNull String body) { } /** - * Byte-for-byte identical to {@link #decodeSqsPayload(String)}; exposed under both names so call - * sites read intent. + * Reverses an SNS HTTP notification envelope. When {@code notificationBody} is a JSON envelope + * ({@code {"Type":"Notification","Message":"..."}}), the inner {@code Message} field is extracted + * and run through the SQS pipeline (base64-decode, then gzip-if-magic). When the input is not a + * JSON envelope it is treated as the already-extracted {@code Message} string, so call sites that + * pre-unwrap continue to work. */ - public static byte[] decodeSnsPayload(@NotNull String message) { - return decodeSqsPayload(message); + public static byte[] decodeSnsPayload(@NotNull String notificationBody) { + String inner = extractSnsMessage(notificationBody); + return decodeSqsPayload(inner != null ? inner : notificationBody); + } + + /** + * Returns the inner {@code Message} field of an SNS HTTP notification envelope, or {@code null} + * when the input is not a JSON object that contains a {@code Message} string. + */ + private static String extractSnsMessage(@NotNull String notificationBody) { + int i = 0; + while (i < notificationBody.length() && Character.isWhitespace(notificationBody.charAt(i))) { + i++; + } + if (i >= notificationBody.length() || notificationBody.charAt(i) != '{') { + return null; + } + try { + JsonNode parsed = WEBHOOK_OBJECT_MAPPER.readTree(notificationBody); + if (parsed == null || !parsed.isObject()) { + return null; + } + JsonNode message = parsed.get("Message"); + return message != null && message.isTextual() ? message.asText() : null; + } catch (IOException e) { + return null; + } } /** diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java index 53dc3e98..f9a69739 100644 --- a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -96,13 +96,45 @@ void decodeSqsPayload_malformedBase64() { } @Test - @DisplayName("decodeSnsPayload aliases decodeSqsPayload") - void decodeSnsPayload_aliasesSqs() throws Exception { + @DisplayName("decodeSnsPayload treats pre-extracted Message identically to decodeSqsPayload") + void decodeSnsPayload_preExtractedMessage() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); String wrapped = base64(gzip(raw)); Assertions.assertArrayEquals(App.decodeSqsPayload(wrapped), App.decodeSnsPayload(wrapped)); } + @Test + @DisplayName("decodeSnsPayload unwraps a full SNS HTTP notification envelope") + void decodeSnsPayload_fullEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String envelope = snsEnvelope(wrapped); + Assertions.assertArrayEquals(raw, App.decodeSnsPayload(envelope)); + } + + @Test + @DisplayName("decodeSnsPayload handles whitespace before envelope JSON") + void decodeSnsPayload_envelopeWithLeadingWhitespace() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String envelope = "\n " + snsEnvelope(wrapped); + Assertions.assertArrayEquals(raw, App.decodeSnsPayload(envelope)); + } + + private static String snsEnvelope(String innerMessage) { + return "{" + + "\"Type\":\"Notification\"," + + "\"MessageId\":\"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\"," + + "\"TopicArn\":\"arn:aws:sns:us-east-1:123456789012:stream-webhooks\"," + + "\"Message\":\"" + + innerMessage + + "\"," + + "\"Timestamp\":\"2026-05-11T10:00:00.000Z\"," + + "\"SignatureVersion\":\"1\"," + + "\"MessageAttributes\":{\"X-Signature\":{\"Type\":\"String\",\"Value\":\"placeholder\"}}" + + "}"; + } + @Test @DisplayName("verifySignature returns true for matching HMAC") void verifySignature_matching() throws Exception { @@ -228,7 +260,8 @@ void verifyAndParseSns_base64Gzip() throws Exception { } @Test - @DisplayName("verifyAndParseSns and verifyAndParseSqs return identical events") + @DisplayName( + "verifyAndParseSns and verifyAndParseSqs return identical events for pre-extracted Message") void verifyAndParseSns_matchesSqs() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); String sig = hmacSHA256Hex(API_SECRET, raw); @@ -238,6 +271,27 @@ void verifyAndParseSns_matchesSqs() throws Exception { Assertions.assertEquals(sqs.getType(), sns.getType()); } + @Test + @DisplayName("verifyAndParseSns parses a full SNS HTTP notification envelope") + void verifyAndParseSns_fullEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + String envelope = snsEnvelope(base64(gzip(raw))); + Event ev = App.verifyAndParseSns(envelope, sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseSns rejects signature computed over the envelope JSON") + void verifyAndParseSns_rejectsSignatureOverEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String envelope = snsEnvelope(base64(gzip(raw))); + String sigOverEnvelope = hmacSHA256Hex(API_SECRET, envelope.getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndParseSns(envelope, sigOverEnvelope, API_SECRET)); + } + @Test @DisplayName("verifyWebhookSignature backward compatibility still validates HMAC") void verifyWebhookSignature_backwardCompat() throws Exception { From 073ae7327bb364048ddec58354d7838999871033 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 15:36:56 +0200 Subject: [PATCH 10/10] refactor(webhooks): rename ungzipPayload to gunzipPayload + add golden fixtures (CHA-3071) Per Tommaso's suggestion, align the gzip helper with the GNU `gunzip` command name. The function was added in this PR and not yet released, so this is a straight rename with no back-compat alias. Adds Tommaso's reference fixtures to the test suite as named cases so future SDKs can sanity-check against the same payloads: aGVsbG93b3JsZA== -> helloworld (base64) H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA -> helloworld (base64+gzip) Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 2 +- .../io/getstream/chat/java/models/App.java | 6 +-- .../chat/java/WebhookCompressionTest.java | 54 ++++++++++++++----- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 6dcf5da2..d0dc2098 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -117,7 +117,7 @@ Event event = client.verifyAndParseWebhook(rawBody, signature); If you prefer to handle the steps yourself, the primitives are also exposed: ```java -byte[] json = App.ungzipPayload(rawBody); // pass-through when the bytes aren't gzipped +byte[] json = App.gunzipPayload(rawBody); // pass-through when the bytes aren't gzipped boolean valid = App.verifySignature(json, signature, apiSecret); Event event = App.parseEvent(json); ``` 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 a3c02a3e..5b956b13 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -1532,7 +1532,7 @@ public static boolean verifySignature( *

Magic-byte detection (rather than relying on a header) lets the same handler stay correct * when middleware auto-decompresses the request before your code sees it. */ - public static byte[] ungzipPayload(@NotNull byte[] body) { + public static byte[] gunzipPayload(@NotNull byte[] body) { if (body.length < 2 || body[0] != GZIP_MAGIC[0] || body[1] != GZIP_MAGIC[1]) { return body; } @@ -1558,7 +1558,7 @@ public static byte[] decodeSqsPayload(@NotNull String body) { } catch (IllegalArgumentException e) { throw new IllegalStateException("failed to base64-decode payload", e); } - return ungzipPayload(decoded); + return gunzipPayload(decoded); } /** @@ -1655,7 +1655,7 @@ private static ObjectMapper buildWebhookObjectMapper() { */ public static @NotNull Event verifyAndParseWebhook( @NotNull byte[] body, @NotNull String signature, @NotNull String secret) { - return verifyAndParseInternal(ungzipPayload(body), signature, secret); + return verifyAndParseInternal(gunzipPayload(body), signature, secret); } /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */ diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java index f9a69739..dd013eeb 100644 --- a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -11,6 +11,7 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; public class WebhookCompressionTest { @@ -48,30 +49,30 @@ private static String hmacSHA256Hex(String secret, byte[] body) throws Exception } @Test - @DisplayName("ungzipPayload passes through plain bytes unchanged") - void ungzipPayload_passthroughPlainBytes() { + @DisplayName("gunzipPayload passes through plain bytes unchanged") + void gunzipPayload_passthroughPlainBytes() { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - Assertions.assertArrayEquals(raw, App.ungzipPayload(raw)); + Assertions.assertArrayEquals(raw, App.gunzipPayload(raw)); } @Test - @DisplayName("ungzipPayload inflates gzip-magic bytes") - void ungzipPayload_inflatesGzip() throws Exception { + @DisplayName("gunzipPayload inflates gzip-magic bytes") + void gunzipPayload_inflatesGzip() throws Exception { byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); - Assertions.assertArrayEquals(raw, App.ungzipPayload(gzip(raw))); + Assertions.assertArrayEquals(raw, App.gunzipPayload(gzip(raw))); } @Test - @DisplayName("ungzipPayload returns empty input unchanged") - void ungzipPayload_emptyInput() { - Assertions.assertArrayEquals(new byte[0], App.ungzipPayload(new byte[0])); + @DisplayName("gunzipPayload returns empty input unchanged") + void gunzipPayload_emptyInput() { + Assertions.assertArrayEquals(new byte[0], App.gunzipPayload(new byte[0])); } @Test - @DisplayName("ungzipPayload throws on truncated gzip with magic") - void ungzipPayload_truncatedGzipThrows() { + @DisplayName("gunzipPayload throws on truncated gzip with magic") + void gunzipPayload_truncatedGzipThrows() { byte[] bad = new byte[] {0x1f, (byte) 0x8b, 0x08, 0, 0, 0}; - Assertions.assertThrows(IllegalStateException.class, () -> App.ungzipPayload(bad)); + Assertions.assertThrows(IllegalStateException.class, () -> App.gunzipPayload(bad)); } @Test @@ -95,6 +96,21 @@ void decodeSqsPayload_malformedBase64() { IllegalStateException.class, () -> App.decodeSqsPayload("!!!not-base64!!!")); } + @Test + @DisplayName("decodeSqsPayload decodes Tommaso's plain helloworld fixture") + void decodeSqsPayload_helloworldBase64Fixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), App.decodeSqsPayload("aGVsbG93b3JsZA==")); + } + + @Test + @DisplayName("decodeSqsPayload decodes Tommaso's gzipped helloworld fixture") + void decodeSqsPayload_helloworldBase64GzipFixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), + App.decodeSqsPayload("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA")); + } + @Test @DisplayName("decodeSnsPayload treats pre-extracted Message identically to decodeSqsPayload") void decodeSnsPayload_preExtractedMessage() throws Exception { @@ -366,4 +382,18 @@ void clientInstance_verifyAndParseWebhook_rejectsMismatch() { Assertions.assertThrows( SecurityException.class, () -> client.verifyAndParseWebhook(raw, "0".repeat(64))); } + + @Nested + @DisplayName("gunzipPayload golden fixtures") + class GunzipPayloadTest { + + @Test + @DisplayName("gunzipPayload inflates Tommaso's helloworld gzip fixture") + void gunzipPayload_helloworldFixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), + App.gunzipPayload( + Base64.getDecoder().decode("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"))); + } + } }