From 32c984b0e0b5c8e1f912d33aee1033e05f480304 Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Tue, 10 Feb 2026 12:26:53 +0530 Subject: [PATCH 1/6] support for publish v2 for larger publish payload size --- .../EndPoint/PubSub/PublishOperation.cs | 110 ++++- src/Api/PubnubApi/Transport/Middleware.cs | 11 +- .../WhenAMessageIsPublished.cs | 403 +++++++++++++++++- 3 files changed, 500 insertions(+), 24 deletions(-) diff --git a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs index 169e1478d..def93c990 100644 --- a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs +++ b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs @@ -15,7 +15,9 @@ public class PublishOperation : PubnubCoreBase private readonly PNConfiguration config; private readonly IJsonPluggableLibrary jsonLibrary; private readonly IPubnubUnitTest unit; - + + private const int MaxPublishRequestSizeBytes = 32 * 1024; + private const int PostBodyFramingOverheadBytes = 2; private object publishContent; private string channelName = ""; private bool storeInHistory = true; @@ -412,20 +414,7 @@ private string PrepareContent(object originalMessage) private RequestParameter CreateRequestParameter() { - List urlSegments = - [ - "publish", - config.PublishKey ?? "", - config.SubscribeKey ?? "", - "0", - channelName, - "0" - ]; - if (!httpPost) - { - urlSegments.Add(PrepareContent(publishContent)); - } - + var messageContent = PrepareContent(publishContent); Dictionary requestQueryStringParams = new Dictionary(); if (userMetadata != null) @@ -463,20 +452,99 @@ private RequestParameter CreateRequestParameter() } } - var requestParam = new RequestParameter() + // Determine whether to use v2/publish endpoint and HTTP method. + var endpointInfo = ResolvePublishEndpoint(messageContent, requestQueryStringParams); + bool useV2Endpoint = endpointInfo.UseV2Endpoint; + bool usePost = endpointInfo.UsePost; + + // Build URL path segments + var pathSegments = new List(); + if (useV2Endpoint) { - RequestType = httpPost ? Constants.POST : Constants.GET, - PathSegment = urlSegments, + pathSegments.Add("v2"); + } + pathSegments.AddRange(["publish", config.PublishKey ?? "", config.SubscribeKey ?? "", "0", channelName, "0"]); + + if (!usePost) + { + pathSegments.Add(messageContent); + } + + var requestParam = new RequestParameter + { + RequestType = usePost ? Constants.POST : Constants.GET, + PathSegment = pathSegments, Query = requestQueryStringParams }; - if (httpPost) + requestParam.Headers.Add("Expect", "100-continue"); + + if (usePost) { - string postMessage = PrepareContent(publishContent); - requestParam.BodyContentString = postMessage; + requestParam.BodyContentString = messageContent; } return requestParam; } + + private PublishEndpointInfo ResolvePublishEndpoint( + string messageContent, + Dictionary queryParams) + { + int messageSizeBytes = Encoding.UTF8.GetByteCount(messageContent); + + if (httpPost) + { + bool exceedsPostLimit = messageSizeBytes >= MaxPublishRequestSizeBytes - PostBodyFramingOverheadBytes; + return new PublishEndpointInfo { UseV2Endpoint = exceedsPostLimit, UsePost = true }; + } + int urlSizeWithoutMessage = EstimateUrlSizeWithoutMessage(queryParams); + int availableForMessage = MaxPublishRequestSizeBytes - urlSizeWithoutMessage; + + if (messageSizeBytes >= availableForMessage) + { + // Message too large for URL; switch to v2/publish with POST body + return new PublishEndpointInfo { UseV2Endpoint = true, UsePost = true }; + } + + return new PublishEndpointInfo { UseV2Endpoint = false, UsePost = false }; + } + private struct PublishEndpointInfo + { + public bool UseV2Endpoint; + public bool UsePost; + } + + /// Estimates the total URL byte size excluding the message content. + /// Accounts for base URL components, path segments, user-specified query parameters, + /// and parameters injected later by the transport middleware + /// (uuid, pnsdk, requestid, instanceid, timestamp, auth, signature). + private int EstimateUrlSizeWithoutMessage(Dictionary queryParams) + { + // 155 bytes covers the fixed overhead from base URL components and + // middleware-injected query params: scheme, origin, path separators, + // uuid, pnsdk, requestid, instanceid, and timestamp. + int estimatedSize = 155; + + // Auth key adds "&auth={value}" — 5 bytes for the key portion plus the value length + if (config.AuthKey?.Length > 0) + { + estimatedSize += 5 + config.AuthKey.Length; + } + + // Secret key triggers HMAC signature: "&signature=v2.{base64}" ≈ 80 bytes + if (!string.IsNullOrEmpty(config.SecretKey)) + { + estimatedSize += 80; + } + + // Add encoded size of the user-specified query parameters + estimatedSize += UriUtil.EncodeUriComponent( + UriUtil.BuildQueryString(queryParams), + PNOperationType.PNPublishOperation, false, false, false + ).Length; + + return estimatedSize; + } private void CleanUp() { diff --git a/src/Api/PubnubApi/Transport/Middleware.cs b/src/Api/PubnubApi/Transport/Middleware.cs index 79a297941..8d7ddd14f 100644 --- a/src/Api/PubnubApi/Transport/Middleware.cs +++ b/src/Api/PubnubApi/Transport/Middleware.cs @@ -80,11 +80,13 @@ public TransportRequest PreapareTransportRequest(RequestParameter requestParamet { string signature = string.Empty; StringBuilder stringToSign = new StringBuilder(); - stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", operationType == PNOperationType.PNPublishOperation ? "GET" : requestParameter.RequestType); + stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", operationType == PNOperationType.PNPublishOperation + && !requestParameter.PathSegment.Contains("v2")? "GET": requestParameter.RequestType); stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", configuration.PublishKey); stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", pathString); stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", queryString); - if (!string.IsNullOrEmpty(requestParameter.BodyContentString) && operationType != PNOperationType.PNPublishOperation) stringToSign.Append(requestParameter.BodyContentString); + if (!string.IsNullOrEmpty(requestParameter.BodyContentString) && + !isPublishGET(requestParameter.PathSegment)) stringToSign.Append(requestParameter.BodyContentString); signature = Util.PubnubAccessManagerSign(configuration.SecretKey, stringToSign.ToString()); signature = string.Format(CultureInfo.InvariantCulture, "v2.{0}", signature.TrimEnd(new[] { '=' })); requestParameter.Query.Add("signature", signature); @@ -153,5 +155,10 @@ private long TranslateUtcDateTimeToSeconds(DateTime dotNetUTCDateTime) long timeStamp = Convert.ToInt64(timeSpan.TotalSeconds); return timeStamp; } + + private bool isPublishGET(List pathSegments) + { + return pathSegments.Contains("publish") && !pathSegments.Contains("v2"); + } } } diff --git a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs index 795eaf86e..7dd68b6bf 100644 --- a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs +++ b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Linq; using NUnit.Framework; using System.Threading; using PubnubApi; @@ -13,6 +14,10 @@ #endif using PubnubApi.Security.Crypto; using PubnubApi.Security.Crypto.Cryptors; +using WireMock.Server; +using WireMock.Matchers; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; namespace PubNubMessaging.Tests { @@ -2642,4 +2647,400 @@ await publisher.Publish().Channel(channel).Message(message).UsePOST(true).Custom publisher = null; } } + + /// + /// Tests for the v2/publish endpoint selection logic based on message payload size. + /// Verifies that the SDK correctly chooses between the regular publish endpoint and + /// the v2/publish endpoint depending on serialized message size and the UsePOST flag. + /// + /// Boundary rules under test: + /// + /// Regular publish (GET): message in URL path, total URL must stay under 32 KB. + /// Regular publish (POST): body must be less than 32*1024 − 2 = 32 766 bytes. + /// v2/publish (POST): used when regular limits are exceeded; server rejects bodies ≥ 2*1024*1024 − 2 bytes with 413. + /// + /// + [TestFixture] + public class WhenLargeMessageIsPublished + { + private WireMockServer _server; + private Pubnub _pubnub; + + private const string Channel = "test_channel"; + private const string PubKey = "demo"; + private const string SubKey = "demo"; + private const string PublishSuccessResponse = "[1,\"Sent\",\"14715278266153304\"]"; + private const string Publish413Response = + "{\"status\":413,\"service\":\"Balancer\",\"error\":true,\"message\":\"Request Entity Too Large\"}"; + + /// + /// POST body boundary for the regular publish endpoint. + /// Matches PublishOperation.MaxPublishRequestSizeBytes − PostBodyFramingOverheadBytes. + /// At or above this size the SDK must switch to v2/publish. + /// + private const int PostBodyBoundaryBytes = 32 * 1024 - 2; // 32 766 + + /// + /// Server-side body limit for v2/publish. + /// PubNub servers return HTTP 413 for payloads at or above this size. + /// + private const int TwoMbBoundaryBytes = 2 * 1024 * 1024 - 2; // 2 097 150 + + [SetUp] + public void Setup() + { + _server = WireMockServer.Start(); + SetupDefaultPublishSuccessMock(); + } + + [TearDown] + public void TearDown() + { + _pubnub?.Destroy(); + _server?.Stop(); + _server?.Dispose(); + } + + /// + /// Registers a catch-all mock that responds with a standard publish success + /// regardless of path or HTTP method. Individual tests override as needed. + /// + private void SetupDefaultPublishSuccessMock() + { + _server + .Given(WireMockRequest.Create().UsingAnyMethod()) + .RespondWith(WireMockResponse.Create() + .WithStatusCode(200) + .WithBody(PublishSuccessResponse)); + } + + private Pubnub CreatePubnub() + { + var config = new PNConfiguration(new UserId("test-uuid")) + { + PublishKey = PubKey, + SubscribeKey = SubKey, + Origin = $"localhost:{_server.Port}", + Secure = false + }; + _pubnub = new Pubnub(config); + return _pubnub; + } + + /// + /// Creates a plain ASCII string that, when JSON-serialized by Newtonsoft.Json, + /// produces exactly bytes. + /// JSON serialization of a simple string adds 2 bytes for the surrounding double-quotes. + /// + private static string CreateMessageOfSerializedSize(int targetSerializedBytes) + { + // 'a' is a single UTF-8 byte that requires no JSON escaping. + // Serialized form: "aaa...a" → (targetSerializedBytes − 2) chars + 2 quote bytes. + return new string('a', targetSerializedBytes - 2); + } + + /// + /// Expected URL path prefix for the regular publish endpoint. + /// Format: /publish/{pubKey}/{subKey}/0/{channel}/0. + /// For GET, the encoded message is appended after this prefix. + /// + private static readonly string RegularPublishPathBase = + $"/publish/{PubKey}/{SubKey}/0/{Channel}/0"; + + /// + /// Expected URL path for the v2/publish endpoint (no message in path). + /// Format: /v2/publish/{pubKey}/{subKey}/0/{channel}/0. + /// + private static readonly string V2PublishPath = + $"/v2/publish/{PubKey}/{SubKey}/0/{Channel}/0"; + + /// + /// Asserts that the last HTTP request used the regular publish endpoint + /// with the expected method, correct path structure, and no v2 prefix. + /// + private void AssertRegularPublishEndpoint(string expectedMethod) + { + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Method, Is.EqualTo(expectedMethod), + $"Expected HTTP method {expectedMethod}."); + Assert.That(entry.RequestMessage.Path, Does.StartWith(RegularPublishPathBase), + $"Path should follow the regular publish structure: {RegularPublishPathBase}"); + Assert.That(entry.RequestMessage.Path, Does.Not.StartWith("/v2/"), + "Should NOT use the /v2/publish/ endpoint."); + } + + /// + /// Asserts that the last HTTP request used the v2/publish endpoint + /// with POST method and the correct path structure. + /// + private void AssertV2PublishEndpoint() + { + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Method, Is.EqualTo("POST"), + "v2/publish always uses POST."); + Assert.That(entry.RequestMessage.Path, Is.EqualTo(V2PublishPath), + $"Path should exactly match the v2/publish structure: {V2PublishPath}"); + } + + #region Small payload — regular publish endpoint respects UsePOST flag + + [Test] + public async Task ThenSmallPayload_GetMode_UsesRegularPublishGetEndpoint() + { + // Arrange + var pubnub = CreatePubnub(); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message("Hello") + .ExecuteAsync(); + + // Assert — publish succeeded + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + Assert.That(result.Result.Timetoken, Is.GreaterThan(0), "Timetoken should be set."); + AssertRegularPublishEndpoint("GET"); + + // Assert — for GET, message is in the URL path, not in the body + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Path, Does.Contain("Hello"), + "GET publish should include the message in the URL path."); + Assert.That(entry.RequestMessage.Body, Is.Null.Or.Empty, + "GET publish should not have a request body."); + } + + [Test] + public async Task ThenSmallPayload_PostMode_UsesRegularPublishPostEndpoint() + { + // Arrange + var pubnub = CreatePubnub(); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message("Hello") + .UsePOST(true) + .ExecuteAsync(); + + // Assert — publish succeeded + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + Assert.That(result.Result.Timetoken, Is.GreaterThan(0), "Timetoken should be set."); + AssertRegularPublishEndpoint("POST"); + + // Assert — for POST, message is in the body and the path ends at /0 + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Body, Does.Contain("Hello"), + "POST publish should include the message in the request body."); + Assert.That(entry.RequestMessage.Path, Is.EqualTo(RegularPublishPathBase), + "Regular POST path should not contain the message."); + } + + #endregion + + #region 32 KB POST-body boundary — v2/publish activation + + [Test] + public async Task ThenPostBody_ExactlyAtBoundary_32766Bytes_UsesV2PublishEndpoint() + { + // Arrange — serialized message is exactly at the 32 766-byte boundary + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(PostBodyBoundaryBytes); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertV2PublishEndpoint(); + } + + [Test] + public async Task ThenPostBody_OneBelowBoundary_32765Bytes_UsesRegularPublishEndpoint() + { + // Arrange — serialized message is one byte below the boundary + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(PostBodyBoundaryBytes - 1); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertRegularPublishEndpoint("POST"); + } + + #endregion + + #region Large payload — v2/publish forced regardless of UsePOST flag + + [Test] + public async Task ThenLargePayload_GetMode_FallsBackToV2PublishPostEndpoint() + { + // Arrange — 40 KB message, well above the 32 KB URL limit; UsePOST not set + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(40_000); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .ExecuteAsync(); + + // Assert — SDK should auto-switch to v2/publish with POST + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertV2PublishEndpoint(); + } + + [Test] + public async Task ThenLargePayload_PostMode_AboveBoundary_UsesV2PublishEndpoint() + { + // Arrange — 100 bytes above the POST-body boundary, with UsePOST(true) + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(PostBodyBoundaryBytes + 100); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertV2PublishEndpoint(); + } + + [Test] + public async Task ThenLargePayload_ExplicitGetMode_StillForcedToV2PublishPost() + { + // Arrange — explicitly set UsePOST(false) with a large message. + // The SDK must override this and use v2/publish with POST. + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(40_000); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(false) + .ExecuteAsync(); + + // Assert — UsePOST(false) should be overridden for large payloads + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertV2PublishEndpoint(); + + // Assert — message is in the body, not in the URL path + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Body, Is.Not.Null.And.Not.Empty, + "v2/publish should carry the message in the request body."); + } + + #endregion + + #region Non-string payload — JSON object endpoint selection + + [Test] + public async Task ThenLargeJsonObjectPayload_GetMode_UsesV2PublishEndpoint() + { + // Arrange — a large dictionary that serializes to > 32 KB of JSON + var pubnub = CreatePubnub(); + var largeObject = new Dictionary(); + for (int i = 0; i < 500; i++) + { + largeObject[$"key_{i:D4}"] = new string('x', 100); + } + + // Act — no UsePOST, so GET mode is attempted first + var result = await pubnub.Publish() + .Channel(Channel) + .Message(largeObject) + .ExecuteAsync(); + + // Assert — large JSON object should trigger v2/publish with POST + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, "Publish should succeed."); + AssertV2PublishEndpoint(); + } + + #endregion + + #region 2 MB server-side boundary — HTTP 413 Request Entity Too Large + + [Test] + public async Task ThenPayload_Above2MBBoundary_UsesV2PublishAndServerReturns413() + { + // Arrange — override the default mock so v2/publish returns 413 + _server.Reset(); + _server + .Given(WireMockRequest.Create() + .WithPath(new WildcardMatcher("/v2/publish/*")) + .UsingPost()) + .RespondWith(WireMockResponse.Create() + .WithStatusCode(413) + .WithBody(Publish413Response)); + + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(TwoMbBoundaryBytes); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert — verify the request went to v2/publish + Assert.That(_server.LogEntries.Count(), Is.GreaterThanOrEqualTo(1), + "At least one request should have been made."); + var entry = _server.LogEntries.Last(); + Assert.That(entry.RequestMessage.Method, Is.EqualTo("POST")); + Assert.That(entry.RequestMessage.Path, Does.StartWith("/v2/publish/"), + "Payload at the 2 MB boundary should use v2/publish."); + + // Assert — verify the 413 error was surfaced through the SDK + Assert.That(result.Result, Is.Null, + "Publish should not return a result when the server rejects with 413."); + Assert.That(result.Status, Is.Not.Null, "Status should always be set."); + Assert.That(result.Status.Error, Is.True, + "Server 413 rejection should be reported as an error."); + } + + [Test] + public async Task ThenPayload_JustBelow2MBBoundary_UsesV2PublishAndSucceeds() + { + // Arrange — one byte below the 2 MB boundary; default success mock handles it + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(TwoMbBoundaryBytes - 1); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, + "Payload one byte below the 2 MB boundary should succeed."); + AssertV2PublishEndpoint(); + } + + #endregion + } } From a3970c88076599c77f2736473f7a8d14c033dead Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Mon, 16 Feb 2026 15:14:00 +0530 Subject: [PATCH 2/6] update publish v2 request to not add Header Expect:100-continue as its not needed add headers in case of POST request when header is not for content header, add such header for Http request --- src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs | 1 - src/Api/PubnubApi/Transport/HttpClientService.cs | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs index def93c990..5d035aa4e 100644 --- a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs +++ b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs @@ -476,7 +476,6 @@ private RequestParameter CreateRequestParameter() PathSegment = pathSegments, Query = requestQueryStringParams }; - requestParam.Headers.Add("Expect", "100-continue"); if (usePost) { diff --git a/src/Api/PubnubApi/Transport/HttpClientService.cs b/src/Api/PubnubApi/Transport/HttpClientService.cs index 11c39ddea..c92e6c28a 100644 --- a/src/Api/PubnubApi/Transport/HttpClientService.cs +++ b/src/Api/PubnubApi/Transport/HttpClientService.cs @@ -115,10 +115,17 @@ public async Task PostRequest(TransportRequest transportReque postData.Headers.Add(transportRequestHeader.Key, transportRequestHeader.Value); } } - HttpRequestMessage requestMessage = new HttpRequestMessage(method: HttpMethod.Post, requestUri: transportRequest.RequestUrl) { Content = postData }; + // Set Http Request header, When the header is not a payload content header. + if (transportRequest.Headers.Keys.Count > 0 && transportRequest.BodyContentBytes == null) + { + foreach (var kvp in transportRequest.Headers) + { + requestMessage.Headers.Add(kvp.Key, kvp.Value); + } + } logger?.Debug( $"HttpClient Service:Sending http request {transportRequest.RequestType} to {transportRequest.RequestUrl}" + (requestMessage.Headers.Any() From 78e7f971a888b7183d9341ab97beacd15b9f9e93 Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Tue, 17 Feb 2026 13:30:49 +0530 Subject: [PATCH 3/6] Codacy reported suggestion about code format. --- src/Api/PubnubApi/Transport/Middleware.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Api/PubnubApi/Transport/Middleware.cs b/src/Api/PubnubApi/Transport/Middleware.cs index 8d7ddd14f..60f42c813 100644 --- a/src/Api/PubnubApi/Transport/Middleware.cs +++ b/src/Api/PubnubApi/Transport/Middleware.cs @@ -85,8 +85,11 @@ public TransportRequest PreapareTransportRequest(RequestParameter requestParamet stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", configuration.PublishKey); stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", pathString); stringToSign.AppendFormat(CultureInfo.InvariantCulture, "{0}\n", queryString); - if (!string.IsNullOrEmpty(requestParameter.BodyContentString) && - !isPublishGET(requestParameter.PathSegment)) stringToSign.Append(requestParameter.BodyContentString); + if (!string.IsNullOrEmpty(requestParameter.BodyContentString) && + !isPublishGET(requestParameter.PathSegment)) + { + stringToSign.Append(requestParameter.BodyContentString); + } signature = Util.PubnubAccessManagerSign(configuration.SecretKey, stringToSign.ToString()); signature = string.Format(CultureInfo.InvariantCulture, "v2.{0}", signature.TrimEnd(new[] { '=' })); requestParameter.Query.Add("signature", signature); From 92e68d031276424edfdb78b8c65d137a1b9d4f1f Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Tue, 24 Feb 2026 00:04:24 +0530 Subject: [PATCH 4/6] optional header added for publish v2. new tests added for message size exceeding 2MiB verification at parameter validation level. increase request timeout default value for non subscribe http request so that history api can have more time to download the content --- .../EndPoint/PubSub/PublishOperation.cs | 28 +++- src/Api/PubnubApi/PNConfiguration.cs | 4 +- .../WhenAMessageIsPublished.cs | 156 +++++++++++++++++- 3 files changed, 178 insertions(+), 10 deletions(-) diff --git a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs index 5d035aa4e..fb6cf3472 100644 --- a/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs +++ b/src/Api/PubnubApi/EndPoint/PubSub/PublishOperation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Threading; @@ -16,9 +16,11 @@ public class PublishOperation : PubnubCoreBase private readonly IJsonPluggableLibrary jsonLibrary; private readonly IPubnubUnitTest unit; - private const int MaxPublishRequestSizeBytes = 32 * 1024; + private const int MaxPublishRequestSizeBytes = 32768; + private const int MaxMessageContentSizeBytes = 2097152; private const int PostBodyFramingOverheadBytes = 2; private object publishContent; + private string preparedMessageContent; private string channelName = ""; private bool storeInHistory = true; private bool httpPost; @@ -41,6 +43,7 @@ public PublishOperation(PNConfiguration pubnubConfig, IJsonPluggableLibrary json public PublishOperation Message(object message) { publishContent = message; + preparedMessageContent = PrepareContent(message); return this; } @@ -114,6 +117,8 @@ public void Execute(PNCallback callback) throw new ArgumentException("Missing userCallback"); } + ValidateMessageContentSize(); + savedCallback = callback; logger?.Trace($"{GetType().Name} Execute invoked"); Publish(channelName, publishContent, storeInHistory, ttl, userMetadata, queryParam, callback); @@ -121,6 +126,7 @@ public void Execute(PNCallback callback) public async Task> ExecuteAsync() { + ValidateMessageContentSize(); syncRequest = false; logger?.Trace($"{GetType().Name} ExecuteAsync invoked."); return await Publish(channelName, publishContent, storeInHistory, ttl, userMetadata, queryParam) @@ -139,6 +145,8 @@ public PNPublishResult Sync() throw new MissingMemberException("publish key is required"); } + ValidateMessageContentSize(); + logger?.Trace($"{GetType().Name} parameter validated."); ManualResetEvent syncEvent = new ManualResetEvent(false); Task task = Task.Factory.StartNew(() => @@ -398,6 +406,15 @@ internal void CurrentPubnubInstance(Pubnub instance) } } + private void ValidateMessageContentSize() + { + if (preparedMessageContent != null + && Encoding.UTF8.GetByteCount(preparedMessageContent) > MaxMessageContentSizeBytes) + { + throw new ArgumentException("Message content size exceeds the maximum permissible size of 2 MiB."); + } + } + private string PrepareContent(object originalMessage) { string message = jsonLibrary.SerializeToJsonString(originalMessage); @@ -414,7 +431,7 @@ private string PrepareContent(object originalMessage) private RequestParameter CreateRequestParameter() { - var messageContent = PrepareContent(publishContent); + var messageContent = preparedMessageContent; Dictionary requestQueryStringParams = new Dictionary(); if (userMetadata != null) @@ -477,6 +494,11 @@ private RequestParameter CreateRequestParameter() Query = requestQueryStringParams }; + if (useV2Endpoint) + { + requestParam.Headers.Add("Expect", "100-continue"); + } + if (usePost) { requestParam.BodyContentString = messageContent; diff --git a/src/Api/PubnubApi/PNConfiguration.cs b/src/Api/PubnubApi/PNConfiguration.cs index cb7078bdb..5869704a2 100644 --- a/src/Api/PubnubApi/PNConfiguration.cs +++ b/src/Api/PubnubApi/PNConfiguration.cs @@ -198,7 +198,7 @@ public int SubscribeTimeout } } - public int NonSubscribeRequestTimeout { get; set; } = 15; + public int NonSubscribeRequestTimeout { get; set; } = 120; public PNHeartbeatNotificationOption HeartbeatNotificationOption { get; set; } @@ -266,7 +266,7 @@ private void ConstructorInit(UserId currentUserId) { Origin = "ps.pndsn.com"; presenceHeartbeatTimeout = 300; - NonSubscribeRequestTimeout = 15; + NonSubscribeRequestTimeout = 120; SubscribeTimeout = 310; LogVerbosity = PNLogVerbosity.NONE; CipherKey = ""; diff --git a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs index 7dd68b6bf..ed5f76faf 100644 --- a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs +++ b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs @@ -2658,6 +2658,9 @@ await publisher.Publish().Channel(channel).Message(message).UsePOST(true).Custom /// Regular publish (GET): message in URL path, total URL must stay under 32 KB. /// Regular publish (POST): body must be less than 32*1024 − 2 = 32 766 bytes. /// v2/publish (POST): used when regular limits are exceeded; server rejects bodies ≥ 2*1024*1024 − 2 bytes with 413. + /// Client-side validation: prepared message content (including encryption overhead) + /// exceeding 2*1024*1024 − 2 bytes throws before + /// any HTTP request is made. /// /// [TestFixture] @@ -2681,8 +2684,10 @@ public class WhenLargeMessageIsPublished private const int PostBodyBoundaryBytes = 32 * 1024 - 2; // 32 766 /// - /// Server-side body limit for v2/publish. - /// PubNub servers return HTTP 413 for payloads at or above this size. + /// Maximum permitted size for the prepared message content (2 MiB − 2 bytes). + /// The SDK throws client-side for payloads + /// strictly exceeding this value. Payloads exactly at this size are sent to + /// the server, which returns HTTP 413. /// private const int TwoMbBoundaryBytes = 2 * 1024 * 1024 - 2; // 2 097 150 @@ -2727,6 +2732,25 @@ private Pubnub CreatePubnub() return _pubnub; } + /// + /// Creates a Pubnub instance with CryptoModule configured using LegacyCryptor. + /// Encryption adds overhead (AES-CBC IV, PKCS7 padding, Base64 encoding, JSON wrapping), + /// so the final serialized payload will be larger than the original plaintext. + /// + private Pubnub CreatePubnubWithCrypto() + { + var config = new PNConfiguration(new UserId("test-uuid")) + { + PublishKey = PubKey, + SubscribeKey = SubKey, + Origin = $"localhost:{_server.Port}", + Secure = false, + CryptoModule = new CryptoModule(new LegacyCryptor("enigma"), null) + }; + _pubnub = new Pubnub(config); + return _pubnub; + } + /// /// Creates a plain ASCII string that, when JSON-serialized by Newtonsoft.Json, /// produces exactly bytes. @@ -2979,12 +3003,14 @@ public async Task ThenLargeJsonObjectPayload_GetMode_UsesV2PublishEndpoint() #endregion - #region 2 MB server-side boundary — HTTP 413 Request Entity Too Large + #region 2 MB boundary — client-side ArgumentException and server-side HTTP 413 [Test] - public async Task ThenPayload_Above2MBBoundary_UsesV2PublishAndServerReturns413() + public async Task ThenPayload_AtTwoMBBoundary_UsesV2PublishAndServerReturns413() { - // Arrange — override the default mock so v2/publish returns 413 + // Arrange — message is exactly at TwoMbBoundaryBytes (2,097,150 bytes). + // This passes the client-side validation (> check, not >=) but the server + // rejects it with 413. _server.Reset(); _server .Given(WireMockRequest.Create() @@ -3041,6 +3067,126 @@ public async Task ThenPayload_JustBelow2MBBoundary_UsesV2PublishAndSucceeds() AssertV2PublishEndpoint(); } + [Test] + public void ThenPayload_AboveTwoMBBoundary_ThrowsArgumentException() + { + // Arrange — message is one byte above the max permitted content size. + // The SDK must throw ArgumentException before making any HTTP request. + var pubnub = CreatePubnub(); + var message = CreateMessageOfSerializedSize(TwoMbBoundaryBytes + 1); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + { + await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + }); + + Assert.That(ex!.Message, Does.Contain("Message content size exceeds"), + "Exception message should describe the size violation."); + Assert.That(_server.LogEntries.Count(), Is.EqualTo(0), + "No HTTP request should be made when the message exceeds the size limit."); + } + + #endregion + + #region 2 MB server-side boundary with CryptoModule — encrypted payload near limit + + [Test] + public async Task ThenEncryptedPayload_JustBelowTwoMBBoundary_WithCryptoModule_UsesV2PublishAndSucceeds() + { + // Arrange + // + // When CryptoModule is configured, the SDK encrypts the message before publishing. + // The encryption pipeline in PrepareContent is: + // 1. JSON-serialize the plaintext → adds 2 bytes (surrounding quotes) + // 2. AES-256-CBC encrypt with PKCS7 pad → rounds up to next 16-byte block + // 3. Prepend 16-byte random IV → +16 bytes + // 4. Base64-encode the encrypted bytes → ~33 % expansion (4/3 ratio) + // 5. JSON-serialize the Base64 string → adds 2 bytes (surrounding quotes) + // + // For a plaintext of 1,572,829 ASCII characters the sizes are: + // Step 1 → 1,572,831 bytes (1,572,829 + 2) + // Step 2 → 1,572,832 bytes (1,572,831 + 1 byte PKCS7 padding) + // Step 3 → 1,572,848 bytes (1,572,832 + 16) + // Step 4 → 2,097,132 bytes (ceil(1,572,848 / 3) × 4 = 524,283 × 4) + // Step 5 → 2,097,134 bytes (2,097,132 + 2) + // + // 2,097,134 < 2,097,150 (TwoMbBoundaryBytes), so this payload fits just under + // the 2 MB − 2 server-side limit. Due to AES block alignment and Base64 step + // quantization, this is the largest achievable encrypted payload below the limit. + const int plaintextLength = 1_572_829; + + var pubnub = CreatePubnubWithCrypto(); + var message = new string('a', plaintextLength); + + // Act + var result = await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + + // Assert — publish succeeded via v2/publish + Assert.That(result.Result, Is.Not.Null, "Publish result should not be null."); + Assert.That(result.Status.Error, Is.False, + "Encrypted payload just below the 2 MB − 2 boundary should publish successfully."); + AssertV2PublishEndpoint(); + + // Assert — verify the message body is encrypted (no plaintext leakage) + var entry = _server.LogEntries.Last(); + var bodyString = entry.RequestMessage.Body; + Assert.That(bodyString, Is.Not.Null.And.Not.Empty, + "v2/publish POST body should contain the encrypted message."); + Assert.That(bodyString, Does.Not.Contain(new string('a', 100)), + "POST body should contain encrypted data, not the original plaintext."); + + // Assert — verify the encrypted payload is under the 2 MB − 2 server limit + Assert.That(System.Text.Encoding.UTF8.GetByteCount(bodyString!), + Is.LessThan(TwoMbBoundaryBytes), + "Encrypted payload byte size should be below the 2 MB − 2 server-side limit."); + } + + [Test] + public void ThenEncryptedPayload_AboveTwoMBBoundary_WithCryptoModule_ThrowsArgumentException() + { + // Arrange + // + // A plaintext of 1,572,830 ASCII chars encrypts to 2,097,154 bytes — 4 bytes + // above MaxMessageContentSizeBytes (2,097,150). The client-side validation + // must reject this before any HTTP request is made. + // + // Encryption size breakdown for 1,572,830 chars: + // JSON serialize → 1,572,832 bytes (plaintext + 2 quote bytes) + // AES-CBC + PKCS7 pad → 1,572,848 bytes (1,572,832 is a 16-byte multiple, + // so PKCS7 appends a full 16-byte block) + // Prepend 16-byte IV → 1,572,864 bytes + // Base64 encode → 2,097,152 bytes (1,572,864 / 3 × 4 = 524,288 × 4) + // JSON wrap (quotes) → 2,097,154 bytes (> 2,097,150 = TwoMbBoundaryBytes) + const int plaintextLength = 1_572_830; + + var pubnub = CreatePubnubWithCrypto(); + var message = new string('a', plaintextLength); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + { + await pubnub.Publish() + .Channel(Channel) + .Message(message) + .UsePOST(true) + .ExecuteAsync(); + }); + + Assert.That(ex!.Message, Does.Contain("Message content size exceeds"), + "Exception message should describe the size violation."); + Assert.That(_server.LogEntries.Count(), Is.EqualTo(0), + "No HTTP request should be made when the encrypted message exceeds the size limit."); + } + #endregion } } From 8c3ccb7a4139ee96a31095b1c61dc9199391906f Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Tue, 24 Feb 2026 13:11:53 +0530 Subject: [PATCH 5/6] fix test with boundry condition validation by providing calculated payload size --- src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs index ed5f76faf..764a27733 100644 --- a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs +++ b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs @@ -3073,7 +3073,7 @@ public void ThenPayload_AboveTwoMBBoundary_ThrowsArgumentException() // Arrange — message is one byte above the max permitted content size. // The SDK must throw ArgumentException before making any HTTP request. var pubnub = CreatePubnub(); - var message = CreateMessageOfSerializedSize(TwoMbBoundaryBytes + 1); + var message = CreateMessageOfSerializedSize(TwoMbBoundaryBytes + 4); // Act & Assert var ex = Assert.ThrowsAsync(async () => From e7afa9ae8e571707edd1c5793c85e92d38f20b39 Mon Sep 17 00:00:00 2001 From: Mohit Tejani Date: Tue, 24 Feb 2026 14:19:30 +0530 Subject: [PATCH 6/6] revert non subscribe http request timeout changes to original one --- src/Api/PubnubApi/PNConfiguration.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/PubnubApi/PNConfiguration.cs b/src/Api/PubnubApi/PNConfiguration.cs index 5869704a2..cb7078bdb 100644 --- a/src/Api/PubnubApi/PNConfiguration.cs +++ b/src/Api/PubnubApi/PNConfiguration.cs @@ -198,7 +198,7 @@ public int SubscribeTimeout } } - public int NonSubscribeRequestTimeout { get; set; } = 120; + public int NonSubscribeRequestTimeout { get; set; } = 15; public PNHeartbeatNotificationOption HeartbeatNotificationOption { get; set; } @@ -266,7 +266,7 @@ private void ConstructorInit(UserId currentUserId) { Origin = "ps.pndsn.com"; presenceHeartbeatTimeout = 300; - NonSubscribeRequestTimeout = 120; + NonSubscribeRequestTimeout = 15; SubscribeTimeout = 310; LogVerbosity = PNLogVerbosity.NONE; CipherKey = "";