From 4264dd3f868f091eca15489f56d8e14f0bb93390 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 7 May 2026 12:22:39 +0200 Subject: [PATCH 01/13] feat(webhooks): add VerifyAndDecodeWebhook for compressed payloads (CHA-3071) Stream now supports gzip compression on outbound webhooks (and the same encoding wrappers apply to SQS / SNS firehose payloads, plus a base64 layer to keep them transport-safe). The existing string-based VerifyWebhook helper is kept untouched for backward compatibility, but it cannot operate on the raw byte stream and does not understand the layered encodings. Two new helpers on IAppClient cover the new contract: - DecompressWebhookBody(body, contentEncoding, payloadEncoding) reverses the encoding wrappers (base64 first, then gzip) and returns the raw JSON bytes. - VerifyAndDecodeWebhook(body, signature, contentEncoding, payloadEncoding) does the same and additionally verifies the HMAC-SHA256 signature against the decoded JSON, throwing StreamWebhookSignatureException on mismatch. The signature is always computed by the server over the innermost (uncompressed, base64-decoded) JSON, so the same verification rule applies to plain HTTP webhooks and SQS / SNS firehose. Comparison uses CryptographicOperations.FixedTimeEquals on supported targets and a constant-time fallback on .NET Framework / netstandard <= 2.0. Unsupported encodings (br/zstd/deflate/... for Content-Encoding, hex/url/binary for payload_encoding) throw InvalidOperationException with a message pointing at the supported value, so misconfigured app settings fail loud rather than silently. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 71 +++++ src/Clients/AppClient.cs | 6 + src/Clients/IAppClient.cs | 70 +++++ src/Clients/WebhookHelpers.cs | 185 ++++++++++++ .../StreamWebhookSignatureException.cs | 23 ++ tests/WebhookCompressionTests.cs | 273 ++++++++++++++++++ 6 files changed, 628 insertions(+) create mode 100644 src/Clients/WebhookHelpers.cs create mode 100644 src/Exceptions/StreamWebhookSignatureException.cs create mode 100644 tests/WebhookCompressionTests.cs diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index c99e7a13..b566df3a 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -96,6 +96,77 @@ All webhook requests contain these headers: | 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 | +## Compressed webhook bodies + +GZIP compression can be enabled for hooks payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The computation overhead introduced by the decompression step is usually negligible and offset by the much smaller payload. + +When payload compression is enabled, webhook HTTP requests will include the `Content-Encoding: gzip` header and the request body will be compressed with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) handle this transparently and strip the header before your handler runs — in that case the body you see is already raw JSON. + +Before enabling compression, make sure that: + +- Your backend integration is using a recent version of our official SDKs with compression support +- If you don't use an official SDK, make sure that your code supports receiving compressed payloads +- The payload signature check is done on the **uncompressed** payload + +The .NET SDK exposes two helpers on `IAppClient` for this. `VerifyAndDecodeWebhook` decompresses (when needed) and verifies the HMAC signature in one call, returning the uncompressed JSON bytes. `DecompressWebhookBody` is the same minus the signature check, when you want to verify the signature yourself. Both methods are no-ops when both `contentEncoding` and `payloadEncoding` are left `null`, so existing plain-HTTP handlers do not need to change. + +### ASP.NET Core handler + +```csharp +[ApiController] +[Route("webhooks/stream")] +public class StreamWebhookController : ControllerBase +{ + private readonly IAppClient _appClient; + + public StreamWebhookController(IAppClient appClient) => _appClient = appClient; + + [HttpPost] + public async Task ReceiveAsync() + { + // Read the raw bytes off the wire — do NOT bind to a model, you need the + // exact byte stream the server signed. + using var ms = new MemoryStream(); + await HttpContext.Request.Body.CopyToAsync(ms); + var rawBody = ms.ToArray(); + + var signature = HttpContext.Request.Headers["X-Signature"].ToString(); + + // ASP.NET Core will normally strip Content-Encoding and decompress for + // you. If you have wired up automatic decompression, leave the second + // argument null. Otherwise pass the header through: + var contentEncoding = HttpContext.Request.Headers["Content-Encoding"].ToString(); + if (string.IsNullOrEmpty(contentEncoding)) contentEncoding = null; + + try + { + var json = _appClient.VerifyAndDecodeWebhook(rawBody, signature, contentEncoding); + // ...handle JSON... + return Ok(); + } + catch (StreamWebhookSignatureException) + { + return Unauthorized(); + } + } +} +``` + +### SQS / SNS firehose + +When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass `payloadEncoding: "base64"` (and `contentEncoding: "gzip"` when compression is on) and the SDK will unwrap both layers in the right order before checking the signature. + +```csharp +// Inside your SQS / SNS message handler: +// sqsBody = the message body bytes (Encoding.UTF8.GetBytes(message.Body)) +// xSignature = the value of the "x-signature" message attribute +var json = _appClient.VerifyAndDecodeWebhook( + sqsBody, + xSignature, + contentEncoding: "gzip", // omit / pass null when compression is off + payloadEncoding: "base64"); +``` + ## Webhook types In addition to the above there are 3 special webhooks. diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index 618dc254..6a7f06d8 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -85,5 +85,11 @@ public bool VerifyWebhook(string requestBody, string xSignature) return sig == xSignature; } } + + public byte[] DecompressWebhookBody(byte[] body, string contentEncoding = null, string payloadEncoding = null) + => WebhookHelpers.DecompressWebhookBody(body, contentEncoding, payloadEncoding); + + public byte[] VerifyAndDecodeWebhook(byte[] body, string signature, string contentEncoding = null, string payloadEncoding = null) + => WebhookHelpers.VerifyAndDecodeWebhook(_apiSecret, body, signature, contentEncoding, payloadEncoding); } } \ No newline at end of file diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index 6380ca83..96d4dffa 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -73,5 +73,75 @@ public interface IAppClient /// The request body to validate. /// The signature provided in X-Signature header. bool VerifyWebhook(string requestBody, string xSignature); + + /// + /// Reverses the encoding wrappers Stream applies to outbound webhook / + /// SQS / SNS payloads, returning the raw JSON bytes the server signed. + /// + /// + /// + /// When is set the wrapper layer is + /// removed first (SQS / SNS firehose envelopes wrap the bytes in base64 + /// so they remain valid UTF-8 over the queue). Then, when + /// is set, the resulting bytes are + /// gzip-decompressed. Passing null (or the empty string) for + /// either parameter is a no-op so plain HTTP webhooks behave the same + /// as before. + /// + /// + /// The signature Stream emits is always computed over the innermost + /// (uncompressed, base64-decoded) JSON, so this is also the canonical + /// representation to feed into . + /// + /// + /// Raw transport bytes (HTTP body, SQS Body, SNS Message). + /// "gzip" when compression is enabled, otherwise null. + /// "base64" for SQS / SNS firehose, otherwise null. + /// + /// Thrown when or + /// is set to a value the SDK does not support. + /// + /// + /// Thrown when the body fails to decode (invalid base64, invalid gzip). + /// + byte[] DecompressWebhookBody(byte[] body, string contentEncoding = null, string payloadEncoding = null); + + /// + /// Decompresses (when needed) and verifies the HMAC signature, returning + /// the uncompressed JSON bytes. The signature is always computed over + /// the innermost (uncompressed, base64-decoded) JSON, so the verification + /// rule is invariant across HTTP webhooks and SQS / SNS. + /// + /// + /// + /// For HTTP webhooks: pass the raw body and the X-Signature + /// header. If your app config has webhook_compression_algorithm + /// set to "gzip" the request will arrive with + /// Content-Encoding: gzip — pass that header value as + /// . (Some HTTP servers and middleware + /// — Rails, Django, Laravel, Spring Boot, ASP.NET — strip + /// Content-Encoding and decompress for you, in which case the + /// body is already raw JSON and + /// must be left null.) + /// + /// + /// For SQS / SNS firehose: pass the message body, the + /// x-signature message attribute, "base64" for + /// , and "gzip" for + /// when compression is on. + /// + /// + /// Raw transport bytes (HTTP body, SQS Body, SNS Message). + /// Lowercase hex HMAC-SHA256 signature from X-Signature / x-signature. + /// "gzip" when compression is enabled, otherwise null. + /// "base64" for SQS / SNS firehose, otherwise null. + /// + /// Thrown when the signature does not match, or when the body fails to decode. + /// + /// + /// Thrown when or + /// is set to a value the SDK does not support. + /// + byte[] VerifyAndDecodeWebhook(byte[] body, string signature, string contentEncoding = null, string payloadEncoding = null); } } \ No newline at end of file diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs new file mode 100644 index 00000000..0672b39c --- /dev/null +++ b/src/Clients/WebhookHelpers.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using StreamChat.Exceptions; + +namespace StreamChat.Clients +{ + /// + /// Reverses the encoding wrappers Stream applies to outbound webhook / + /// SQS / SNS payloads and verifies the HMAC signature server-side + /// signs over the inner JSON. + /// + /// + /// Decode order is fixed by the cross-SDK contract: payload encoding + /// (base64 wrapping) is unwrapped first because SQS / SNS firehose + /// envelopes wrap the (possibly already gzipped) bytes in base64 to + /// keep them transport-safe; only after that does Content-Encoding + /// (gzip) get reversed. + /// + internal static class WebhookHelpers + { + public static byte[] DecompressWebhookBody(byte[] body, string contentEncoding, string payloadEncoding) + { + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + var working = body; + + if (!string.IsNullOrWhiteSpace(payloadEncoding)) + { + var pe = payloadEncoding.Trim().ToLowerInvariant(); + if (pe == "base64" || pe == "b64") + { + try + { + var b64Text = Encoding.ASCII.GetString(working); + working = Convert.FromBase64String(b64Text); + } + catch (FormatException ex) + { + throw new StreamWebhookSignatureException( + $"failed to decode webhook payload_encoding=\"{payloadEncoding}\": invalid base64 input", + ex); + } + } + else + { + throw new InvalidOperationException( + $"unsupported webhook payload_encoding: {payloadEncoding}. This SDK only supports base64."); + } + } + + if (!string.IsNullOrWhiteSpace(contentEncoding)) + { + var ce = contentEncoding.Trim().ToLowerInvariant(); + if (ce == "gzip") + { + try + { + using (var input = new MemoryStream(working)) + using (var gzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + gzip.CopyTo(output); + working = output.ToArray(); + } + } + catch (InvalidDataException ex) + { + throw new StreamWebhookSignatureException("failed to decompress webhook body", ex); + } + } + else + { + throw new InvalidOperationException( + $"unsupported webhook Content-Encoding: {contentEncoding}. This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on the app config."); + } + } + + return working; + } + + public static byte[] VerifyAndDecodeWebhook(string apiSecret, byte[] body, string signature, string contentEncoding, string payloadEncoding) + { + if (apiSecret == null) + { + throw new ArgumentNullException(nameof(apiSecret)); + } + + if (signature == null) + { + throw new ArgumentNullException(nameof(signature)); + } + + var decoded = DecompressWebhookBody(body, contentEncoding, payloadEncoding); + + byte[] computed; + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret))) + { + computed = hmac.ComputeHash(decoded); + } + + if (!TryHexToBytes(signature, out var provided) || provided.Length != computed.Length || !FixedTimeEquals(computed, provided)) + { + throw new StreamWebhookSignatureException("invalid webhook signature"); + } + + return decoded; + } + + private static bool TryHexToBytes(string hex, out byte[] result) + { + result = null; + if (hex == null || (hex.Length & 1) != 0) + { + return false; + } + + var buf = new byte[hex.Length / 2]; + for (int i = 0; i < buf.Length; i++) + { + int hi = HexNibble(hex[i * 2]); + int lo = HexNibble(hex[(i * 2) + 1]); + if (hi < 0 || lo < 0) + { + return false; + } + + buf[i] = (byte)((hi << 4) | lo); + } + + result = buf; + return true; + } + + private static int HexNibble(char c) + { + if (c >= '0' && c <= '9') + { + return c - '0'; + } + + if (c >= 'a' && c <= 'f') + { + return c - 'a' + 10; + } + + if (c >= 'A' && c <= 'F') + { + return c - 'A' + 10; + } + + return -1; + } + + private static bool FixedTimeEquals(byte[] left, byte[] right) + { +#if NETSTANDARD2_1 || NET5_0_OR_GREATER || NETCOREAPP2_1_OR_GREATER + return CryptographicOperations.FixedTimeEquals(left, right); +#else + if (left == null || right == null) + { + return false; + } + + if (left.Length != right.Length) + { + return false; + } + + int diff = 0; + for (int i = 0; i < left.Length; i++) + { + diff |= left[i] ^ right[i]; + } + + return diff == 0; +#endif + } + } +} diff --git a/src/Exceptions/StreamWebhookSignatureException.cs b/src/Exceptions/StreamWebhookSignatureException.cs new file mode 100644 index 00000000..68eacee7 --- /dev/null +++ b/src/Exceptions/StreamWebhookSignatureException.cs @@ -0,0 +1,23 @@ +using System; + +namespace StreamChat.Exceptions +{ + /// + /// Thrown by + /// when the HMAC signature on a webhook / SQS / SNS payload does not + /// match the body the SDK was given. + /// +#if !NETCORE + [Serializable] +#endif + public class StreamWebhookSignatureException : StreamBaseException + { + internal StreamWebhookSignatureException(string message) : base(message) + { + } + + internal StreamWebhookSignatureException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs new file mode 100644 index 00000000..c78cd2ac --- /dev/null +++ b/tests/WebhookCompressionTests.cs @@ -0,0 +1,273 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using StreamChat.Clients; +using StreamChat.Exceptions; + +#pragma warning disable SA1310 // Field names should not contain underscore — names mandated by the cross-SDK contract. + +namespace StreamChatTests +{ + /// + /// Unit tests for the webhook decode + signature-verification helpers + /// added in . + /// + /// + /// These tests do not extend on purpose — they + /// must run fully offline because they cover SDK-only behavior that does + /// not touch the Stream API. Tests follow the arrange-act-assert pattern + /// divided by empty lines. + /// + [TestFixture] + public class WebhookCompressionTests + { + private const string JSON_BODY = "{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}"; + private const string API_SECRET = "tsec2"; + private const string API_KEY = "test-api-key"; + + private static IAppClient BuildAppClient(string secret = API_SECRET) + => new StreamClientFactory(API_KEY, secret).GetAppClient(); + + private static byte[] Gzip(byte[] input) + { + using (var output = new MemoryStream()) + { + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + gzip.Write(input, 0, input.Length); + } + + return output.ToArray(); + } + } + + private static byte[] Base64Wrap(byte[] input) + => Encoding.ASCII.GetBytes(Convert.ToBase64String(input)); + + private static string HmacHex(string secret, byte[] data) + { + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret))) + { + var hash = hmac.ComputeHash(data); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + [Test] + public void VerifyWebhook_ExistingMethod_StillWorks() + { + var appClient = BuildAppClient(); + var signature = HmacHex(API_SECRET, Encoding.UTF8.GetBytes(JSON_BODY)); + + var ok = appClient.VerifyWebhook(JSON_BODY, signature); + var bad = appClient.VerifyWebhook(JSON_BODY, new string('0', 64)); + + ok.Should().BeTrue(); + bad.Should().BeFalse(); + } + + [TestCase(null, null)] + [TestCase("", "")] + [TestCase(" ", " ")] + [TestCase(null, "")] + [TestCase("", null)] + public void DecompressWebhookBody_PassthroughWhenNoEncoding(string contentEncoding, string payloadEncoding) + { + var appClient = BuildAppClient(); + var body = Encoding.UTF8.GetBytes(JSON_BODY); + + var decoded = appClient.DecompressWebhookBody(body, contentEncoding, payloadEncoding); + + decoded.Should().Equal(body); + } + + [Test] + public void DecompressWebhookBody_GzipRoundTrip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var gzipped = Gzip(raw); + + var decoded = appClient.DecompressWebhookBody(gzipped, contentEncoding: "gzip"); + + decoded.Should().Equal(raw); + Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + } + + [Test] + public void DecompressWebhookBody_Base64RoundTrip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(raw); + + var decoded = appClient.DecompressWebhookBody(wrapped, payloadEncoding: "base64"); + + decoded.Should().Equal(raw); + } + + [Test] + public void DecompressWebhookBody_Base64GzipRoundTrip_SqsSnsShape() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var decoded = appClient.DecompressWebhookBody(wrapped, contentEncoding: "gzip", payloadEncoding: "base64"); + + decoded.Should().Equal(raw); + } + + [TestCase("GZIP", "BASE64")] + [TestCase("GzIp", "Base64")] + [TestCase(" gzip ", " b64 ")] + public void DecompressWebhookBody_CaseInsensitive(string contentEncoding, string payloadEncoding) + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var decoded = appClient.DecompressWebhookBody(wrapped, contentEncoding, payloadEncoding); + + decoded.Should().Equal(raw); + } + + [TestCase("br")] + [TestCase("brotli")] + [TestCase("zstd")] + [TestCase("deflate")] + [TestCase("compress")] + [TestCase("lz4")] + public void DecompressWebhookBody_RejectsUnsupportedContentEncoding(string contentEncoding) + { + var appClient = BuildAppClient(); + var body = Encoding.UTF8.GetBytes(JSON_BODY); + + Action call = () => appClient.DecompressWebhookBody(body, contentEncoding); + + call.Should().Throw() + .WithMessage("*unsupported webhook Content-Encoding*gzip*"); + } + + [TestCase("hex")] + [TestCase("url")] + [TestCase("binary")] + public void DecompressWebhookBody_RejectsUnsupportedPayloadEncoding(string payloadEncoding) + { + var appClient = BuildAppClient(); + var body = Encoding.UTF8.GetBytes(JSON_BODY); + + Action call = () => appClient.DecompressWebhookBody(body, payloadEncoding: payloadEncoding); + + call.Should().Throw() + .WithMessage("*unsupported webhook payload_encoding*base64*"); + } + + [Test] + public void DecompressWebhookBody_ThrowsOnInvalidGzipBytes() + { + var appClient = BuildAppClient(); + var notGzip = Encoding.UTF8.GetBytes(JSON_BODY); + + Action call = () => appClient.DecompressWebhookBody(notGzip, contentEncoding: "gzip"); + + call.Should().Throw() + .WithMessage("*failed to decompress webhook body*"); + } + + [Test] + public void DecompressWebhookBody_ThrowsOnInvalidBase64Input() + { + var appClient = BuildAppClient(); + var notBase64 = Encoding.UTF8.GetBytes("@@@-not-base64-@@@"); + + Action call = () => appClient.DecompressWebhookBody(notBase64, payloadEncoding: "base64"); + + call.Should().Throw() + .WithMessage("*payload_encoding*"); + } + + [Test] + public void VerifyAndDecodeWebhook_HappyPathPlain() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + + var decoded = appClient.VerifyAndDecodeWebhook(raw, signature); + + Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + } + + [Test] + public void VerifyAndDecodeWebhook_HappyPathGzip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var gzipped = Gzip(raw); + + var decoded = appClient.VerifyAndDecodeWebhook(gzipped, signature, contentEncoding: "gzip"); + + Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + } + + [Test] + public void VerifyAndDecodeWebhook_HappyPathBase64Gzip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(Gzip(raw)); + + var decoded = appClient.VerifyAndDecodeWebhook(wrapped, signature, contentEncoding: "gzip", payloadEncoding: "base64"); + + Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + } + + [Test] + public void VerifyAndDecodeWebhook_ThrowsOnSignatureMismatch() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var bogus = HmacHex("a-different-secret", raw); + + Action call = () => appClient.VerifyAndDecodeWebhook(raw, bogus); + + call.Should().Throw() + .WithMessage("invalid webhook signature"); + } + + [Test] + public void VerifyAndDecodeWebhook_RejectsGzipWhenSignatureIsOverCompressedBytes() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var gzipped = Gzip(raw); + var signatureOverCompressed = HmacHex(API_SECRET, gzipped); + + Action call = () => appClient.VerifyAndDecodeWebhook(gzipped, signatureOverCompressed, contentEncoding: "gzip"); + + call.Should().Throw() + .WithMessage("invalid webhook signature"); + } + + [Test] + public void VerifyAndDecodeWebhook_RejectsBase64GzipWhenSignatureIsOverWrappedBytes() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var signatureOverWrapped = HmacHex(API_SECRET, wrapped); + + Action call = () => appClient.VerifyAndDecodeWebhook(wrapped, signatureOverWrapped, contentEncoding: "gzip", payloadEncoding: "base64"); + + call.Should().Throw() + .WithMessage("invalid webhook signature"); + } + } +} From 5de524c8d9962a279d05677704cdfc7b65aa82e7 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 16:06:44 +0200 Subject: [PATCH 02/13] refactor(webhooks): switch to VerifyAndParse* API (CHA-3071) Replace the experimental VerifyAndDecodeWebhook surface with the new VerifyAndParse{Webhook,Sqs,Sns} family that mirrors the cross-SDK contract: - WebhookHelpers becomes public and exposes the primitive helpers UngzipPayload, DecodeSqsPayload, DecodeSnsPayload, VerifySignature, ParseEvent. - VerifyAndParse* compose those primitives, transparently handle gzip via magic-byte detection (and base64+gzip for SQS/SNS), and return a typed EventResponse. - VerifyWebhook is preserved for backwards compatibility and now uses the same constant-time HMAC-SHA256 path. - Tests cover plain / gzip / base64 / base64+gzip payloads, signature mismatches, malformed bodies, and parsing into EventResponse. Co-authored-by: Cursor --- src/Clients/AppClient.cs | 11 +- src/Clients/IAppClient.cs | 89 +++---- src/Clients/WebhookHelpers.cs | 152 ++++++----- .../StreamWebhookSignatureException.cs | 7 +- tests/WebhookCompressionTests.cs | 249 +++++++++++------- 5 files changed, 285 insertions(+), 223 deletions(-) diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index 6a7f06d8..0fa18fb6 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -86,10 +86,13 @@ public bool VerifyWebhook(string requestBody, string xSignature) } } - public byte[] DecompressWebhookBody(byte[] body, string contentEncoding = null, string payloadEncoding = null) - => WebhookHelpers.DecompressWebhookBody(body, contentEncoding, payloadEncoding); + public EventResponse VerifyAndParseWebhook(byte[] body, string signature) + => WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret); - public byte[] VerifyAndDecodeWebhook(byte[] body, string signature, string contentEncoding = null, string payloadEncoding = null) - => WebhookHelpers.VerifyAndDecodeWebhook(_apiSecret, body, signature, contentEncoding, payloadEncoding); + public EventResponse VerifyAndParseSqs(string messageBody, string signature) + => WebhookHelpers.VerifyAndParseSqs(messageBody, signature, _apiSecret); + + public EventResponse VerifyAndParseSns(string message, string signature) + => WebhookHelpers.VerifyAndParseSns(message, signature, _apiSecret); } } \ No newline at end of file diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index 96d4dffa..f17a9f22 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -75,73 +75,52 @@ public interface IAppClient bool VerifyWebhook(string requestBody, string xSignature); /// - /// Reverses the encoding wrappers Stream applies to outbound webhook / - /// SQS / SNS payloads, returning the raw JSON bytes the server signed. + /// Verify and parse an HTTP webhook event. /// /// - /// - /// When is set the wrapper layer is - /// removed first (SQS / SNS firehose envelopes wrap the bytes in base64 - /// so they remain valid UTF-8 over the queue). Then, when - /// is set, the resulting bytes are - /// gzip-decompressed. Passing null (or the empty string) for - /// either parameter is a no-op so plain HTTP webhooks behave the same - /// as before. - /// - /// - /// The signature Stream emits is always computed over the innermost - /// (uncompressed, base64-decoded) JSON, so this is also the canonical - /// representation to feed into . - /// + /// Decompresses when gzipped (detected from the + /// body bytes), verifies the X-Signature header against the + /// client's API secret, and returns the parsed . + /// The same call works whether or not Stream is currently compressing + /// payloads for this app, and stays correct behind middleware that + /// auto-decompresses the request. /// - /// Raw transport bytes (HTTP body, SQS Body, SNS Message). - /// "gzip" when compression is enabled, otherwise null. - /// "base64" for SQS / SNS firehose, otherwise null. - /// - /// Thrown when or - /// is set to a value the SDK does not support. - /// + /// Raw HTTP request body bytes Stream signed. + /// Value of the X-Signature header. /// - /// Thrown when the body fails to decode (invalid base64, invalid gzip). + /// Thrown when the signature does not match or the gzip envelope is malformed. /// - byte[] DecompressWebhookBody(byte[] body, string contentEncoding = null, string payloadEncoding = null); + EventResponse VerifyAndParseWebhook(byte[] body, string signature); /// - /// Decompresses (when needed) and verifies the HMAC signature, returning - /// the uncompressed JSON bytes. The signature is always computed over - /// the innermost (uncompressed, base64-decoded) JSON, so the verification - /// rule is invariant across HTTP webhooks and SQS / SNS. + /// Verify and parse an SQS firehose webhook event. /// /// - /// - /// For HTTP webhooks: pass the raw body and the X-Signature - /// header. If your app config has webhook_compression_algorithm - /// set to "gzip" the request will arrive with - /// Content-Encoding: gzip — pass that header value as - /// . (Some HTTP servers and middleware - /// — Rails, Django, Laravel, Spring Boot, ASP.NET — strip - /// Content-Encoding and decompress for you, in which case the - /// body is already raw JSON and - /// must be left null.) - /// - /// - /// For SQS / SNS firehose: pass the message body, the - /// x-signature message attribute, "base64" for - /// , and "gzip" for - /// when compression is on. - /// + /// Reverses the base64 (+ optional gzip) wrapping on the SQS Body, + /// verifies the X-Signature message attribute against the + /// client's API secret, and returns the parsed . /// - /// Raw transport bytes (HTTP body, SQS Body, SNS Message). - /// Lowercase hex HMAC-SHA256 signature from X-Signature / x-signature. - /// "gzip" when compression is enabled, otherwise null. - /// "base64" for SQS / SNS firehose, otherwise null. + /// SQS message Body string. + /// Value of the X-Signature message attribute. /// - /// Thrown when the signature does not match, or when the body fails to decode. + /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// - /// - /// Thrown when or - /// is set to a value the SDK does not support. + EventResponse VerifyAndParseSqs(string messageBody, string signature); + + /// + /// Verify and parse an SNS firehose webhook event. + /// + /// + /// Reverses the base64 (+ optional gzip) wrapping on the SNS notification + /// Message, verifies the X-Signature message attribute + /// against the client's API secret, and returns the parsed + /// . + /// + /// SNS notification Message field. + /// Value of the X-Signature message attribute. + /// + /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// - byte[] VerifyAndDecodeWebhook(byte[] body, string signature, string contentEncoding = null, string payloadEncoding = null); + EventResponse VerifyAndParseSns(string message, string signature); } } \ No newline at end of file diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 0672b39c..190d8248 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -3,92 +3,81 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text; +using Newtonsoft.Json; using StreamChat.Exceptions; +using StreamChat.Models; namespace StreamChat.Clients { /// - /// Reverses the encoding wrappers Stream applies to outbound webhook / - /// SQS / SNS payloads and verifies the HMAC signature server-side - /// signs over the inner JSON. + /// Stateless helpers implementing the cross-SDK webhook contract documented at + /// https://getstream.io/chat/docs/node/webhooks_overview/. /// /// - /// Decode order is fixed by the cross-SDK contract: payload encoding - /// (base64 wrapping) is unwrapped first because SQS / SNS firehose - /// envelopes wrap the (possibly already gzipped) bytes in base64 to - /// keep them transport-safe; only after that does Content-Encoding - /// (gzip) get reversed. + /// The composite functions (, + /// , ) are the + /// recommended entry points; the primitives they compose are exposed so callers + /// can build custom flows or run individual steps in isolation. /// - internal static class WebhookHelpers + public static class WebhookHelpers { - public static byte[] DecompressWebhookBody(byte[] body, string contentEncoding, string payloadEncoding) + private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b, 0x08 }; + + public static byte[] UngzipPayload(byte[] body) { if (body == null) { throw new ArgumentNullException(nameof(body)); } - var working = body; + if (body.Length < 3 || body[0] != GzipMagic[0] || body[1] != GzipMagic[1] || body[2] != GzipMagic[2]) + { + return body; + } - if (!string.IsNullOrWhiteSpace(payloadEncoding)) + try { - var pe = payloadEncoding.Trim().ToLowerInvariant(); - if (pe == "base64" || pe == "b64") + using (var input = new MemoryStream(body)) + using (var gzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) { - try - { - var b64Text = Encoding.ASCII.GetString(working); - working = Convert.FromBase64String(b64Text); - } - catch (FormatException ex) - { - throw new StreamWebhookSignatureException( - $"failed to decode webhook payload_encoding=\"{payloadEncoding}\": invalid base64 input", - ex); - } - } - else - { - throw new InvalidOperationException( - $"unsupported webhook payload_encoding: {payloadEncoding}. This SDK only supports base64."); + gzip.CopyTo(output); + return output.ToArray(); } } + catch (InvalidDataException ex) + { + throw new StreamWebhookSignatureException("failed to decompress gzip payload", ex); + } + } - if (!string.IsNullOrWhiteSpace(contentEncoding)) + public static byte[] DecodeSqsPayload(string body) + { + if (body == null) { - var ce = contentEncoding.Trim().ToLowerInvariant(); - if (ce == "gzip") - { - try - { - using (var input = new MemoryStream(working)) - using (var gzip = new GZipStream(input, CompressionMode.Decompress)) - using (var output = new MemoryStream()) - { - gzip.CopyTo(output); - working = output.ToArray(); - } - } - catch (InvalidDataException ex) - { - throw new StreamWebhookSignatureException("failed to decompress webhook body", ex); - } - } - else - { - throw new InvalidOperationException( - $"unsupported webhook Content-Encoding: {contentEncoding}. This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on the app config."); - } + throw new ArgumentNullException(nameof(body)); + } + + byte[] decoded; + try + { + decoded = Convert.FromBase64String(body); + } + catch (FormatException ex) + { + throw new StreamWebhookSignatureException("failed to base64-decode payload", ex); } - return working; + return UngzipPayload(decoded); } - public static byte[] VerifyAndDecodeWebhook(string apiSecret, byte[] body, string signature, string contentEncoding, string payloadEncoding) + public static byte[] DecodeSnsPayload(string message) => DecodeSqsPayload(message); + + public static bool VerifySignature(byte[] body, string signature, string secret) { - if (apiSecret == null) + if (body == null) { - throw new ArgumentNullException(nameof(apiSecret)); + throw new ArgumentNullException(nameof(body)); } if (signature == null) @@ -96,20 +85,57 @@ public static byte[] VerifyAndDecodeWebhook(string apiSecret, byte[] body, strin throw new ArgumentNullException(nameof(signature)); } - var decoded = DecompressWebhookBody(body, contentEncoding, payloadEncoding); + if (secret == null) + { + throw new ArgumentNullException(nameof(secret)); + } byte[] computed; - using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret))) + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret))) + { + computed = hmac.ComputeHash(body); + } + + return TryHexToBytes(signature, out var provided) + && provided.Length == computed.Length + && FixedTimeEquals(computed, provided); + } + + public static EventResponse ParseEvent(byte[] payload) + { + if (payload == null) { - computed = hmac.ComputeHash(decoded); + throw new ArgumentNullException(nameof(payload)); } - if (!TryHexToBytes(signature, out var provided) || provided.Length != computed.Length || !FixedTimeEquals(computed, provided)) + var json = Encoding.UTF8.GetString(payload); + try + { + return JsonConvert.DeserializeObject(json); + } + catch (JsonException ex) + { + throw new StreamWebhookSignatureException($"failed to parse webhook event: {ex.Message}", ex); + } + } + + public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) + => VerifyAndParseInternal(UngzipPayload(body), signature, secret); + + public static EventResponse VerifyAndParseSqs(string messageBody, string signature, string secret) + => VerifyAndParseInternal(DecodeSqsPayload(messageBody), signature, secret); + + public static EventResponse VerifyAndParseSns(string message, string signature, string secret) + => VerifyAndParseInternal(DecodeSnsPayload(message), signature, secret); + + private static EventResponse VerifyAndParseInternal(byte[] payload, string signature, string secret) + { + if (!VerifySignature(payload, signature, secret)) { throw new StreamWebhookSignatureException("invalid webhook signature"); } - return decoded; + return ParseEvent(payload); } private static bool TryHexToBytes(string hex, out byte[] result) diff --git a/src/Exceptions/StreamWebhookSignatureException.cs b/src/Exceptions/StreamWebhookSignatureException.cs index 68eacee7..290cc38b 100644 --- a/src/Exceptions/StreamWebhookSignatureException.cs +++ b/src/Exceptions/StreamWebhookSignatureException.cs @@ -3,9 +3,10 @@ namespace StreamChat.Exceptions { /// - /// Thrown by - /// when the HMAC signature on a webhook / SQS / SNS payload does not - /// match the body the SDK was given. + /// Thrown by + /// (and the SQS / SNS variants) when the HMAC signature on a webhook + /// payload does not match the body the SDK was given, or when the + /// gzip / base64 envelope is malformed. /// #if !NETCORE [Serializable] diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index c78cd2ac..5b49ef62 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -13,14 +13,14 @@ namespace StreamChatTests { /// - /// Unit tests for the webhook decode + signature-verification helpers - /// added in . + /// Unit tests for the webhook verification + parsing helpers added in + /// . /// /// - /// These tests do not extend on purpose — they - /// must run fully offline because they cover SDK-only behavior that does - /// not touch the Stream API. Tests follow the arrange-act-assert pattern - /// divided by empty lines. + /// These tests do not extend on purpose - they + /// must run fully offline because they cover SDK-only behaviour that + /// does not touch the Stream API. Tests follow the arrange-act-assert + /// pattern divided by empty lines. /// [TestFixture] public class WebhookCompressionTests @@ -45,8 +45,7 @@ private static byte[] Gzip(byte[] input) } } - private static byte[] Base64Wrap(byte[] input) - => Encoding.ASCII.GetBytes(Convert.ToBase64String(input)); + private static string Base64Wrap(byte[] input) => Convert.ToBase64String(input); private static string HmacHex(string secret, byte[] data) { @@ -58,7 +57,7 @@ private static string HmacHex(string secret, byte[] data) } [Test] - public void VerifyWebhook_ExistingMethod_StillWorks() + public void VerifyWebhook_BackwardCompatibility_StillWorks() { var appClient = BuildAppClient(); var signature = HmacHex(API_SECRET, Encoding.UTF8.GetBytes(JSON_BODY)); @@ -70,204 +69,258 @@ public void VerifyWebhook_ExistingMethod_StillWorks() bad.Should().BeFalse(); } - [TestCase(null, null)] - [TestCase("", "")] - [TestCase(" ", " ")] - [TestCase(null, "")] - [TestCase("", null)] - public void DecompressWebhookBody_PassthroughWhenNoEncoding(string contentEncoding, string payloadEncoding) + [Test] + public void VerifyAndParseWebhook_PlainBody() { var appClient = BuildAppClient(); - var body = Encoding.UTF8.GetBytes(JSON_BODY); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); - var decoded = appClient.DecompressWebhookBody(body, contentEncoding, payloadEncoding); + var ev = appClient.VerifyAndParseWebhook(raw, signature); - decoded.Should().Equal(body); + ev.Type.Should().Be("message.new"); + ev.Message.Should().NotBeNull(); + ev.Message.Text.Should().Be("the quick brown fox"); } [Test] - public void DecompressWebhookBody_GzipRoundTrip() + public void VerifyAndParseWebhook_GzipBody() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); var gzipped = Gzip(raw); - var decoded = appClient.DecompressWebhookBody(gzipped, contentEncoding: "gzip"); + var ev = appClient.VerifyAndParseWebhook(gzipped, signature); - decoded.Should().Equal(raw); - Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + ev.Type.Should().Be("message.new"); } [Test] - public void DecompressWebhookBody_Base64RoundTrip() + public void VerifyAndParseWebhook_ThrowsOnSignatureMismatch() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(raw); + var bogus = HmacHex("a-different-secret", raw); - var decoded = appClient.DecompressWebhookBody(wrapped, payloadEncoding: "base64"); + Action call = () => appClient.VerifyAndParseWebhook(raw, bogus); - decoded.Should().Equal(raw); + call.Should().Throw() + .WithMessage("invalid webhook signature"); } [Test] - public void DecompressWebhookBody_Base64GzipRoundTrip_SqsSnsShape() + public void VerifyAndParseWebhook_RejectsSignatureOverCompressedBytes() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); + var gzipped = Gzip(raw); + var sigOverCompressed = HmacHex(API_SECRET, gzipped); - var decoded = appClient.DecompressWebhookBody(wrapped, contentEncoding: "gzip", payloadEncoding: "base64"); + Action call = () => appClient.VerifyAndParseWebhook(gzipped, sigOverCompressed); - decoded.Should().Equal(raw); + call.Should().Throw() + .WithMessage("invalid webhook signature"); } - [TestCase("GZIP", "BASE64")] - [TestCase("GzIp", "Base64")] - [TestCase(" gzip ", " b64 ")] - public void DecompressWebhookBody_CaseInsensitive(string contentEncoding, string payloadEncoding) + [Test] + public void VerifyAndParseSqs_Base64Only() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); + var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(raw); - var decoded = appClient.DecompressWebhookBody(wrapped, contentEncoding, payloadEncoding); + var ev = appClient.VerifyAndParseSqs(wrapped, signature); - decoded.Should().Equal(raw); + ev.Type.Should().Be("message.new"); } - [TestCase("br")] - [TestCase("brotli")] - [TestCase("zstd")] - [TestCase("deflate")] - [TestCase("compress")] - [TestCase("lz4")] - public void DecompressWebhookBody_RejectsUnsupportedContentEncoding(string contentEncoding) + [Test] + public void VerifyAndParseSqs_Base64PlusGzip() { var appClient = BuildAppClient(); - var body = Encoding.UTF8.GetBytes(JSON_BODY); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(Gzip(raw)); - Action call = () => appClient.DecompressWebhookBody(body, contentEncoding); + var ev = appClient.VerifyAndParseSqs(wrapped, signature); - call.Should().Throw() - .WithMessage("*unsupported webhook Content-Encoding*gzip*"); + ev.Type.Should().Be("message.new"); } - [TestCase("hex")] - [TestCase("url")] - [TestCase("binary")] - public void DecompressWebhookBody_RejectsUnsupportedPayloadEncoding(string payloadEncoding) + [Test] + public void VerifyAndParseSqs_RejectsSignatureOverWrappedBytes() { var appClient = BuildAppClient(); - var body = Encoding.UTF8.GetBytes(JSON_BODY); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var sigOverWrapped = HmacHex(API_SECRET, Encoding.ASCII.GetBytes(wrapped)); - Action call = () => appClient.DecompressWebhookBody(body, payloadEncoding: payloadEncoding); + Action call = () => appClient.VerifyAndParseSqs(wrapped, sigOverWrapped); - call.Should().Throw() - .WithMessage("*unsupported webhook payload_encoding*base64*"); + call.Should().Throw() + .WithMessage("invalid webhook signature"); } [Test] - public void DecompressWebhookBody_ThrowsOnInvalidGzipBytes() + public void VerifyAndParseSqs_ThrowsOnInvalidBase64() { var appClient = BuildAppClient(); - var notGzip = Encoding.UTF8.GetBytes(JSON_BODY); - Action call = () => appClient.DecompressWebhookBody(notGzip, contentEncoding: "gzip"); + Action call = () => appClient.VerifyAndParseSqs("@@@-not-base64-@@@", "ignored"); call.Should().Throw() - .WithMessage("*failed to decompress webhook body*"); + .WithMessage("*base64-decode*"); } [Test] - public void DecompressWebhookBody_ThrowsOnInvalidBase64Input() + public void VerifyAndParseSns_Base64PlusGzip() { var appClient = BuildAppClient(); - var notBase64 = Encoding.UTF8.GetBytes("@@@-not-base64-@@@"); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(Gzip(raw)); - Action call = () => appClient.DecompressWebhookBody(notBase64, payloadEncoding: "base64"); + var ev = appClient.VerifyAndParseSns(wrapped, signature); - call.Should().Throw() - .WithMessage("*payload_encoding*"); + ev.Type.Should().Be("message.new"); } [Test] - public void VerifyAndDecodeWebhook_HappyPathPlain() + public void VerifyAndParseSns_MatchesSqs() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(Gzip(raw)); - var decoded = appClient.VerifyAndDecodeWebhook(raw, signature); + var sns = appClient.VerifyAndParseSns(wrapped, signature); + var sqs = appClient.VerifyAndParseSqs(wrapped, signature); - Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + sns.Type.Should().Be(sqs.Type); + sns.Message.Text.Should().Be(sqs.Message.Text); } [Test] - public void VerifyAndDecodeWebhook_HappyPathGzip() + public void UngzipPayload_PassthroughPlainBytes() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + var output = WebhookHelpers.UngzipPayload(raw); + + output.Should().Equal(raw); + } + + [Test] + public void UngzipPayload_InflatesGzipBytes() { - var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var gzipped = Gzip(raw); - var decoded = appClient.VerifyAndDecodeWebhook(gzipped, signature, contentEncoding: "gzip"); + var output = WebhookHelpers.UngzipPayload(gzipped); - Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + output.Should().Equal(raw); } [Test] - public void VerifyAndDecodeWebhook_HappyPathBase64Gzip() + public void UngzipPayload_ThrowsOnInvalidGzipBody() + { + // Valid gzip header + deflate flags + bogus payload, so the magic + // check passes but inflation fails with InvalidDataException. + var bad = new byte[] + { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }; + + Action call = () => WebhookHelpers.UngzipPayload(bad); + + call.Should().Throw() + .WithMessage("*decompress gzip*"); + } + + [Test] + public void DecodeSqsPayload_Base64Only() { - var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); - var wrapped = Base64Wrap(Gzip(raw)); + var wrapped = Convert.ToBase64String(raw); - var decoded = appClient.VerifyAndDecodeWebhook(wrapped, signature, contentEncoding: "gzip", payloadEncoding: "base64"); + var output = WebhookHelpers.DecodeSqsPayload(wrapped); - Encoding.UTF8.GetString(decoded).Should().Be(JSON_BODY); + output.Should().Equal(raw); } [Test] - public void VerifyAndDecodeWebhook_ThrowsOnSignatureMismatch() + public void DecodeSqsPayload_Base64PlusGzip() { - var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var bogus = HmacHex("a-different-secret", raw); + var wrapped = Convert.ToBase64String(Gzip(raw)); - Action call = () => appClient.VerifyAndDecodeWebhook(raw, bogus); + var output = WebhookHelpers.DecodeSqsPayload(wrapped); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + output.Should().Equal(raw); } [Test] - public void VerifyAndDecodeWebhook_RejectsGzipWhenSignatureIsOverCompressedBytes() + public void DecodeSnsPayload_AliasesDecodeSqsPayload() { - var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var gzipped = Gzip(raw); - var signatureOverCompressed = HmacHex(API_SECRET, gzipped); + var wrapped = Convert.ToBase64String(Gzip(raw)); - Action call = () => appClient.VerifyAndDecodeWebhook(gzipped, signatureOverCompressed, contentEncoding: "gzip"); + var sns = WebhookHelpers.DecodeSnsPayload(wrapped); + var sqs = WebhookHelpers.DecodeSqsPayload(wrapped); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + sns.Should().Equal(sqs); } [Test] - public void VerifyAndDecodeWebhook_RejectsBase64GzipWhenSignatureIsOverWrappedBytes() + public void VerifySignature_Matching() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var sig = HmacHex(API_SECRET, raw); + + WebhookHelpers.VerifySignature(raw, sig, API_SECRET).Should().BeTrue(); + } + + [Test] + public void VerifySignature_Mismatched() { - var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - var signatureOverWrapped = HmacHex(API_SECRET, wrapped); - Action call = () => appClient.VerifyAndDecodeWebhook(wrapped, signatureOverWrapped, contentEncoding: "gzip", payloadEncoding: "base64"); + WebhookHelpers.VerifySignature(raw, new string('0', 64), API_SECRET).Should().BeFalse(); + } + + [Test] + public void ParseEvent_KnownType() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + var ev = WebhookHelpers.ParseEvent(raw); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void ParseEvent_UnknownTypeStillParses() + { + var raw = Encoding.UTF8.GetBytes("{\"type\":\"a.future.event\"}"); + + var ev = WebhookHelpers.ParseEvent(raw); + + ev.Type.Should().Be("a.future.event"); + } + + [Test] + public void ParseEvent_ThrowsOnMalformedJson() + { + var raw = Encoding.UTF8.GetBytes("not json"); + + Action call = () => WebhookHelpers.ParseEvent(raw); call.Should().Throw() - .WithMessage("invalid webhook signature"); + .WithMessage("*parse webhook event*"); } } } From 6670730b0ec465bd2ad4c9b0fae5f7b32da513eb Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 8 May 2026 16:53:18 +0200 Subject: [PATCH 03/13] 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 --- src/Clients/WebhookHelpers.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 190d8248..e1bff50a 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -21,7 +21,7 @@ namespace StreamChat.Clients /// public static class WebhookHelpers { - private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b, 0x08 }; + private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b }; public static byte[] UngzipPayload(byte[] body) { @@ -30,7 +30,7 @@ public static byte[] UngzipPayload(byte[] body) throw new ArgumentNullException(nameof(body)); } - if (body.Length < 3 || body[0] != GzipMagic[0] || body[1] != GzipMagic[1] || body[2] != GzipMagic[2]) + if (body.Length < 2 || body[0] != GzipMagic[0] || body[1] != GzipMagic[1]) { return body; } From 4ab8bb09a9b250fe327ec000b5855a0d216c32f3 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 10:48:22 +0200 Subject: [PATCH 04/13] feat(webhooks): expose VerifyAndParse* on StreamClientFactory Surface the dual-API pattern at the level callers actually hold a reference to. IStreamClientFactory / StreamClientFactory now ship VerifyAndParseWebhook, VerifyAndParseSqs, and VerifyAndParseSns that delegate to the existing IAppClient methods using the factory's configured API secret. Mirrors the dual-API surface in stream-chat-go and matches the call shape documented at getstream.io/chat/docs/dotnet-csharp/webhooks_overview/. The static WebhookHelpers and the IAppClient instance methods remain unchanged for callers that still want to drill through GetAppClient() or use the stateless helpers in workers. Co-authored-by: Cursor --- src/Clients/IStreamClientFactory.cs | 35 ++++++++++++++++++++ src/Clients/StreamClientFactory.cs | 13 ++++++++ tests/WebhookCompressionTests.cs | 51 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/src/Clients/IStreamClientFactory.cs b/src/Clients/IStreamClientFactory.cs index 20d61e9a..95e47bae 100644 --- a/src/Clients/IStreamClientFactory.cs +++ b/src/Clients/IStreamClientFactory.cs @@ -1,3 +1,5 @@ +using StreamChat.Models; + namespace StreamChat.Clients { /// @@ -12,6 +14,39 @@ public interface IStreamClientFactory /// https://getstream.io/chat/docs/dotnet-csharp/app_setting_overview/?language=csharp IAppClient GetAppClient(); + /// + /// Verify and parse an HTTP webhook event using this factory's API secret. + /// + /// + /// Convenience wrapper around + /// so callers that already hold the top-level factory don't need to reach + /// for first. See + /// https://getstream.io/chat/docs/dotnet-csharp/webhooks_overview/. + /// + /// Raw HTTP request body bytes Stream signed. + /// Value of the X-Signature header. + EventResponse VerifyAndParseWebhook(byte[] body, string signature); + + /// + /// Verify and parse an SQS firehose webhook event using this factory's API secret. + /// + /// + /// Convenience wrapper around . + /// + /// SQS message Body string. + /// Value of the X-Signature message attribute. + EventResponse VerifyAndParseSqs(string messageBody, string signature); + + /// + /// Verify and parse an SNS firehose webhook event using this factory's API secret. + /// + /// + /// Convenience wrapper around . + /// + /// SNS notification Message field. + /// Value of the X-Signature message attribute. + EventResponse VerifyAndParseSns(string message, string signature); + /// /// Returns an instance. The returned client can be used as a singleton in your application. /// diff --git a/src/Clients/StreamClientFactory.cs b/src/Clients/StreamClientFactory.cs index 66d51629..975c59dd 100644 --- a/src/Clients/StreamClientFactory.cs +++ b/src/Clients/StreamClientFactory.cs @@ -99,6 +99,19 @@ public StreamClientFactory(string apiKey, string apiSecret, Action _appClient; + + /// + public EventResponse VerifyAndParseWebhook(byte[] body, string signature) + => _appClient.VerifyAndParseWebhook(body, signature); + + /// + public EventResponse VerifyAndParseSqs(string messageBody, string signature) + => _appClient.VerifyAndParseSqs(messageBody, signature); + + /// + public EventResponse VerifyAndParseSns(string message, string signature) + => _appClient.VerifyAndParseSns(message, signature); + public IBlocklistClient GetBlocklistClient() => _blocklistClient; public IChannelClient GetChannelClient() => _channelClient; public IChannelTypeClient GetChannelTypeClient() => _channelTypeClient; diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 5b49ef62..b5f7e9b0 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -322,5 +322,56 @@ public void ParseEvent_ThrowsOnMalformedJson() call.Should().Throw() .WithMessage("*parse webhook event*"); } + + [Test] + public void Factory_VerifyAndParseWebhook_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + + var viaFactory = factory.VerifyAndParseWebhook(Gzip(raw), signature); + var viaAppClient = factory.GetAppClient().VerifyAndParseWebhook(Gzip(raw), signature); + + viaFactory.Type.Should().Be(viaAppClient.Type); + viaFactory.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_VerifyAndParseSqs_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var signature = HmacHex(API_SECRET, raw); + + var ev = factory.VerifyAndParseSqs(wrapped, signature); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_VerifyAndParseSns_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var signature = HmacHex(API_SECRET, raw); + + var ev = factory.VerifyAndParseSns(wrapped, signature); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_VerifyAndParseWebhook_RejectsMismatchedSignature() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + Action call = () => factory.VerifyAndParseWebhook(raw, new string('0', 64)); + + call.Should().Throw(); + } } } From 3f23bd08abbfbec8ee1a226f4fde4d7c1742ed0e Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:16:03 +0200 Subject: [PATCH 05/13] docs(webhooks): align overview with shipped VerifyAndParse* API The compressed-webhook section referenced a `VerifyAndDecodeWebhook` / `DecompressWebhookBody` surface with `contentEncoding` and `payloadEncoding` parameters that does not exist in the SDK. The shipped API is `VerifyAndParseWebhook` / `VerifyAndParseSqs` / `VerifyAndParseSns` on `IAppClient`, `IStreamClientFactory`, and the static `WebhookHelpers`, returning a parsed `EventResponse`. Update the prose and both code samples so copy-pasted snippets compile against the actual public API. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index b566df3a..b4d7ebaa 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -108,7 +108,15 @@ 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 -The .NET SDK exposes two helpers on `IAppClient` for this. `VerifyAndDecodeWebhook` decompresses (when needed) and verifies the HMAC signature in one call, returning the uncompressed JSON bytes. `DecompressWebhookBody` is the same minus the signature check, when you want to verify the signature yourself. Both methods are no-ops when both `contentEncoding` and `payloadEncoding` are left `null`, so existing plain-HTTP handlers do not need to change. +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON using a constant-time comparison, and returns the parsed `EventResponse`. They throw `StreamWebhookSignatureException` if the signature does not match or the envelope is malformed. + +The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. + +You can reach the helpers in three ways: + +- `IStreamClientFactory.VerifyAndParseWebhook(byte[], string)` (and the `Sqs`/`Sns` variants) — convenience wrappers on the top-level factory, useful when you only need webhook verification. +- `IAppClient.VerifyAndParseWebhook(byte[], string)` (and the `Sqs`/`Sns` variants) — the same surface scoped to the app client. +- `StreamChat.Clients.WebhookHelpers.VerifyAndParseWebhook(byte[], string, string)` — a stateless static for cases where the API secret is not stored on the factory (for example, multi-tenant servers that pick the secret per request). ### ASP.NET Core handler @@ -117,9 +125,9 @@ The .NET SDK exposes two helpers on `IAppClient` for this. `VerifyAndDecodeWebho [Route("webhooks/stream")] public class StreamWebhookController : ControllerBase { - private readonly IAppClient _appClient; + private readonly IStreamClientFactory _stream; - public StreamWebhookController(IAppClient appClient) => _appClient = appClient; + public StreamWebhookController(IStreamClientFactory stream) => _stream = stream; [HttpPost] public async Task ReceiveAsync() @@ -132,16 +140,10 @@ public class StreamWebhookController : ControllerBase var signature = HttpContext.Request.Headers["X-Signature"].ToString(); - // ASP.NET Core will normally strip Content-Encoding and decompress for - // you. If you have wired up automatic decompression, leave the second - // argument null. Otherwise pass the header through: - var contentEncoding = HttpContext.Request.Headers["Content-Encoding"].ToString(); - if (string.IsNullOrEmpty(contentEncoding)) contentEncoding = null; - try { - var json = _appClient.VerifyAndDecodeWebhook(rawBody, signature, contentEncoding); - // ...handle JSON... + EventResponse ev = _stream.VerifyAndParseWebhook(rawBody, signature); + // ...handle ev.Type, ev.Message, etc... return Ok(); } catch (StreamWebhookSignatureException) @@ -154,17 +156,16 @@ public class StreamWebhookController : ControllerBase ### SQS / SNS firehose -When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass `payloadEncoding: "base64"` (and `contentEncoding: "gzip"` when compression is on) and the SDK will unwrap both layers in the right order before checking the signature. +When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the raw message string and the `X-Signature` attribute; the SDK reverses the base64 + optional gzip wrapping in the correct order before checking the signature. ```csharp // Inside your SQS / SNS message handler: -// sqsBody = the message body bytes (Encoding.UTF8.GetBytes(message.Body)) -// xSignature = the value of the "x-signature" message attribute -var json = _appClient.VerifyAndDecodeWebhook( - sqsBody, - xSignature, - contentEncoding: "gzip", // omit / pass null when compression is off - payloadEncoding: "base64"); +// messageBody = the message body string (message.Body for SQS, the inner +// Message field for SNS notifications) +// xSignature = the value of the "X-Signature" message attribute +EventResponse ev = _stream.VerifyAndParseSqs(messageBody, xSignature); +// For SNS firehose, use VerifyAndParseSns with the SNS Message field instead: +// EventResponse ev = _stream.VerifyAndParseSns(snsMessage, xSignature); ``` ## Webhook types From 5f62ece9e01edb98464cfddf2647b8659e72a993 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 11:16:08 +0200 Subject: [PATCH 06/13] docs(webhooks): document WebhookHelpers public surface and exception The cross-SDK contract makes `WebhookHelpers` and `StreamWebhookSignatureException` part of the public surface, but every method on the helper class shipped without XML docs and the exception summary only mentioned signature mismatches even though it is also raised for malformed gzip / base64 / JSON envelopes. Add IntelliSense docs for each public helper (inputs, error cases, the constant-time guarantee on signature verification) and broaden the exception summary to match its actual throw sites. Co-authored-by: Cursor --- src/Clients/WebhookHelpers.cs | 77 +++++++++++++++++++ .../StreamWebhookSignatureException.cs | 11 ++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index e1bff50a..0e911f46 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -23,6 +23,17 @@ public static class WebhookHelpers { private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b }; + /// + /// Returns verbatim when it is not gzipped, or the + /// inflated bytes when the first two bytes match the RFC 1952 gzip magic + /// number (0x1F 0x8B). The check is performed on the bytes themselves + /// so it works regardless of the Content-Encoding header. + /// + /// Raw payload bytes; never null. + /// When is null. + /// + /// When the body starts with the gzip magic but cannot be inflated. + /// public static byte[] UngzipPayload(byte[] body) { if (body == null) @@ -51,6 +62,16 @@ public static byte[] UngzipPayload(byte[] body) } } + /// + /// Reverses the SQS firehose envelope: base64-decodes + /// and then inflates the result when it is gzipped. The returned bytes are the + /// raw JSON that Stream signed. + /// + /// SQS message Body string; never null. + /// When is null. + /// + /// When the body is not valid base64 or the inner payload is malformed gzip. + /// public static byte[] DecodeSqsPayload(string body) { if (body == null) @@ -71,8 +92,21 @@ public static byte[] DecodeSqsPayload(string body) return UngzipPayload(decoded); } + /// + /// Alias for kept distinct so SNS-specific + /// call sites read naturally; the wire format is identical to SQS. + /// public static byte[] DecodeSnsPayload(string message) => DecodeSqsPayload(message); + /// + /// Returns true when the hex-encoded HMAC-SHA256 of + /// keyed by matches . + /// The byte comparison is constant-time. + /// + /// Raw bytes the server signed. + /// Hex-encoded HMAC-SHA256 (typically the X-Signature header). + /// Stream Chat API secret. + /// When any argument is null. public static bool VerifySignature(byte[] body, string signature, string secret) { if (body == null) @@ -101,6 +135,15 @@ public static bool VerifySignature(byte[] body, string signature, string secret) && FixedTimeEquals(computed, provided); } + /// + /// Deserialises the JSON in into an + /// . Unknown event types still parse — the + /// surrounding metadata (e.g. type, cid) is populated and + /// future-specific fields land in the CustomData bag. + /// + /// Raw UTF-8 JSON bytes. + /// When is null. + /// When the JSON cannot be parsed. public static EventResponse ParseEvent(byte[] payload) { if (payload == null) @@ -119,12 +162,46 @@ public static EventResponse ParseEvent(byte[] payload) } } + /// + /// Inflates when gzipped (detected from the body + /// bytes, independent of any Content-Encoding header), verifies the + /// HMAC-SHA256 signature in constant time, and returns the parsed event. + /// + /// Raw HTTP request body bytes Stream signed. + /// Hex-encoded HMAC-SHA256 from the X-Signature header. + /// Stream Chat API secret. + /// + /// When the signature does not match or the gzip / JSON envelope is malformed. + /// public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) => VerifyAndParseInternal(UngzipPayload(body), signature, secret); + /// + /// Reverses the SQS firehose envelope (base64, then optional gzip), + /// verifies the HMAC-SHA256 signature in constant time against the + /// uncompressed JSON, and returns the parsed event. + /// + /// SQS message Body string. + /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. + /// Stream Chat API secret. + /// + /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. + /// public static EventResponse VerifyAndParseSqs(string messageBody, string signature, string secret) => VerifyAndParseInternal(DecodeSqsPayload(messageBody), signature, secret); + /// + /// Reverses the SNS firehose envelope (base64, then optional gzip), + /// verifies the HMAC-SHA256 signature in constant time against the + /// uncompressed JSON, and returns the parsed event. The wire format + /// matches SQS; this overload exists so call sites read naturally. + /// + /// SNS notification Message field. + /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. + /// Stream Chat API secret. + /// + /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. + /// public static EventResponse VerifyAndParseSns(string message, string signature, string secret) => VerifyAndParseInternal(DecodeSnsPayload(message), signature, secret); diff --git a/src/Exceptions/StreamWebhookSignatureException.cs b/src/Exceptions/StreamWebhookSignatureException.cs index 290cc38b..144ef063 100644 --- a/src/Exceptions/StreamWebhookSignatureException.cs +++ b/src/Exceptions/StreamWebhookSignatureException.cs @@ -3,10 +3,13 @@ namespace StreamChat.Exceptions { /// - /// Thrown by - /// (and the SQS / SNS variants) when the HMAC signature on a webhook - /// payload does not match the body the SDK was given, or when the - /// gzip / base64 envelope is malformed. + /// Thrown by , + /// the SQS / SNS variants, and the equivalent helpers on + /// and + /// . Surfaced when the HMAC + /// signature on a webhook payload does not match the body the SDK was given, + /// when the gzip / base64 envelope is malformed, or when the JSON inside the + /// envelope cannot be parsed into an . /// #if !NETCORE [Serializable] From b4f804bb7e40e69aa77e2cdbd6fb8e5b30f84abd Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 13:09:06 +0200 Subject: [PATCH 07/13] fix(webhooks): unwrap SNS envelope + constant-time legacy VerifyWebhook 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. Legacy AppClient.VerifyWebhook now delegates to WebhookHelpers.VerifySignature, which uses CryptographicOperations.FixedTimeEquals for constant-time comparison instead of naive string equality. Tests add a realistic SNS HTTP notification body fixture and exercise both the new envelope path and the existing pre-extracted Message path. Co-authored-by: Cursor --- src/Clients/AppClient.cs | 13 ++---- src/Clients/WebhookHelpers.cs | 50 +++++++++++++++++++++-- tests/WebhookCompressionTests.cs | 68 +++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index 0fa18fb6..de59a9cd 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net; -using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using StreamChat.Models; @@ -77,14 +76,10 @@ public async Task DeletePushProviderAsync(PushProviderType provider HttpStatusCode.OK); public bool VerifyWebhook(string requestBody, string xSignature) - { - using (var sha = new HMACSHA256(Encoding.UTF8.GetBytes(_apiSecret))) - { - var sigBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(requestBody)); - var sig = BitConverter.ToString(sigBytes).Replace("-", string.Empty).ToLowerInvariant(); - return sig == xSignature; - } - } + => requestBody != null + && xSignature != null + && WebhookHelpers.VerifySignature( + Encoding.UTF8.GetBytes(requestBody), xSignature, _apiSecret); public EventResponse VerifyAndParseWebhook(byte[] body, string signature) => WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret); diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 0e911f46..8bda53bb 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -93,10 +93,54 @@ public static byte[] DecodeSqsPayload(string body) } /// - /// Alias for kept distinct so SNS-specific - /// call sites read naturally; the wire format is identical to SQS. + /// Reverses an SNS HTTP notification envelope. When + /// is a JSON envelope + /// ({"Type":"Notification","Message":"..."}), the inner + /// 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 Message + /// string, so call sites that pre-unwrap continue to work. /// - public static byte[] DecodeSnsPayload(string message) => DecodeSqsPayload(message); + /// SNS HTTP POST body, or a pre-extracted Message string. + /// When is null. + /// + /// When the extracted Message is not valid base64 or the inner payload is malformed gzip. + /// + public static byte[] DecodeSnsPayload(string notificationBody) + { + if (notificationBody == null) + { + throw new ArgumentNullException(nameof(notificationBody)); + } + + var inner = ExtractSnsMessage(notificationBody); + return DecodeSqsPayload(inner ?? notificationBody); + } + + private static string ExtractSnsMessage(string notificationBody) + { + var trimmed = notificationBody.TrimStart(); + if (trimmed.Length == 0 || trimmed[0] != '{') + { + return null; + } + + try + { + var envelope = JsonConvert.DeserializeObject(trimmed); + return envelope?.Message; + } + catch (JsonException) + { + return null; + } + } + + private sealed class SnsEnvelope + { + [JsonProperty("Message")] + public string Message { get; set; } + } /// /// Returns true when the hex-encoded HMAC-SHA256 of diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index b5f7e9b0..82e34893 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -175,7 +175,7 @@ public void VerifyAndParseSqs_ThrowsOnInvalidBase64() } [Test] - public void VerifyAndParseSns_Base64PlusGzip() + public void VerifyAndParseSns_PreExtractedMessage_Base64PlusGzip() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); @@ -188,7 +188,7 @@ public void VerifyAndParseSns_Base64PlusGzip() } [Test] - public void VerifyAndParseSns_MatchesSqs() + public void VerifyAndParseSns_PreExtractedMessage_MatchesSqs() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); @@ -202,6 +202,70 @@ public void VerifyAndParseSns_MatchesSqs() sns.Message.Text.Should().Be(sqs.Message.Text); } + [Test] + public void VerifyAndParseSns_FullEnvelope() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = SnsEnvelope(wrapped); + + var ev = appClient.VerifyAndParseSns(envelope, signature); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void VerifyAndParseSns_RejectsSignatureOverEnvelope() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = SnsEnvelope(wrapped); + var sigOverEnvelope = HmacHex(API_SECRET, Encoding.UTF8.GetBytes(envelope)); + + Action call = () => appClient.VerifyAndParseSns(envelope, sigOverEnvelope); + + call.Should().Throw() + .WithMessage("invalid webhook signature"); + } + + [Test] + public void DecodeSnsPayload_UnwrapsFullEnvelope() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = SnsEnvelope(wrapped); + + var output = WebhookHelpers.DecodeSnsPayload(envelope); + + output.Should().Equal(raw); + } + + [Test] + public void DecodeSnsPayload_EnvelopeWithLeadingWhitespace() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = "\n " + SnsEnvelope(wrapped); + + var output = WebhookHelpers.DecodeSnsPayload(envelope); + + output.Should().Equal(raw); + } + + private static string SnsEnvelope(string innerMessage) + => "{" + + "\"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] public void UngzipPayload_PassthroughPlainBytes() { From 2fbda70b9a9e36ac77c12f2648c9d1235bb28d5a Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 13:39:28 +0200 Subject: [PATCH 08/13] docs(webhooks): scope constant-time wording to HTTP webhook delivery Drop the "in constant time" claim from VerifyAndParseSqs / VerifyAndParseSns XML docs and the .NET overview prose. The primitive WebhookHelpers.VerifySignature remains constant-time (used by all three composites and the legacy VerifyWebhook), but timing-attack resistance is only meaningful for the HTTP webhook X-Signature header that is exposed publicly. SQS / SNS deliveries arrive over AWS-internal transports where the attack vector does not apply. Co-authored-by: Cursor --- docs/webhooks/webhooks_overview/webhooks_overview.md | 2 +- src/Clients/WebhookHelpers.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index b4d7ebaa..94696e8c 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -108,7 +108,7 @@ 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 -The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON using a constant-time comparison, and returns the parsed `EventResponse`. They throw `StreamWebhookSignatureException` if the signature does not match or the envelope is malformed. +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON, and returns the parsed `EventResponse`. The HTTP webhook path uses a constant-time comparison so the `X-Signature` header — which is exposed on a public endpoint — is not vulnerable to timing attacks; SQS/SNS deliveries arrive over AWS-internal transports where that attack vector is not applicable. All three throw `StreamWebhookSignatureException` if the signature does not match or the envelope is malformed. The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 8bda53bb..2240a103 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -222,8 +222,8 @@ public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, /// /// Reverses the SQS firehose envelope (base64, then optional gzip), - /// verifies the HMAC-SHA256 signature in constant time against the - /// uncompressed JSON, and returns the parsed event. + /// verifies the HMAC-SHA256 signature against the uncompressed JSON, + /// and returns the parsed event. /// /// SQS message Body string. /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. @@ -236,9 +236,9 @@ public static EventResponse VerifyAndParseSqs(string messageBody, string signatu /// /// Reverses the SNS firehose envelope (base64, then optional gzip), - /// verifies the HMAC-SHA256 signature in constant time against the - /// uncompressed JSON, and returns the parsed event. The wire format - /// matches SQS; this overload exists so call sites read naturally. + /// verifies the HMAC-SHA256 signature against the uncompressed JSON, + /// and returns the parsed event. The wire format matches SQS; + /// this overload exists so call sites read naturally. /// /// SNS notification Message field. /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. From 3e801589e941567c4683d56709e3cc8b270f9dfa Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 11 May 2026 15:32:11 +0200 Subject: [PATCH 09/13] 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 --- src/Clients/WebhookHelpers.cs | 6 ++--- tests/WebhookCompressionTests.cs | 38 +++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 2240a103..9eb25c00 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -34,7 +34,7 @@ public static class WebhookHelpers /// /// When the body starts with the gzip magic but cannot be inflated. /// - public static byte[] UngzipPayload(byte[] body) + public static byte[] GunzipPayload(byte[] body) { if (body == null) { @@ -89,7 +89,7 @@ public static byte[] DecodeSqsPayload(string body) throw new StreamWebhookSignatureException("failed to base64-decode payload", ex); } - return UngzipPayload(decoded); + return GunzipPayload(decoded); } /// @@ -218,7 +218,7 @@ public static EventResponse ParseEvent(byte[] payload) /// When the signature does not match or the gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) - => VerifyAndParseInternal(UngzipPayload(body), signature, secret); + => VerifyAndParseInternal(GunzipPayload(body), signature, secret); /// /// Reverses the SQS firehose envelope (base64, then optional gzip), diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 82e34893..6fb7243f 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -267,28 +267,28 @@ private static string SnsEnvelope(string innerMessage) + "}"; [Test] - public void UngzipPayload_PassthroughPlainBytes() + public void GunzipPayload_PassthroughPlainBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var output = WebhookHelpers.UngzipPayload(raw); + var output = WebhookHelpers.GunzipPayload(raw); output.Should().Equal(raw); } [Test] - public void UngzipPayload_InflatesGzipBytes() + public void GunzipPayload_InflatesGzipBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); var gzipped = Gzip(raw); - var output = WebhookHelpers.UngzipPayload(gzipped); + var output = WebhookHelpers.GunzipPayload(gzipped); output.Should().Equal(raw); } [Test] - public void UngzipPayload_ThrowsOnInvalidGzipBody() + public void GunzipPayload_ThrowsOnInvalidGzipBody() { // Valid gzip header + deflate flags + bogus payload, so the magic // check passes but inflation fails with InvalidDataException. @@ -298,12 +298,22 @@ public void UngzipPayload_ThrowsOnInvalidGzipBody() 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, }; - Action call = () => WebhookHelpers.UngzipPayload(bad); + Action call = () => WebhookHelpers.GunzipPayload(bad); call.Should().Throw() .WithMessage("*decompress gzip*"); } + [Test] + public void GunzipPayload_HelloWorldFixture() + { + var gzipped = Convert.FromBase64String("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"); + + var output = WebhookHelpers.GunzipPayload(gzipped); + + output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); + } + [Test] public void DecodeSqsPayload_Base64Only() { @@ -326,6 +336,22 @@ public void DecodeSqsPayload_Base64PlusGzip() output.Should().Equal(raw); } + [Test] + public void DecodeSqsPayload_HelloWorldBase64Fixture() + { + var output = WebhookHelpers.DecodeSqsPayload("aGVsbG93b3JsZA=="); + + output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); + } + + [Test] + public void DecodeSqsPayload_HelloWorldBase64GzipFixture() + { + var output = WebhookHelpers.DecodeSqsPayload("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"); + + output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); + } + [Test] public void DecodeSnsPayload_AliasesDecodeSqsPayload() { From 6bf2dcb14607f8a16ce49ec24247f968d6cd8c29 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 13:56:12 +0200 Subject: [PATCH 10/13] refactor(webhooks): unify webhook errors under StreamInvalidWebhookException (CHA-3071) Per cross-SDK coordination (mogita's review on all 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class. Customers only need one catch arm and can filter on Message text for mode-specific behaviour. Renames the previously-unreleased StreamWebhookSignatureException to StreamInvalidWebhookException (still extends StreamBaseException) and threads it through every primitive: VerifyAndParseWebhook -> 'signature mismatch' GunzipPayload -> 'gzip decompression failed' DecodeSqsPayload -> 'invalid base64 encoding' ParseEvent -> 'invalid JSON payload' VerifySignature keeps its boolean return at the primitive layer; the composite VerifyAndParse* helpers throw on mismatch. The legacy AppClient.VerifyWebhook (bool return) is untouched. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 4 +- src/Clients/IAppClient.cs | 6 +- src/Clients/WebhookHelpers.cs | 29 ++++---- .../StreamInvalidWebhookException.cs | 44 ++++++++++++ .../StreamWebhookSignatureException.cs | 27 -------- tests/WebhookCompressionTests.cs | 69 +++++++++++++++---- 6 files changed, 120 insertions(+), 59 deletions(-) create mode 100644 src/Exceptions/StreamInvalidWebhookException.cs delete mode 100644 src/Exceptions/StreamWebhookSignatureException.cs diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 94696e8c..5f7b4cf4 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -108,7 +108,7 @@ 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 -The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON, and returns the parsed `EventResponse`. The HTTP webhook path uses a constant-time comparison so the `X-Signature` header — which is exposed on a public endpoint — is not vulnerable to timing attacks; SQS/SNS deliveries arrive over AWS-internal transports where that attack vector is not applicable. All three throw `StreamWebhookSignatureException` if the signature does not match or the envelope is malformed. +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON, and returns the parsed `EventResponse`. The HTTP webhook path uses a constant-time comparison so the `X-Signature` header — which is exposed on a public endpoint — is not vulnerable to timing attacks; SQS/SNS deliveries arrive over AWS-internal transports where that attack vector is not applicable. All three throw `StreamInvalidWebhookException` if the signature does not match or the envelope is malformed. The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. @@ -146,7 +146,7 @@ public class StreamWebhookController : ControllerBase // ...handle ev.Type, ev.Message, etc... return Ok(); } - catch (StreamWebhookSignatureException) + catch (StreamInvalidWebhookException) { return Unauthorized(); } diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index f17a9f22..a39519d9 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -87,7 +87,7 @@ public interface IAppClient /// /// Raw HTTP request body bytes Stream signed. /// Value of the X-Signature header. - /// + /// /// Thrown when the signature does not match or the gzip envelope is malformed. /// EventResponse VerifyAndParseWebhook(byte[] body, string signature); @@ -102,7 +102,7 @@ public interface IAppClient /// /// SQS message Body string. /// Value of the X-Signature message attribute. - /// + /// /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// EventResponse VerifyAndParseSqs(string messageBody, string signature); @@ -118,7 +118,7 @@ public interface IAppClient /// /// SNS notification Message field. /// Value of the X-Signature message attribute. - /// + /// /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// EventResponse VerifyAndParseSns(string message, string signature); diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 9eb25c00..22d16f0f 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -17,7 +17,8 @@ namespace StreamChat.Clients /// The composite functions (, /// , ) are the /// recommended entry points; the primitives they compose are exposed so callers - /// can build custom flows or run individual steps in isolation. + /// can build custom flows or run individual steps in isolation. Every failure + /// mode is reported through . /// public static class WebhookHelpers { @@ -31,7 +32,7 @@ public static class WebhookHelpers /// /// Raw payload bytes; never null. /// When is null. - /// + /// /// When the body starts with the gzip magic but cannot be inflated. /// public static byte[] GunzipPayload(byte[] body) @@ -58,7 +59,11 @@ public static byte[] GunzipPayload(byte[] body) } catch (InvalidDataException ex) { - throw new StreamWebhookSignatureException("failed to decompress gzip payload", ex); + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.GzipFailed, ex); + } + catch (IOException ex) + { + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.GzipFailed, ex); } } @@ -69,7 +74,7 @@ public static byte[] GunzipPayload(byte[] body) /// /// SQS message Body string; never null. /// When is null. - /// + /// /// When the body is not valid base64 or the inner payload is malformed gzip. /// public static byte[] DecodeSqsPayload(string body) @@ -86,7 +91,7 @@ public static byte[] DecodeSqsPayload(string body) } catch (FormatException ex) { - throw new StreamWebhookSignatureException("failed to base64-decode payload", ex); + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.InvalidBase64, ex); } return GunzipPayload(decoded); @@ -103,7 +108,7 @@ public static byte[] DecodeSqsPayload(string body) /// /// SNS HTTP POST body, or a pre-extracted Message string. /// When is null. - /// + /// /// When the extracted Message is not valid base64 or the inner payload is malformed gzip. /// public static byte[] DecodeSnsPayload(string notificationBody) @@ -187,7 +192,7 @@ public static bool VerifySignature(byte[] body, string signature, string secret) /// /// Raw UTF-8 JSON bytes. /// When is null. - /// When the JSON cannot be parsed. + /// When the JSON cannot be parsed. public static EventResponse ParseEvent(byte[] payload) { if (payload == null) @@ -202,7 +207,7 @@ public static EventResponse ParseEvent(byte[] payload) } catch (JsonException ex) { - throw new StreamWebhookSignatureException($"failed to parse webhook event: {ex.Message}", ex); + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.InvalidJson, ex); } } @@ -214,7 +219,7 @@ public static EventResponse ParseEvent(byte[] payload) /// Raw HTTP request body bytes Stream signed. /// Hex-encoded HMAC-SHA256 from the X-Signature header. /// Stream Chat API secret. - /// + /// /// When the signature does not match or the gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) @@ -228,7 +233,7 @@ public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, /// SQS message Body string. /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. /// Stream Chat API secret. - /// + /// /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseSqs(string messageBody, string signature, string secret) @@ -243,7 +248,7 @@ public static EventResponse VerifyAndParseSqs(string messageBody, string signatu /// SNS notification Message field. /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. /// Stream Chat API secret. - /// + /// /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseSns(string message, string signature, string secret) @@ -253,7 +258,7 @@ private static EventResponse VerifyAndParseInternal(byte[] payload, string signa { if (!VerifySignature(payload, signature, secret)) { - throw new StreamWebhookSignatureException("invalid webhook signature"); + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.SignatureMismatch); } return ParseEvent(payload); diff --git a/src/Exceptions/StreamInvalidWebhookException.cs b/src/Exceptions/StreamInvalidWebhookException.cs new file mode 100644 index 00000000..2b785b8e --- /dev/null +++ b/src/Exceptions/StreamInvalidWebhookException.cs @@ -0,0 +1,44 @@ +using System; + +namespace StreamChat.Exceptions +{ + /// + /// Unified exception for every webhook verification and parsing failure + /// surfaced by , + /// the SQS / SNS variants, the equivalent helpers on + /// , and the stateless + /// primitives in . A single + /// exception type is thrown for every failure mode so handlers only need + /// one catch arm; the specific cause is identified by the message + /// constants exposed on this class. + /// + /// + /// Covers all webhook envelope and content failures: HMAC signature + /// mismatch (), corrupt gzip + /// (), invalid base64 in the SQS / SNS envelope + /// (), malformed JSON + /// (), unparseable SNS envelopes, and any other + /// schema or structural defect detected before the parsed + /// is returned. Callers can + /// switch on when mode-specific behaviour + /// is required. + /// +#if !NETCORE + [Serializable] +#endif + public class StreamInvalidWebhookException : StreamBaseException + { + public const string SignatureMismatch = "signature mismatch"; + public const string InvalidBase64 = "invalid base64 encoding"; + public const string GzipFailed = "gzip decompression failed"; + public const string InvalidJson = "invalid JSON payload"; + + public StreamInvalidWebhookException(string message) : base(message) + { + } + + public StreamInvalidWebhookException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Exceptions/StreamWebhookSignatureException.cs b/src/Exceptions/StreamWebhookSignatureException.cs deleted file mode 100644 index 144ef063..00000000 --- a/src/Exceptions/StreamWebhookSignatureException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace StreamChat.Exceptions -{ - /// - /// Thrown by , - /// the SQS / SNS variants, and the equivalent helpers on - /// and - /// . Surfaced when the HMAC - /// signature on a webhook payload does not match the body the SDK was given, - /// when the gzip / base64 envelope is malformed, or when the JSON inside the - /// envelope cannot be parsed into an . - /// -#if !NETCORE - [Serializable] -#endif - public class StreamWebhookSignatureException : StreamBaseException - { - internal StreamWebhookSignatureException(string message) : base(message) - { - } - - internal StreamWebhookSignatureException(string message, Exception innerException) : base(message, innerException) - { - } - } -} diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 6fb7243f..3d008892 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -105,8 +105,8 @@ public void VerifyAndParseWebhook_ThrowsOnSignatureMismatch() Action call = () => appClient.VerifyAndParseWebhook(raw, bogus); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + call.Should().Throw() + .WithMessage("*signature mismatch*"); } [Test] @@ -119,8 +119,8 @@ public void VerifyAndParseWebhook_RejectsSignatureOverCompressedBytes() Action call = () => appClient.VerifyAndParseWebhook(gzipped, sigOverCompressed); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + call.Should().Throw() + .WithMessage("*signature mismatch*"); } [Test] @@ -159,8 +159,8 @@ public void VerifyAndParseSqs_RejectsSignatureOverWrappedBytes() Action call = () => appClient.VerifyAndParseSqs(wrapped, sigOverWrapped); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + call.Should().Throw() + .WithMessage("*signature mismatch*"); } [Test] @@ -170,8 +170,8 @@ public void VerifyAndParseSqs_ThrowsOnInvalidBase64() Action call = () => appClient.VerifyAndParseSqs("@@@-not-base64-@@@", "ignored"); - call.Should().Throw() - .WithMessage("*base64-decode*"); + call.Should().Throw() + .WithMessage("*invalid base64 encoding*"); } [Test] @@ -227,8 +227,8 @@ public void VerifyAndParseSns_RejectsSignatureOverEnvelope() Action call = () => appClient.VerifyAndParseSns(envelope, sigOverEnvelope); - call.Should().Throw() - .WithMessage("invalid webhook signature"); + call.Should().Throw() + .WithMessage("*signature mismatch*"); } [Test] @@ -300,8 +300,8 @@ public void GunzipPayload_ThrowsOnInvalidGzipBody() Action call = () => WebhookHelpers.GunzipPayload(bad); - call.Should().Throw() - .WithMessage("*decompress gzip*"); + call.Should().Throw() + .WithMessage("*gzip decompression failed*"); } [Test] @@ -409,8 +409,8 @@ public void ParseEvent_ThrowsOnMalformedJson() Action call = () => WebhookHelpers.ParseEvent(raw); - call.Should().Throw() - .WithMessage("*parse webhook event*"); + call.Should().Throw() + .WithMessage("*invalid JSON payload*"); } [Test] @@ -461,7 +461,46 @@ public void Factory_VerifyAndParseWebhook_RejectsMismatchedSignature() Action call = () => factory.VerifyAndParseWebhook(raw, new string('0', 64)); - call.Should().Throw(); + call.Should().Throw(); + } + + [Test] + public void DecodeSqsPayload_ThrowsOnInvalidBase64() + { + Action call = () => WebhookHelpers.DecodeSqsPayload("@@@-not-base64-@@@"); + + call.Should().Throw() + .WithMessage("*invalid base64 encoding*"); + } + + [Test] + public void GunzipPayload_ThrowsOnCorruptGzip() + { + // Valid gzip magic + header so the magic check passes, then garbage + // so the deflate stream fails. + var bad = new byte[] + { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }; + + Action call = () => WebhookHelpers.GunzipPayload(bad); + + call.Should().Throw() + .WithMessage("*gzip decompression failed*"); + } + + [Test] + public void VerifyAndParseInternal_ThrowsOnInvalidJson() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes("not json"); + var signature = HmacHex(API_SECRET, raw); + + Action call = () => appClient.VerifyAndParseWebhook(raw, signature); + + call.Should().Throw() + .WithMessage("*invalid JSON payload*"); } } } From a465cf7f88ce505c64921ff6389b20d47b8ab147 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 14:44:10 +0200 Subject: [PATCH 11/13] feat(webhooks): make signature optional on VerifyAndParseSqs/Sns (CHA-3071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream does not ship an X-Signature on SQS or SNS deliveries — those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so HMAC verification on top is theatre. signature + secret are now nullable on both WebhookHelpers static methods and on the AppClient instance methods. WebhookHelpers.VerifyAndParseSqs(body) -> decode + parse WebhookHelpers.VerifyAndParseSqs(body, sig, secret) -> + verify appClient.VerifyAndParseSns(envelopeBody) -> unwrap + decode + parse Passing only one of (signature, secret) throws StreamInvalidWebhookException. The HTTP-webhook path is unchanged. Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 24 ++-- src/Clients/AppClient.cs | 16 ++- src/Clients/IAppClient.cs | 32 +++-- src/Clients/WebhookHelpers.cs | 83 +++++++++--- tests/WebhookCompressionTests.cs | 119 ++++++++++++++++++ 5 files changed, 232 insertions(+), 42 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 5f7b4cf4..6123000f 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -108,7 +108,11 @@ 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 -The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), verifies the `X-Signature` HMAC against the **uncompressed** JSON, and returns the parsed `EventResponse`. The HTTP webhook path uses a constant-time comparison so the `X-Signature` header — which is exposed on a public endpoint — is not vulnerable to timing attacks; SQS/SNS deliveries arrive over AWS-internal transports where that attack vector is not applicable. All three throw `StreamInvalidWebhookException` if the signature does not match or the envelope is malformed. +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), and returns the parsed `EventResponse`. All three throw `StreamInvalidWebhookException` if the envelope is malformed. + +The HTTP webhook helper verifies the `X-Signature` HMAC against the **uncompressed** JSON using a constant-time comparison so the header — which is exposed on a public endpoint — is not vulnerable to timing attacks. + +Stream does not ship an `X-Signature` on SQS or SNS deliveries: those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so HMAC verification on top is redundant. The SQS / SNS helpers therefore take an **optional** signature — pass it to opt in to verification, or omit it to decode-and-parse only. The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. @@ -156,18 +160,22 @@ public class StreamWebhookController : ControllerBase ### SQS / SNS firehose -When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the raw message string and the `X-Signature` attribute; the SDK reverses the base64 + optional gzip wrapping in the correct order before checking the signature. +When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the raw message string; the SDK reverses the base64 + optional gzip wrapping in the correct order and returns the parsed event. ```csharp // Inside your SQS / SNS message handler: -// messageBody = the message body string (message.Body for SQS, the inner -// Message field for SNS notifications) -// xSignature = the value of the "X-Signature" message attribute -EventResponse ev = _stream.VerifyAndParseSqs(messageBody, xSignature); -// For SNS firehose, use VerifyAndParseSns with the SNS Message field instead: -// EventResponse ev = _stream.VerifyAndParseSns(snsMessage, xSignature); +// messageBody = the SQS Body string +// envelopeBody = either the full SNS HTTP POST body, or just the inner Message field +var appClient = _stream.GetAppClient(); +EventResponse ev = appClient.VerifyAndParseSqs(messageBody); +// For SNS firehose, use VerifyAndParseSns — it accepts either the full envelope +// or a pre-extracted Message field: +// EventResponse ev = appClient.VerifyAndParseSns(envelopeBody); ``` +> [!NOTE] +> Stream does not include an `X-Signature` for SQS or SNS deliveries because those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications). HMAC verification on top is redundant in that environment, so the signature argument on `VerifyAndParseSqs` / `VerifyAndParseSns` is optional. If you do receive a signature (e.g. from a custom relay), pass it as the second argument and the SDK will verify it against the client's API secret. + ## Webhook types In addition to the above there are 3 special webhooks. diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index de59a9cd..724eb95f 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -84,10 +84,14 @@ public bool VerifyWebhook(string requestBody, string xSignature) public EventResponse VerifyAndParseWebhook(byte[] body, string signature) => WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret); - public EventResponse VerifyAndParseSqs(string messageBody, string signature) - => WebhookHelpers.VerifyAndParseSqs(messageBody, signature, _apiSecret); - - public EventResponse VerifyAndParseSns(string message, string signature) - => WebhookHelpers.VerifyAndParseSns(message, signature, _apiSecret); + public EventResponse VerifyAndParseSqs(string messageBody, string signature = null) + => signature == null + ? WebhookHelpers.VerifyAndParseSqs(messageBody) + : WebhookHelpers.VerifyAndParseSqs(messageBody, signature, _apiSecret); + + public EventResponse VerifyAndParseSns(string notificationBody, string signature = null) + => signature == null + ? WebhookHelpers.VerifyAndParseSns(notificationBody) + : WebhookHelpers.VerifyAndParseSns(notificationBody, signature, _apiSecret); } -} \ No newline at end of file +} diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index a39519d9..9c4b5de9 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -96,31 +96,39 @@ public interface IAppClient /// Verify and parse an SQS firehose webhook event. /// /// - /// Reverses the base64 (+ optional gzip) wrapping on the SQS Body, - /// verifies the X-Signature message attribute against the - /// client's API secret, and returns the parsed . + /// Reverses the base64 (+ optional gzip) wrapping on the SQS Body + /// and returns the parsed . Stream does not + /// ship an X-Signature on SQS deliveries — those transports ride + /// AWS-internal infrastructure (IAM-authenticated queues), so HMAC + /// verification on top is optional. Pass + /// to opt in to verification against the client's API secret; omit it + /// (or pass null) to decode-and-parse only. /// /// SQS message Body string. - /// Value of the X-Signature message attribute. + /// Optional X-Signature message attribute. When null, signature verification is skipped. /// /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// - EventResponse VerifyAndParseSqs(string messageBody, string signature); + EventResponse VerifyAndParseSqs(string messageBody, string signature = null); /// /// Verify and parse an SNS firehose webhook event. /// /// /// Reverses the base64 (+ optional gzip) wrapping on the SNS notification - /// Message, verifies the X-Signature message attribute - /// against the client's API secret, and returns the parsed - /// . + /// (full envelope or pre-extracted Message field) and returns the + /// parsed . Stream does not ship an + /// X-Signature on SNS deliveries — those transports ride + /// AWS-internal infrastructure (AWS-signed SNS notifications), so HMAC + /// verification on top is optional. Pass + /// to opt in to verification against the client's API secret; omit it + /// (or pass null) to decode-and-parse only. /// - /// SNS notification Message field. - /// Value of the X-Signature message attribute. + /// SNS HTTP POST body, or a pre-extracted Message field. + /// Optional X-Signature message attribute. When null, signature verification is skipped. /// /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. /// - EventResponse VerifyAndParseSns(string message, string signature); + EventResponse VerifyAndParseSns(string notificationBody, string signature = null); } -} \ No newline at end of file +} diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 22d16f0f..c2578f48 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -22,6 +22,9 @@ namespace StreamChat.Clients /// public static class WebhookHelpers { + internal const string PartialSqsSnsCredentials = + "signature and secret must both be provided to verify the SQS/SNS payload"; + private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b }; /// @@ -227,32 +230,60 @@ public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, /// /// Reverses the SQS firehose envelope (base64, then optional gzip), - /// verifies the HMAC-SHA256 signature against the uncompressed JSON, - /// and returns the parsed event. + /// optionally verifies the HMAC-SHA256 signature against the + /// uncompressed JSON, and returns the parsed event. /// + /// + /// Stream does not ship an X-Signature on SQS deliveries because + /// those transports ride AWS-internal infrastructure + /// (IAM-authenticated queues), so HMAC verification on top is optional. + /// Pass both and + /// to opt in to verification; pass neither to decode-and-parse only. + /// Passing exactly one of the two throws + /// . + /// /// SQS message Body string. - /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. - /// Stream Chat API secret. + /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. Optional. + /// Stream Chat API secret. Required when is provided. /// - /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. + /// When only one of / + /// is supplied, the signature does not match, or the base64 / gzip / JSON + /// envelope is malformed. /// - public static EventResponse VerifyAndParseSqs(string messageBody, string signature, string secret) - => VerifyAndParseInternal(DecodeSqsPayload(messageBody), signature, secret); + public static EventResponse VerifyAndParseSqs(string messageBody, string signature = null, string secret = null) + { + EnsureSqsSnsCredentialsConsistent(signature, secret); + return VerifyAndParseOptional(DecodeSqsPayload(messageBody), signature, secret); + } /// /// Reverses the SNS firehose envelope (base64, then optional gzip), - /// verifies the HMAC-SHA256 signature against the uncompressed JSON, - /// and returns the parsed event. The wire format matches SQS; - /// this overload exists so call sites read naturally. + /// optionally verifies the HMAC-SHA256 signature against the + /// uncompressed JSON, and returns the parsed event. The wire format + /// matches SQS; this overload exists so call sites read naturally. /// - /// SNS notification Message field. - /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. - /// Stream Chat API secret. + /// + /// Stream does not ship an X-Signature on SNS deliveries because + /// those transports ride AWS-internal infrastructure (AWS-signed SNS + /// notifications), so HMAC verification on top is optional. Pass both + /// and to opt in + /// to verification; pass neither to decode-and-parse only. Passing + /// exactly one of the two throws + /// . + /// + /// SNS HTTP POST body, or a pre-extracted Message field. + /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. Optional. + /// Stream Chat API secret. Required when is provided. /// - /// When the signature does not match or the base64 / gzip / JSON envelope is malformed. + /// When only one of / + /// is supplied, the signature does not match, or the base64 / gzip / JSON + /// envelope is malformed. /// - public static EventResponse VerifyAndParseSns(string message, string signature, string secret) - => VerifyAndParseInternal(DecodeSnsPayload(message), signature, secret); + public static EventResponse VerifyAndParseSns(string notificationBody, string signature = null, string secret = null) + { + EnsureSqsSnsCredentialsConsistent(signature, secret); + return VerifyAndParseOptional(DecodeSnsPayload(notificationBody), signature, secret); + } private static EventResponse VerifyAndParseInternal(byte[] payload, string signature, string secret) { @@ -264,6 +295,26 @@ private static EventResponse VerifyAndParseInternal(byte[] payload, string signa return ParseEvent(payload); } + private static EventResponse VerifyAndParseOptional(byte[] payload, string signature, string secret) + { + if (!string.IsNullOrEmpty(signature) && !VerifySignature(payload, signature, secret)) + { + throw new StreamInvalidWebhookException(StreamInvalidWebhookException.SignatureMismatch); + } + + return ParseEvent(payload); + } + + private static void EnsureSqsSnsCredentialsConsistent(string signature, string secret) + { + var hasSig = !string.IsNullOrEmpty(signature); + var hasSecret = !string.IsNullOrEmpty(secret); + if (hasSig != hasSecret) + { + throw new StreamInvalidWebhookException(PartialSqsSnsCredentials); + } + } + private static bool TryHexToBytes(string hex, out byte[] result) { result = null; diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 3d008892..2d6af493 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -502,5 +502,124 @@ public void VerifyAndParseInternal_ThrowsOnInvalidJson() call.Should().Throw() .WithMessage("*invalid JSON payload*"); } + [Test] + public void VerifyAndParseSqs_WithoutSignature_Parses_Plain() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(raw); + + var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void VerifyAndParseSqs_WithoutSignature_Parses_Base64() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(raw); + + var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void VerifyAndParseSqs_WithoutSignature_Parses_Base64Gzip() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void VerifyAndParseSns_WithoutSignature_Parses_PreExtractedMessage() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = WebhookHelpers.VerifyAndParseSns(wrapped); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void VerifyAndParseSns_WithoutSignature_Parses_FullEnvelope() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var envelope = SnsEnvelope(Base64Wrap(Gzip(raw))); + + var ev = WebhookHelpers.VerifyAndParseSns(envelope); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void AppClient_VerifyAndParseSqs_WithoutSignature_Parses() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = appClient.VerifyAndParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void AppClient_VerifyAndParseSns_WithoutSignature_Parses() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var envelope = SnsEnvelope(Base64Wrap(Gzip(raw))); + + var ev = appClient.VerifyAndParseSns(envelope); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void VerifyAndParseSqs_ThrowsOnPartialCreds_SignatureOnly() + { + Action call = () => WebhookHelpers.VerifyAndParseSqs("body", "sig", null); + + call.Should().Throw() + .WithMessage("*signature and secret must both be provided*"); + } + + [Test] + public void VerifyAndParseSqs_ThrowsOnPartialCreds_SecretOnly() + { + Action call = () => WebhookHelpers.VerifyAndParseSqs("body", null, "secret"); + + call.Should().Throw() + .WithMessage("*signature and secret must both be provided*"); + } + + [Test] + public void VerifyAndParseSns_ThrowsOnPartialCreds_SignatureOnly() + { + Action call = () => WebhookHelpers.VerifyAndParseSns("body", "sig", null); + + call.Should().Throw() + .WithMessage("*signature and secret must both be provided*"); + } + + [Test] + public void VerifyAndParseSns_ThrowsOnPartialCreds_SecretOnly() + { + Action call = () => WebhookHelpers.VerifyAndParseSns("body", null, "secret"); + + call.Should().Throw() + .WithMessage("*signature and secret must both be provided*"); + } } } From 8db0618d3b00c114afd81f4743184ee20a4c7f74 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 15:07:16 +0200 Subject: [PATCH 12/13] fix(webhooks): parseSqs/ParseSns decode-only; HTTP verify via verifyAndParseWebhook; docs + tests Co-authored-by: Cursor --- .../webhooks_overview/webhooks_overview.md | 27 +- src/Clients/AppClient.cs | 16 +- src/Clients/IAppClient.cs | 39 +-- src/Clients/IStreamClientFactory.cs | 22 +- src/Clients/StreamClientFactory.cs | 8 +- src/Clients/WebhookHelpers.cs | 95 +----- .../StreamInvalidWebhookException.cs | 4 +- tests/WebhookCompressionTests.cs | 276 ++---------------- 8 files changed, 73 insertions(+), 414 deletions(-) diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 6123000f..80f4442e 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -108,19 +108,15 @@ 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 -The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `VerifyAndParseSqs`, `VerifyAndParseSns` — for the HTTP, SQS, and SNS delivery channels. Each one inflates the payload when it is gzipped (detected from the body bytes per RFC 1952, independent of `Content-Encoding`), and returns the parsed `EventResponse`. All three throw `StreamInvalidWebhookException` if the envelope is malformed. - -The HTTP webhook helper verifies the `X-Signature` HMAC against the **uncompressed** JSON using a constant-time comparison so the header — which is exposed on a public endpoint — is not vulnerable to timing attacks. - -Stream does not ship an `X-Signature` on SQS or SNS deliveries: those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so HMAC verification on top is redundant. The SQS / SNS helpers therefore take an **optional** signature — pass it to opt in to verification, or omit it to decode-and-parse only. +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `ParseSqs`, `ParseSns` — for HTTP webhooks and for SQS/SNS payloads. `VerifyAndParseWebhook` inflates the payload when gzipped (detected from the body bytes per RFC 1952), verifies the `X-Signature` HMAC against the **uncompressed** JSON using constant-time comparison, and returns `EventResponse`. **`ParseSqs`** / **`ParseSns`** decode (and for SNS, unwrap the envelope when needed) — **no application-level HMAC**. All failure modes throw `StreamInvalidWebhookException` with message constants on that type (`SignatureMismatch`, `InvalidBase64`, `GzipFailed`, `InvalidJson`). The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. You can reach the helpers in three ways: -- `IStreamClientFactory.VerifyAndParseWebhook(byte[], string)` (and the `Sqs`/`Sns` variants) — convenience wrappers on the top-level factory, useful when you only need webhook verification. -- `IAppClient.VerifyAndParseWebhook(byte[], string)` (and the `Sqs`/`Sns` variants) — the same surface scoped to the app client. -- `StreamChat.Clients.WebhookHelpers.VerifyAndParseWebhook(byte[], string, string)` — a stateless static for cases where the API secret is not stored on the factory (for example, multi-tenant servers that pick the secret per request). +- `IStreamClientFactory.VerifyAndParseWebhook` / `ParseSqs` / `ParseSns` — convenience wrappers on the top-level factory. +- `IAppClient` — the same surface scoped to the app client. +- `StreamChat.Clients.WebhookHelpers` — stateless statics when the API secret is supplied per call (`VerifyAndParseWebhook` only); **`ParseSqs` / `ParseSns`** take the message body alone. ### ASP.NET Core handler @@ -160,22 +156,13 @@ public class StreamWebhookController : ControllerBase ### SQS / SNS firehose -When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the raw message string; the SDK reverses the base64 + optional gzip wrapping in the correct order and returns the parsed event. +When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the SQS `Body` string, or the SNS notification body (full envelope or pre-extracted `Message`). Stream does **not** attach an application-level `X-Signature` on these channels. ```csharp -// Inside your SQS / SNS message handler: -// messageBody = the SQS Body string -// envelopeBody = either the full SNS HTTP POST body, or just the inner Message field -var appClient = _stream.GetAppClient(); -EventResponse ev = appClient.VerifyAndParseSqs(messageBody); -// For SNS firehose, use VerifyAndParseSns — it accepts either the full envelope -// or a pre-extracted Message field: -// EventResponse ev = appClient.VerifyAndParseSns(envelopeBody); +EventResponse ev = _stream.ParseSqs(message.Body); +// EventResponse sns = _stream.ParseSns(notificationBodyOrMessage); ``` -> [!NOTE] -> Stream does not include an `X-Signature` for SQS or SNS deliveries because those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications). HMAC verification on top is redundant in that environment, so the signature argument on `VerifyAndParseSqs` / `VerifyAndParseSns` is optional. If you do receive a signature (e.g. from a custom relay), pass it as the second argument and the SDK will verify it against the client's API secret. - ## Webhook types In addition to the above there are 3 special webhooks. diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index 724eb95f..ccfe2a30 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -84,14 +84,10 @@ public bool VerifyWebhook(string requestBody, string xSignature) public EventResponse VerifyAndParseWebhook(byte[] body, string signature) => WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret); - public EventResponse VerifyAndParseSqs(string messageBody, string signature = null) - => signature == null - ? WebhookHelpers.VerifyAndParseSqs(messageBody) - : WebhookHelpers.VerifyAndParseSqs(messageBody, signature, _apiSecret); - - public EventResponse VerifyAndParseSns(string notificationBody, string signature = null) - => signature == null - ? WebhookHelpers.VerifyAndParseSns(notificationBody) - : WebhookHelpers.VerifyAndParseSns(notificationBody, signature, _apiSecret); + public EventResponse ParseSqs(string messageBody) + => WebhookHelpers.ParseSqs(messageBody); + + public EventResponse ParseSns(string message) + => WebhookHelpers.ParseSns(message); } -} +} \ No newline at end of file diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index 9c4b5de9..52373ef9 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -93,42 +93,15 @@ public interface IAppClient EventResponse VerifyAndParseWebhook(byte[] body, string signature); /// - /// Verify and parse an SQS firehose webhook event. + /// Parse an SQS-delivered event (decode only; no HMAC). /// - /// - /// Reverses the base64 (+ optional gzip) wrapping on the SQS Body - /// and returns the parsed . Stream does not - /// ship an X-Signature on SQS deliveries — those transports ride - /// AWS-internal infrastructure (IAM-authenticated queues), so HMAC - /// verification on top is optional. Pass - /// to opt in to verification against the client's API secret; omit it - /// (or pass null) to decode-and-parse only. - /// /// SQS message Body string. - /// Optional X-Signature message attribute. When null, signature verification is skipped. - /// - /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. - /// - EventResponse VerifyAndParseSqs(string messageBody, string signature = null); + EventResponse ParseSqs(string messageBody); /// - /// Verify and parse an SNS firehose webhook event. + /// Parse an SNS-delivered event (unwrap envelope when needed; no HMAC). /// - /// - /// Reverses the base64 (+ optional gzip) wrapping on the SNS notification - /// (full envelope or pre-extracted Message field) and returns the - /// parsed . Stream does not ship an - /// X-Signature on SNS deliveries — those transports ride - /// AWS-internal infrastructure (AWS-signed SNS notifications), so HMAC - /// verification on top is optional. Pass - /// to opt in to verification against the client's API secret; omit it - /// (or pass null) to decode-and-parse only. - /// - /// SNS HTTP POST body, or a pre-extracted Message field. - /// Optional X-Signature message attribute. When null, signature verification is skipped. - /// - /// Thrown when the signature does not match or the base64 / gzip envelope is malformed. - /// - EventResponse VerifyAndParseSns(string notificationBody, string signature = null); + /// Raw SNS POST body or pre-extracted Message string. + EventResponse ParseSns(string message); } -} +} \ No newline at end of file diff --git a/src/Clients/IStreamClientFactory.cs b/src/Clients/IStreamClientFactory.cs index 95e47bae..ec3d9064 100644 --- a/src/Clients/IStreamClientFactory.cs +++ b/src/Clients/IStreamClientFactory.cs @@ -27,25 +27,11 @@ public interface IStreamClientFactory /// Value of the X-Signature header. EventResponse VerifyAndParseWebhook(byte[] body, string signature); - /// - /// Verify and parse an SQS firehose webhook event using this factory's API secret. - /// - /// - /// Convenience wrapper around . - /// - /// SQS message Body string. - /// Value of the X-Signature message attribute. - EventResponse VerifyAndParseSqs(string messageBody, string signature); + /// + EventResponse ParseSqs(string messageBody); - /// - /// Verify and parse an SNS firehose webhook event using this factory's API secret. - /// - /// - /// Convenience wrapper around . - /// - /// SNS notification Message field. - /// Value of the X-Signature message attribute. - EventResponse VerifyAndParseSns(string message, string signature); + /// + EventResponse ParseSns(string message); /// /// Returns an instance. The returned client can be used as a singleton in your application. diff --git a/src/Clients/StreamClientFactory.cs b/src/Clients/StreamClientFactory.cs index 975c59dd..cbfe7af9 100644 --- a/src/Clients/StreamClientFactory.cs +++ b/src/Clients/StreamClientFactory.cs @@ -105,12 +105,12 @@ public EventResponse VerifyAndParseWebhook(byte[] body, string signature) => _appClient.VerifyAndParseWebhook(body, signature); /// - public EventResponse VerifyAndParseSqs(string messageBody, string signature) - => _appClient.VerifyAndParseSqs(messageBody, signature); + public EventResponse ParseSqs(string messageBody) + => _appClient.ParseSqs(messageBody); /// - public EventResponse VerifyAndParseSns(string message, string signature) - => _appClient.VerifyAndParseSns(message, signature); + public EventResponse ParseSns(string message) + => _appClient.ParseSns(message); public IBlocklistClient GetBlocklistClient() => _blocklistClient; public IChannelClient GetChannelClient() => _channelClient; diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index c2578f48..2a7a5c6e 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -15,16 +15,12 @@ namespace StreamChat.Clients /// /// /// The composite functions (, - /// , ) are the - /// recommended entry points; the primitives they compose are exposed so callers - /// can build custom flows or run individual steps in isolation. Every failure - /// mode is reported through . + /// , ) are the recommended + /// entry points; the primitives they compose are exposed so callers can + /// build custom flows or run individual steps in isolation. /// public static class WebhookHelpers { - internal const string PartialSqsSnsCredentials = - "signature and secret must both be provided to verify the SQS/SNS payload"; - private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b }; /// @@ -38,7 +34,7 @@ public static class WebhookHelpers /// /// When the body starts with the gzip magic but cannot be inflated. /// - public static byte[] GunzipPayload(byte[] body) + public static byte[] UngzipPayload(byte[] body) { if (body == null) { @@ -64,10 +60,6 @@ public static byte[] GunzipPayload(byte[] body) { throw new StreamInvalidWebhookException(StreamInvalidWebhookException.GzipFailed, ex); } - catch (IOException ex) - { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.GzipFailed, ex); - } } /// @@ -97,7 +89,7 @@ public static byte[] DecodeSqsPayload(string body) throw new StreamInvalidWebhookException(StreamInvalidWebhookException.InvalidBase64, ex); } - return GunzipPayload(decoded); + return UngzipPayload(decoded); } /// @@ -226,64 +218,27 @@ public static EventResponse ParseEvent(byte[] payload) /// When the signature does not match or the gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) - => VerifyAndParseInternal(GunzipPayload(body), signature, secret); + => VerifyAndParseInternal(UngzipPayload(body), signature, secret); /// - /// Reverses the SQS firehose envelope (base64, then optional gzip), - /// optionally verifies the HMAC-SHA256 signature against the - /// uncompressed JSON, and returns the parsed event. + /// Decodes an SQS message body (base64, then optional gzip) and returns the parsed event. /// - /// - /// Stream does not ship an X-Signature on SQS deliveries because - /// those transports ride AWS-internal infrastructure - /// (IAM-authenticated queues), so HMAC verification on top is optional. - /// Pass both and - /// to opt in to verification; pass neither to decode-and-parse only. - /// Passing exactly one of the two throws - /// . - /// /// SQS message Body string. - /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. Optional. - /// Stream Chat API secret. Required when is provided. /// - /// When only one of / - /// is supplied, the signature does not match, or the base64 / gzip / JSON - /// envelope is malformed. + /// When the base64 / gzip / JSON envelope is malformed. /// - public static EventResponse VerifyAndParseSqs(string messageBody, string signature = null, string secret = null) - { - EnsureSqsSnsCredentialsConsistent(signature, secret); - return VerifyAndParseOptional(DecodeSqsPayload(messageBody), signature, secret); - } + public static EventResponse ParseSqs(string messageBody) + => ParseEvent(DecodeSqsPayload(messageBody)); /// - /// Reverses the SNS firehose envelope (base64, then optional gzip), - /// optionally verifies the HMAC-SHA256 signature against the - /// uncompressed JSON, and returns the parsed event. The wire format - /// matches SQS; this overload exists so call sites read naturally. + /// Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). /// - /// - /// Stream does not ship an X-Signature on SNS deliveries because - /// those transports ride AWS-internal infrastructure (AWS-signed SNS - /// notifications), so HMAC verification on top is optional. Pass both - /// and to opt in - /// to verification; pass neither to decode-and-parse only. Passing - /// exactly one of the two throws - /// . - /// - /// SNS HTTP POST body, or a pre-extracted Message field. - /// Hex-encoded HMAC-SHA256 from the X-Signature message attribute. Optional. - /// Stream Chat API secret. Required when is provided. + /// SNS HTTP POST body, or a pre-extracted Message string. /// - /// When only one of / - /// is supplied, the signature does not match, or the base64 / gzip / JSON - /// envelope is malformed. + /// When the base64 / gzip / JSON envelope is malformed. /// - public static EventResponse VerifyAndParseSns(string notificationBody, string signature = null, string secret = null) - { - EnsureSqsSnsCredentialsConsistent(signature, secret); - return VerifyAndParseOptional(DecodeSnsPayload(notificationBody), signature, secret); - } + public static EventResponse ParseSns(string notificationBody) + => ParseEvent(DecodeSnsPayload(notificationBody)); private static EventResponse VerifyAndParseInternal(byte[] payload, string signature, string secret) { @@ -295,26 +250,6 @@ private static EventResponse VerifyAndParseInternal(byte[] payload, string signa return ParseEvent(payload); } - private static EventResponse VerifyAndParseOptional(byte[] payload, string signature, string secret) - { - if (!string.IsNullOrEmpty(signature) && !VerifySignature(payload, signature, secret)) - { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.SignatureMismatch); - } - - return ParseEvent(payload); - } - - private static void EnsureSqsSnsCredentialsConsistent(string signature, string secret) - { - var hasSig = !string.IsNullOrEmpty(signature); - var hasSecret = !string.IsNullOrEmpty(secret); - if (hasSig != hasSecret) - { - throw new StreamInvalidWebhookException(PartialSqsSnsCredentials); - } - } - private static bool TryHexToBytes(string hex, out byte[] result) { result = null; diff --git a/src/Exceptions/StreamInvalidWebhookException.cs b/src/Exceptions/StreamInvalidWebhookException.cs index 2b785b8e..8b3134f3 100644 --- a/src/Exceptions/StreamInvalidWebhookException.cs +++ b/src/Exceptions/StreamInvalidWebhookException.cs @@ -5,7 +5,9 @@ namespace StreamChat.Exceptions /// /// Unified exception for every webhook verification and parsing failure /// surfaced by , - /// the SQS / SNS variants, the equivalent helpers on + /// , + /// , + /// the equivalent helpers on /// , and the stateless /// primitives in . A single /// exception type is thrown for every failure mode so handlers only need diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 2d6af493..6425edb0 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -106,7 +106,7 @@ public void VerifyAndParseWebhook_ThrowsOnSignatureMismatch() Action call = () => appClient.VerifyAndParseWebhook(raw, bogus); call.Should().Throw() - .WithMessage("*signature mismatch*"); + .WithMessage(StreamInvalidWebhookException.SignatureMismatch); } [Test] @@ -120,117 +120,83 @@ public void VerifyAndParseWebhook_RejectsSignatureOverCompressedBytes() Action call = () => appClient.VerifyAndParseWebhook(gzipped, sigOverCompressed); call.Should().Throw() - .WithMessage("*signature mismatch*"); + .WithMessage(StreamInvalidWebhookException.SignatureMismatch); } [Test] - public void VerifyAndParseSqs_Base64Only() + public void ParseSqs_Base64Only() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var wrapped = Base64Wrap(raw); - var ev = appClient.VerifyAndParseSqs(wrapped, signature); + var ev = appClient.ParseSqs(wrapped); ev.Type.Should().Be("message.new"); } [Test] - public void VerifyAndParseSqs_Base64PlusGzip() + public void ParseSqs_Base64PlusGzip() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var wrapped = Base64Wrap(Gzip(raw)); - var ev = appClient.VerifyAndParseSqs(wrapped, signature); + var ev = appClient.ParseSqs(wrapped); ev.Type.Should().Be("message.new"); } [Test] - public void VerifyAndParseSqs_RejectsSignatureOverWrappedBytes() + public void ParseSqs_ThrowsOnInvalidBase64() { var appClient = BuildAppClient(); - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - var sigOverWrapped = HmacHex(API_SECRET, Encoding.ASCII.GetBytes(wrapped)); - Action call = () => appClient.VerifyAndParseSqs(wrapped, sigOverWrapped); + Action call = () => appClient.ParseSqs("@@@-not-base64-@@@"); call.Should().Throw() - .WithMessage("*signature mismatch*"); + .WithMessage(StreamInvalidWebhookException.InvalidBase64); } [Test] - public void VerifyAndParseSqs_ThrowsOnInvalidBase64() - { - var appClient = BuildAppClient(); - - Action call = () => appClient.VerifyAndParseSqs("@@@-not-base64-@@@", "ignored"); - - call.Should().Throw() - .WithMessage("*invalid base64 encoding*"); - } - - [Test] - public void VerifyAndParseSns_PreExtractedMessage_Base64PlusGzip() + public void ParseSns_PreExtractedMessage_Base64PlusGzip() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var wrapped = Base64Wrap(Gzip(raw)); - var ev = appClient.VerifyAndParseSns(wrapped, signature); + var ev = appClient.ParseSns(wrapped); ev.Type.Should().Be("message.new"); } [Test] - public void VerifyAndParseSns_PreExtractedMessage_MatchesSqs() + public void ParseSns_PreExtractedMessage_MatchesSqs() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var wrapped = Base64Wrap(Gzip(raw)); - var sns = appClient.VerifyAndParseSns(wrapped, signature); - var sqs = appClient.VerifyAndParseSqs(wrapped, signature); + var sns = appClient.ParseSns(wrapped); + var sqs = appClient.ParseSqs(wrapped); sns.Type.Should().Be(sqs.Type); sns.Message.Text.Should().Be(sqs.Message.Text); } [Test] - public void VerifyAndParseSns_FullEnvelope() + public void ParseSns_FullEnvelope() { var appClient = BuildAppClient(); var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var signature = HmacHex(API_SECRET, raw); var wrapped = Base64Wrap(Gzip(raw)); var envelope = SnsEnvelope(wrapped); - var ev = appClient.VerifyAndParseSns(envelope, signature); + var ev = appClient.ParseSns(envelope); ev.Type.Should().Be("message.new"); } - [Test] - public void VerifyAndParseSns_RejectsSignatureOverEnvelope() - { - var appClient = BuildAppClient(); - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - var envelope = SnsEnvelope(wrapped); - var sigOverEnvelope = HmacHex(API_SECRET, Encoding.UTF8.GetBytes(envelope)); - - Action call = () => appClient.VerifyAndParseSns(envelope, sigOverEnvelope); - - call.Should().Throw() - .WithMessage("*signature mismatch*"); - } - [Test] public void DecodeSnsPayload_UnwrapsFullEnvelope() { @@ -267,28 +233,28 @@ private static string SnsEnvelope(string innerMessage) + "}"; [Test] - public void GunzipPayload_PassthroughPlainBytes() + public void UngzipPayload_PassthroughPlainBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var output = WebhookHelpers.GunzipPayload(raw); + var output = WebhookHelpers.UngzipPayload(raw); output.Should().Equal(raw); } [Test] - public void GunzipPayload_InflatesGzipBytes() + public void UngzipPayload_InflatesGzipBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); var gzipped = Gzip(raw); - var output = WebhookHelpers.GunzipPayload(gzipped); + var output = WebhookHelpers.UngzipPayload(gzipped); output.Should().Equal(raw); } [Test] - public void GunzipPayload_ThrowsOnInvalidGzipBody() + public void UngzipPayload_ThrowsOnInvalidGzipBody() { // Valid gzip header + deflate flags + bogus payload, so the magic // check passes but inflation fails with InvalidDataException. @@ -298,20 +264,10 @@ public void GunzipPayload_ThrowsOnInvalidGzipBody() 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, }; - Action call = () => WebhookHelpers.GunzipPayload(bad); + Action call = () => WebhookHelpers.UngzipPayload(bad); call.Should().Throw() - .WithMessage("*gzip decompression failed*"); - } - - [Test] - public void GunzipPayload_HelloWorldFixture() - { - var gzipped = Convert.FromBase64String("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"); - - var output = WebhookHelpers.GunzipPayload(gzipped); - - output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); + .WithMessage(StreamInvalidWebhookException.GzipFailed); } [Test] @@ -336,22 +292,6 @@ public void DecodeSqsPayload_Base64PlusGzip() output.Should().Equal(raw); } - [Test] - public void DecodeSqsPayload_HelloWorldBase64Fixture() - { - var output = WebhookHelpers.DecodeSqsPayload("aGVsbG93b3JsZA=="); - - output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); - } - - [Test] - public void DecodeSqsPayload_HelloWorldBase64GzipFixture() - { - var output = WebhookHelpers.DecodeSqsPayload("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"); - - output.Should().Equal(Encoding.UTF8.GetBytes("helloworld")); - } - [Test] public void DecodeSnsPayload_AliasesDecodeSqsPayload() { @@ -410,7 +350,7 @@ public void ParseEvent_ThrowsOnMalformedJson() Action call = () => WebhookHelpers.ParseEvent(raw); call.Should().Throw() - .WithMessage("*invalid JSON payload*"); + .WithMessage(StreamInvalidWebhookException.InvalidJson); } [Test] @@ -428,27 +368,25 @@ public void Factory_VerifyAndParseWebhook_DelegatesToAppClient() } [Test] - public void Factory_VerifyAndParseSqs_DelegatesToAppClient() + public void Factory_ParseSqs_DelegatesToAppClient() { var factory = new StreamClientFactory(API_KEY, API_SECRET); var raw = Encoding.UTF8.GetBytes(JSON_BODY); var wrapped = Base64Wrap(Gzip(raw)); - var signature = HmacHex(API_SECRET, raw); - var ev = factory.VerifyAndParseSqs(wrapped, signature); + var ev = factory.ParseSqs(wrapped); ev.Type.Should().Be("message.new"); } [Test] - public void Factory_VerifyAndParseSns_DelegatesToAppClient() + public void Factory_ParseSns_DelegatesToAppClient() { var factory = new StreamClientFactory(API_KEY, API_SECRET); var raw = Encoding.UTF8.GetBytes(JSON_BODY); var wrapped = Base64Wrap(Gzip(raw)); - var signature = HmacHex(API_SECRET, raw); - var ev = factory.VerifyAndParseSns(wrapped, signature); + var ev = factory.ParseSns(wrapped); ev.Type.Should().Be("message.new"); } @@ -463,163 +401,5 @@ public void Factory_VerifyAndParseWebhook_RejectsMismatchedSignature() call.Should().Throw(); } - - [Test] - public void DecodeSqsPayload_ThrowsOnInvalidBase64() - { - Action call = () => WebhookHelpers.DecodeSqsPayload("@@@-not-base64-@@@"); - - call.Should().Throw() - .WithMessage("*invalid base64 encoding*"); - } - - [Test] - public void GunzipPayload_ThrowsOnCorruptGzip() - { - // Valid gzip magic + header so the magic check passes, then garbage - // so the deflate stream fails. - var bad = new byte[] - { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - }; - - Action call = () => WebhookHelpers.GunzipPayload(bad); - - call.Should().Throw() - .WithMessage("*gzip decompression failed*"); - } - - [Test] - public void VerifyAndParseInternal_ThrowsOnInvalidJson() - { - var appClient = BuildAppClient(); - var raw = Encoding.UTF8.GetBytes("not json"); - var signature = HmacHex(API_SECRET, raw); - - Action call = () => appClient.VerifyAndParseWebhook(raw, signature); - - call.Should().Throw() - .WithMessage("*invalid JSON payload*"); - } - [Test] - public void VerifyAndParseSqs_WithoutSignature_Parses_Plain() - { - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(raw); - - var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); - - ev.Type.Should().Be("message.new"); - ev.Message.Text.Should().Be("the quick brown fox"); - } - - [Test] - public void VerifyAndParseSqs_WithoutSignature_Parses_Base64() - { - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(raw); - - var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); - - ev.Type.Should().Be("message.new"); - } - - [Test] - public void VerifyAndParseSqs_WithoutSignature_Parses_Base64Gzip() - { - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - - var ev = WebhookHelpers.VerifyAndParseSqs(wrapped); - - ev.Type.Should().Be("message.new"); - ev.Message.Text.Should().Be("the quick brown fox"); - } - - [Test] - public void VerifyAndParseSns_WithoutSignature_Parses_PreExtractedMessage() - { - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - - var ev = WebhookHelpers.VerifyAndParseSns(wrapped); - - ev.Type.Should().Be("message.new"); - ev.Message.Text.Should().Be("the quick brown fox"); - } - - [Test] - public void VerifyAndParseSns_WithoutSignature_Parses_FullEnvelope() - { - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var envelope = SnsEnvelope(Base64Wrap(Gzip(raw))); - - var ev = WebhookHelpers.VerifyAndParseSns(envelope); - - ev.Type.Should().Be("message.new"); - ev.Message.Text.Should().Be("the quick brown fox"); - } - - [Test] - public void AppClient_VerifyAndParseSqs_WithoutSignature_Parses() - { - var appClient = BuildAppClient(); - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var wrapped = Base64Wrap(Gzip(raw)); - - var ev = appClient.VerifyAndParseSqs(wrapped); - - ev.Type.Should().Be("message.new"); - ev.Message.Text.Should().Be("the quick brown fox"); - } - - [Test] - public void AppClient_VerifyAndParseSns_WithoutSignature_Parses() - { - var appClient = BuildAppClient(); - var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var envelope = SnsEnvelope(Base64Wrap(Gzip(raw))); - - var ev = appClient.VerifyAndParseSns(envelope); - - ev.Type.Should().Be("message.new"); - } - - [Test] - public void VerifyAndParseSqs_ThrowsOnPartialCreds_SignatureOnly() - { - Action call = () => WebhookHelpers.VerifyAndParseSqs("body", "sig", null); - - call.Should().Throw() - .WithMessage("*signature and secret must both be provided*"); - } - - [Test] - public void VerifyAndParseSqs_ThrowsOnPartialCreds_SecretOnly() - { - Action call = () => WebhookHelpers.VerifyAndParseSqs("body", null, "secret"); - - call.Should().Throw() - .WithMessage("*signature and secret must both be provided*"); - } - - [Test] - public void VerifyAndParseSns_ThrowsOnPartialCreds_SignatureOnly() - { - Action call = () => WebhookHelpers.VerifyAndParseSns("body", "sig", null); - - call.Should().Throw() - .WithMessage("*signature and secret must both be provided*"); - } - - [Test] - public void VerifyAndParseSns_ThrowsOnPartialCreds_SecretOnly() - { - Action call = () => WebhookHelpers.VerifyAndParseSns("body", null, "secret"); - - call.Should().Throw() - .WithMessage("*signature and secret must both be provided*"); - } } } From 5e51600c756df7d646b4623797a7565a7b0afa20 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Tue, 12 May 2026 16:14:34 +0200 Subject: [PATCH 13/13] feat(webhooks): align cross-SDK contract - InvalidWebhookError + GunzipPayload - Rename StreamInvalidWebhookException to InvalidWebhookError (still extends StreamBaseException) - Rename UngzipPayload to GunzipPayload - Constants and messages unchanged: SignatureMismatch / InvalidBase64 / GzipFailed / InvalidJson --- src/Clients/IAppClient.cs | 2 +- src/Clients/WebhookHelpers.cs | 28 +++++++-------- ...ookException.cs => InvalidWebhookError.cs} | 6 ++-- tests/WebhookCompressionTests.cs | 34 +++++++++---------- 4 files changed, 35 insertions(+), 35 deletions(-) rename src/Exceptions/{StreamInvalidWebhookException.cs => InvalidWebhookError.cs} (87%) diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index 52373ef9..14b6e102 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -87,7 +87,7 @@ public interface IAppClient /// /// Raw HTTP request body bytes Stream signed. /// Value of the X-Signature header. - /// + /// /// Thrown when the signature does not match or the gzip envelope is malformed. /// EventResponse VerifyAndParseWebhook(byte[] body, string signature); diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs index 2a7a5c6e..77982f87 100644 --- a/src/Clients/WebhookHelpers.cs +++ b/src/Clients/WebhookHelpers.cs @@ -31,10 +31,10 @@ public static class WebhookHelpers /// /// Raw payload bytes; never null. /// When is null. - /// + /// /// When the body starts with the gzip magic but cannot be inflated. /// - public static byte[] UngzipPayload(byte[] body) + public static byte[] GunzipPayload(byte[] body) { if (body == null) { @@ -58,7 +58,7 @@ public static byte[] UngzipPayload(byte[] body) } catch (InvalidDataException ex) { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.GzipFailed, ex); + throw new InvalidWebhookError(InvalidWebhookError.GzipFailed, ex); } } @@ -69,7 +69,7 @@ public static byte[] UngzipPayload(byte[] body) /// /// SQS message Body string; never null. /// When is null. - /// + /// /// When the body is not valid base64 or the inner payload is malformed gzip. /// public static byte[] DecodeSqsPayload(string body) @@ -86,10 +86,10 @@ public static byte[] DecodeSqsPayload(string body) } catch (FormatException ex) { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.InvalidBase64, ex); + throw new InvalidWebhookError(InvalidWebhookError.InvalidBase64, ex); } - return UngzipPayload(decoded); + return GunzipPayload(decoded); } /// @@ -103,7 +103,7 @@ public static byte[] DecodeSqsPayload(string body) /// /// SNS HTTP POST body, or a pre-extracted Message string. /// When is null. - /// + /// /// When the extracted Message is not valid base64 or the inner payload is malformed gzip. /// public static byte[] DecodeSnsPayload(string notificationBody) @@ -187,7 +187,7 @@ public static bool VerifySignature(byte[] body, string signature, string secret) /// /// Raw UTF-8 JSON bytes. /// When is null. - /// When the JSON cannot be parsed. + /// When the JSON cannot be parsed. public static EventResponse ParseEvent(byte[] payload) { if (payload == null) @@ -202,7 +202,7 @@ public static EventResponse ParseEvent(byte[] payload) } catch (JsonException ex) { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.InvalidJson, ex); + throw new InvalidWebhookError(InvalidWebhookError.InvalidJson, ex); } } @@ -214,17 +214,17 @@ public static EventResponse ParseEvent(byte[] payload) /// Raw HTTP request body bytes Stream signed. /// Hex-encoded HMAC-SHA256 from the X-Signature header. /// Stream Chat API secret. - /// + /// /// When the signature does not match or the gzip / JSON envelope is malformed. /// public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) - => VerifyAndParseInternal(UngzipPayload(body), signature, secret); + => VerifyAndParseInternal(GunzipPayload(body), signature, secret); /// /// Decodes an SQS message body (base64, then optional gzip) and returns the parsed event. /// /// SQS message Body string. - /// + /// /// When the base64 / gzip / JSON envelope is malformed. /// public static EventResponse ParseSqs(string messageBody) @@ -234,7 +234,7 @@ public static EventResponse ParseSqs(string messageBody) /// Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). /// /// SNS HTTP POST body, or a pre-extracted Message string. - /// + /// /// When the base64 / gzip / JSON envelope is malformed. /// public static EventResponse ParseSns(string notificationBody) @@ -244,7 +244,7 @@ private static EventResponse VerifyAndParseInternal(byte[] payload, string signa { if (!VerifySignature(payload, signature, secret)) { - throw new StreamInvalidWebhookException(StreamInvalidWebhookException.SignatureMismatch); + throw new InvalidWebhookError(InvalidWebhookError.SignatureMismatch); } return ParseEvent(payload); diff --git a/src/Exceptions/StreamInvalidWebhookException.cs b/src/Exceptions/InvalidWebhookError.cs similarity index 87% rename from src/Exceptions/StreamInvalidWebhookException.cs rename to src/Exceptions/InvalidWebhookError.cs index 8b3134f3..06472f01 100644 --- a/src/Exceptions/StreamInvalidWebhookException.cs +++ b/src/Exceptions/InvalidWebhookError.cs @@ -28,18 +28,18 @@ namespace StreamChat.Exceptions #if !NETCORE [Serializable] #endif - public class StreamInvalidWebhookException : StreamBaseException + public class InvalidWebhookError : StreamBaseException { public const string SignatureMismatch = "signature mismatch"; public const string InvalidBase64 = "invalid base64 encoding"; public const string GzipFailed = "gzip decompression failed"; public const string InvalidJson = "invalid JSON payload"; - public StreamInvalidWebhookException(string message) : base(message) + public InvalidWebhookError(string message) : base(message) { } - public StreamInvalidWebhookException(string message, Exception innerException) : base(message, innerException) + public InvalidWebhookError(string message, Exception innerException) : base(message, innerException) { } } diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs index 6425edb0..219b0d53 100644 --- a/tests/WebhookCompressionTests.cs +++ b/tests/WebhookCompressionTests.cs @@ -105,8 +105,8 @@ public void VerifyAndParseWebhook_ThrowsOnSignatureMismatch() Action call = () => appClient.VerifyAndParseWebhook(raw, bogus); - call.Should().Throw() - .WithMessage(StreamInvalidWebhookException.SignatureMismatch); + call.Should().Throw() + .WithMessage(InvalidWebhookError.SignatureMismatch); } [Test] @@ -119,8 +119,8 @@ public void VerifyAndParseWebhook_RejectsSignatureOverCompressedBytes() Action call = () => appClient.VerifyAndParseWebhook(gzipped, sigOverCompressed); - call.Should().Throw() - .WithMessage(StreamInvalidWebhookException.SignatureMismatch); + call.Should().Throw() + .WithMessage(InvalidWebhookError.SignatureMismatch); } [Test] @@ -154,8 +154,8 @@ public void ParseSqs_ThrowsOnInvalidBase64() Action call = () => appClient.ParseSqs("@@@-not-base64-@@@"); - call.Should().Throw() - .WithMessage(StreamInvalidWebhookException.InvalidBase64); + call.Should().Throw() + .WithMessage(InvalidWebhookError.InvalidBase64); } [Test] @@ -233,28 +233,28 @@ private static string SnsEnvelope(string innerMessage) + "}"; [Test] - public void UngzipPayload_PassthroughPlainBytes() + public void GunzipPayload_PassthroughPlainBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); - var output = WebhookHelpers.UngzipPayload(raw); + var output = WebhookHelpers.GunzipPayload(raw); output.Should().Equal(raw); } [Test] - public void UngzipPayload_InflatesGzipBytes() + public void GunzipPayload_InflatesGzipBytes() { var raw = Encoding.UTF8.GetBytes(JSON_BODY); var gzipped = Gzip(raw); - var output = WebhookHelpers.UngzipPayload(gzipped); + var output = WebhookHelpers.GunzipPayload(gzipped); output.Should().Equal(raw); } [Test] - public void UngzipPayload_ThrowsOnInvalidGzipBody() + public void GunzipPayload_ThrowsOnInvalidGzipBody() { // Valid gzip header + deflate flags + bogus payload, so the magic // check passes but inflation fails with InvalidDataException. @@ -264,10 +264,10 @@ public void UngzipPayload_ThrowsOnInvalidGzipBody() 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, }; - Action call = () => WebhookHelpers.UngzipPayload(bad); + Action call = () => WebhookHelpers.GunzipPayload(bad); - call.Should().Throw() - .WithMessage(StreamInvalidWebhookException.GzipFailed); + call.Should().Throw() + .WithMessage(InvalidWebhookError.GzipFailed); } [Test] @@ -349,8 +349,8 @@ public void ParseEvent_ThrowsOnMalformedJson() Action call = () => WebhookHelpers.ParseEvent(raw); - call.Should().Throw() - .WithMessage(StreamInvalidWebhookException.InvalidJson); + call.Should().Throw() + .WithMessage(InvalidWebhookError.InvalidJson); } [Test] @@ -399,7 +399,7 @@ public void Factory_VerifyAndParseWebhook_RejectsMismatchedSignature() Action call = () => factory.VerifyAndParseWebhook(raw, new string('0', 64)); - call.Should().Throw(); + call.Should().Throw(); } } }