Skip to content

Commit a96bf2f

Browse files
[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 <cursoragent@cursor.com>
1 parent 027a20a commit a96bf2f

3 files changed

Lines changed: 312 additions & 10 deletions

File tree

docs/webhooks/webhooks_overview/webhooks_overview.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,32 @@ All webhook requests contain these headers:
8585
| X-Webhook-Attempt | Number of webhook request attempt starting from 1 | 1 |
8686
| X-Api-Key | Your application’s API key. Should be used to validate request signature | a1b23cdefgh4 |
8787
| X-Signature | HMAC signature of the request body. See Signature section | ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb |
88+
| Content-Encoding | Compression algorithm applied to the request body. Only set when webhook compression is enabled on the app | `gzip` |
89+
90+
### Compressed webhook bodies
91+
92+
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.
93+
94+
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:
95+
96+
```java
97+
// rawBody — bytes read straight from the HTTP request body
98+
// signature — value of the X-Signature header
99+
// contentEncoding — value of the Content-Encoding header (null when absent)
100+
byte[] json = App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding);
101+
// json now contains the uncompressed JSON; parse it as usual.
102+
```
103+
104+
If you prefer to handle the steps yourself, the primitives are also exposed:
105+
106+
```java
107+
byte[] json = App.decompressWebhookBody(rawBody, contentEncoding);
108+
boolean valid = App.verifyWebhookSignature(apiSecret, json, signature);
109+
```
110+
111+
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.
112+
113+
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.
88114

89115
## Webhook types
90116

src/main/java/io/getstream/chat/java/models/App.java

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,20 @@
2222
import io.getstream.chat.java.models.framework.StreamResponseObject;
2323
import io.getstream.chat.java.services.AppService;
2424
import io.getstream.chat.java.services.framework.Client;
25+
import java.io.ByteArrayInputStream;
26+
import java.io.ByteArrayOutputStream;
2527
import java.io.IOException;
28+
import java.io.InputStream;
2629
import java.nio.charset.StandardCharsets;
2730
import java.security.InvalidKeyException;
2831
import java.security.Key;
32+
import java.security.MessageDigest;
2933
import java.security.NoSuchAlgorithmException;
3034
import java.util.Date;
3135
import java.util.List;
36+
import java.util.Locale;
3237
import java.util.Map;
38+
import java.util.zip.GZIPInputStream;
3339
import javax.crypto.Mac;
3440
import javax.crypto.spec.SecretKeySpec;
3541
import lombok.*;
@@ -1460,12 +1466,41 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
14601466
*/
14611467
public static boolean verifyWebhookSignature(
14621468
@NotNull String apiSecret, @NotNull String body, @NotNull String signature) {
1469+
return verifyWebhookSignature(apiSecret, body.getBytes(StandardCharsets.UTF_8), signature);
1470+
}
1471+
1472+
/**
1473+
* Validates if hmac signature is correct for message body.
1474+
*
1475+
* @param body the message body
1476+
* @param signature the signature provided in X-Signature header
1477+
* @return true if the signature is valid
1478+
*/
1479+
public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) {
1480+
String apiSecret = Client.getInstance().getApiSecret();
1481+
return verifyWebhookSignature(apiSecret, body, signature);
1482+
}
1483+
1484+
/**
1485+
* Validates if hmac signature is correct for the raw (uncompressed) body bytes.
1486+
*
1487+
* <p>Stream computes {@code X-Signature} over the uncompressed JSON, so when webhook compression
1488+
* is enabled callers must decompress the request body first (see {@link
1489+
* #decompressWebhookBody(byte[], String)}) and pass the resulting bytes here.
1490+
*
1491+
* @param apiSecret the app's API secret
1492+
* @param body the uncompressed JSON body bytes
1493+
* @param signature the signature provided in {@code X-Signature} header
1494+
* @return true if the signature matches
1495+
*/
1496+
public static boolean verifyWebhookSignature(
1497+
@NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature) {
14631498
try {
1464-
Key sk = new SecretKeySpec(apiSecret.getBytes(), "HmacSHA256");
1499+
Key sk = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
14651500
Mac mac = Mac.getInstance(sk.getAlgorithm());
14661501
mac.init(sk);
1467-
final byte[] hmac = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
1468-
return bytesToHex(hmac).equals(signature);
1502+
final byte[] hmac = mac.doFinal(body);
1503+
return constantTimeEquals(bytesToHex(hmac), signature);
14691504
} catch (NoSuchAlgorithmException e) {
14701505
throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e);
14711506
} catch (InvalidKeyException e) {
@@ -1474,15 +1509,97 @@ public static boolean verifyWebhookSignature(
14741509
}
14751510

14761511
/**
1477-
* Validates if hmac signature is correct for message body.
1512+
* Decompresses an outbound webhook body according to the {@code Content-Encoding} header.
14781513
*
1479-
* @param body the message body
1480-
* @param signature the signature provided in X-Signature header
1481-
* @return true if the signature is valid
1514+
* <p>This SDK only supports {@code gzip} compression. A {@code null} or empty encoding returns
1515+
* the body unchanged. Any other value (including {@code br} / {@code zstd}) raises an {@link
1516+
* IllegalStateException} so callers can surface a clear error and the operator can flip the app
1517+
* back to {@code gzip} on the dashboard.
1518+
*
1519+
* @param body raw HTTP request body
1520+
* @param contentEncoding value of the {@code Content-Encoding} header (case-insensitive); pass
1521+
* {@code null} or {@code ""} when no encoding was set
1522+
* @return uncompressed body bytes
14821523
*/
1483-
public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) {
1484-
String apiSecret = Client.getInstance().getApiSecret();
1485-
return verifyWebhookSignature(apiSecret, body, signature);
1524+
public static byte[] decompressWebhookBody(
1525+
@NotNull byte[] body, @Nullable String contentEncoding) {
1526+
if (contentEncoding == null || contentEncoding.isEmpty()) {
1527+
return body;
1528+
}
1529+
String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT);
1530+
if (encoding.isEmpty()) {
1531+
return body;
1532+
}
1533+
if (!"gzip".equals(encoding)) {
1534+
throw new IllegalStateException(
1535+
"unsupported webhook Content-Encoding: "
1536+
+ contentEncoding
1537+
+ ". This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on"
1538+
+ " the app config.");
1539+
}
1540+
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) {
1541+
return readAll(in);
1542+
} catch (IOException e) {
1543+
throw new IllegalStateException(
1544+
"failed to decompress webhook body (Content-Encoding: " + contentEncoding + ")", e);
1545+
}
1546+
}
1547+
1548+
/**
1549+
* Decompresses (when {@code Content-Encoding} is set) and verifies the HMAC signature of an
1550+
* outbound webhook request, returning the raw JSON bytes when the signature matches.
1551+
*
1552+
* <p>This is the recommended entry point for webhook handlers: it handles every value of {@code
1553+
* Content-Encoding} Stream may send and keeps signature verification on the uncompressed body.
1554+
*
1555+
* @param apiSecret the app's API secret
1556+
* @param body raw HTTP request body bytes
1557+
* @param signature value of the {@code X-Signature} header
1558+
* @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent
1559+
* @return the uncompressed JSON body bytes
1560+
* @throws SecurityException if the signature does not match
1561+
*/
1562+
public static byte[] verifyAndDecodeWebhook(
1563+
@NotNull String apiSecret,
1564+
@NotNull byte[] body,
1565+
@NotNull String signature,
1566+
@Nullable String contentEncoding) {
1567+
byte[] decompressed = decompressWebhookBody(body, contentEncoding);
1568+
if (!verifyWebhookSignature(apiSecret, decompressed, signature)) {
1569+
throw new SecurityException("invalid webhook signature");
1570+
}
1571+
return decompressed;
1572+
}
1573+
1574+
/**
1575+
* Decompresses and verifies a webhook using the API secret of the configured singleton {@link
1576+
* Client}.
1577+
*
1578+
* @param body raw HTTP request body bytes
1579+
* @param signature value of the {@code X-Signature} header
1580+
* @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent
1581+
* @return the uncompressed JSON body bytes
1582+
* @throws SecurityException if the signature does not match
1583+
*/
1584+
public static byte[] verifyAndDecodeWebhook(
1585+
@NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) {
1586+
return verifyAndDecodeWebhook(
1587+
Client.getInstance().getApiSecret(), body, signature, contentEncoding);
1588+
}
1589+
1590+
private static byte[] readAll(InputStream in) throws IOException {
1591+
ByteArrayOutputStream out = new ByteArrayOutputStream();
1592+
byte[] buf = new byte[4096];
1593+
int n;
1594+
while ((n = in.read(buf)) != -1) {
1595+
out.write(buf, 0, n);
1596+
}
1597+
return out.toByteArray();
1598+
}
1599+
1600+
private static boolean constantTimeEquals(@NotNull String a, @NotNull String b) {
1601+
return MessageDigest.isEqual(
1602+
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
14861603
}
14871604

14881605
private static String bytesToHex(byte[] hash) {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package io.getstream.chat.java;
2+
3+
import io.getstream.chat.java.models.App;
4+
import java.io.ByteArrayOutputStream;
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.zip.GZIPOutputStream;
7+
import org.junit.jupiter.api.Assertions;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
11+
public class WebhookCompressionTest {
12+
13+
private static final String API_SECRET = "tsec2";
14+
private static final String JSON_BODY =
15+
"{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}";
16+
17+
private static byte[] gzip(byte[] raw) throws Exception {
18+
ByteArrayOutputStream out = new ByteArrayOutputStream();
19+
try (GZIPOutputStream gz = new GZIPOutputStream(out)) {
20+
gz.write(raw);
21+
}
22+
return out.toByteArray();
23+
}
24+
25+
private static String hmacSHA256Hex(String secret, byte[] body) throws Exception {
26+
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
27+
mac.init(
28+
new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
29+
byte[] hmac = mac.doFinal(body);
30+
StringBuilder hex = new StringBuilder(2 * hmac.length);
31+
for (byte b : hmac) {
32+
String h = Integer.toHexString(0xff & b);
33+
if (h.length() == 1) {
34+
hex.append('0');
35+
}
36+
hex.append(h);
37+
}
38+
return hex.toString();
39+
}
40+
41+
@Test
42+
@DisplayName("decompressWebhookBody returns body unchanged when Content-Encoding is empty")
43+
void decompressWebhookBody_passthroughWhenEncodingEmpty() {
44+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
45+
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, null));
46+
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, ""));
47+
}
48+
49+
@Test
50+
@DisplayName("decompressWebhookBody round-trips gzip bytes")
51+
void decompressWebhookBody_gzipRoundTrip() throws Exception {
52+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
53+
byte[] compressed = gzip(raw);
54+
Assertions.assertTrue(
55+
compressed.length > 0 && compressed.length != raw.length,
56+
"fixture sanity: gzipped bytes should differ from raw");
57+
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "gzip"));
58+
}
59+
60+
@Test
61+
@DisplayName("decompressWebhookBody handles Content-Encoding case-insensitively")
62+
void decompressWebhookBody_caseInsensitiveEncoding() throws Exception {
63+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
64+
byte[] compressed = gzip(raw);
65+
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "GZIP"));
66+
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, " gzip "));
67+
}
68+
69+
@Test
70+
@DisplayName("decompressWebhookBody rejects every non-gzip Content-Encoding")
71+
void decompressWebhookBody_nonGzipRejected() {
72+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
73+
for (String encoding : new String[] {"br", "brotli", "zstd", "deflate", "compress", "lz4"}) {
74+
IllegalStateException ex =
75+
Assertions.assertThrows(
76+
IllegalStateException.class,
77+
() -> App.decompressWebhookBody(raw, encoding),
78+
"encoding " + encoding + " should be rejected");
79+
Assertions.assertTrue(
80+
ex.getMessage().contains("unsupported"),
81+
"error for " + encoding + " should mention 'unsupported'; got: " + ex.getMessage());
82+
Assertions.assertTrue(
83+
ex.getMessage().contains("gzip"),
84+
"error for "
85+
+ encoding
86+
+ " should point operators back to gzip; got: "
87+
+ ex.getMessage());
88+
}
89+
}
90+
91+
@Test
92+
@DisplayName("decompressWebhookBody throws when the payload is not actually gzip")
93+
void decompressWebhookBody_invalidGzipBytes() {
94+
byte[] notGzip = "not actually gzip".getBytes(StandardCharsets.UTF_8);
95+
IllegalStateException ex =
96+
Assertions.assertThrows(
97+
IllegalStateException.class, () -> App.decompressWebhookBody(notGzip, "gzip"));
98+
Assertions.assertTrue(ex.getMessage().contains("failed to decompress"));
99+
}
100+
101+
@Test
102+
@DisplayName("verifyWebhookSignature accepts byte[] body and matches the string overload")
103+
void verifyWebhookSignature_bytesOverload() throws Exception {
104+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
105+
String sig = hmacSHA256Hex(API_SECRET, raw);
106+
Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, raw, sig));
107+
Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig));
108+
Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, raw, "deadbeef"));
109+
}
110+
111+
@Test
112+
@DisplayName(
113+
"verifyAndDecodeWebhook decompresses gzip and returns raw JSON when signature matches")
114+
void verifyAndDecodeWebhook_gzipHappyPath() throws Exception {
115+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
116+
byte[] compressed = gzip(raw);
117+
String sig = hmacSHA256Hex(API_SECRET, raw);
118+
119+
byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, compressed, sig, "gzip");
120+
Assertions.assertArrayEquals(raw, decoded);
121+
}
122+
123+
@Test
124+
@DisplayName("verifyAndDecodeWebhook works for uncompressed bodies (no Content-Encoding)")
125+
void verifyAndDecodeWebhook_passthroughHappyPath() throws Exception {
126+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
127+
String sig = hmacSHA256Hex(API_SECRET, raw);
128+
129+
byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, null);
130+
Assertions.assertArrayEquals(raw, decoded);
131+
132+
byte[] decodedEmpty = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, "");
133+
Assertions.assertArrayEquals(raw, decodedEmpty);
134+
}
135+
136+
@Test
137+
@DisplayName("verifyAndDecodeWebhook throws SecurityException on signature mismatch")
138+
void verifyAndDecodeWebhook_badSignature() throws Exception {
139+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
140+
byte[] compressed = gzip(raw);
141+
142+
Assertions.assertThrows(
143+
SecurityException.class,
144+
() -> App.verifyAndDecodeWebhook(API_SECRET, compressed, "00", "gzip"));
145+
}
146+
147+
@Test
148+
@DisplayName(
149+
"verifyAndDecodeWebhook rejects gzip body when signature was computed over compressed bytes")
150+
void verifyAndDecodeWebhook_signatureMustBeOverUncompressed() throws Exception {
151+
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
152+
byte[] compressed = gzip(raw);
153+
String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed);
154+
155+
Assertions.assertThrows(
156+
SecurityException.class,
157+
() -> App.verifyAndDecodeWebhook(API_SECRET, compressed, sigOverCompressed, "gzip"));
158+
}
159+
}

0 commit comments

Comments
 (0)