diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs index a08bfd67..cea89bf8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs @@ -322,7 +322,12 @@ public class ExternalSystemClient : IExternalSystemClient HttpResponseMessage response; try { - response = await client.SendAsync(request, linkedCts.Token); + // ResponseHeadersRead returns as soon as the headers are in, so the + // body is not eagerly buffered — the bounded read below streams it and + // aborts once MaxResponseBodyBytes is exceeded, keeping a hostile or + // misbehaving endpoint from inflating the active node's memory. + response = await client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -347,7 +352,8 @@ public class ExternalSystemClient : IExternalSystemClient string body; try { - body = await response.Content.ReadAsStringAsync(linkedCts.Token); + body = await ReadBodyBoundedAsync( + response.Content, system.Name, _options.MaxResponseBodyBytes, linkedCts.Token); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -431,6 +437,51 @@ public class ExternalSystemClient : IExternalSystemClient } } + /// + /// Reads an HTTP response body into a string while enforcing an upper size + /// bound. When the Content-Length header is present it fails fast; + /// otherwise the body is streamed in 16 KiB chunks and the read is aborted the + /// moment the accumulated length exceeds (cap+1 + /// pattern), so the whole oversized body is never buffered. Oversize is a + /// permanent failure — retrying will not shrink the response, and buffering it + /// into store-and-forward would defeat the cap. Cancellation + /// () is allowed to propagate so the + /// caller's ordered timeout/cancellation catch filters classify it. + /// + private static async Task ReadBodyBoundedAsync( + HttpContent content, string systemName, long maxBytes, CancellationToken token) + { + // Fast path: a declared Content-Length above the cap is rejected before a + // single body byte is read. + var declaredLength = content.Headers.ContentLength; + if (declaredLength.HasValue && declaredLength.Value > maxBytes) + { + throw new PermanentExternalSystemException( + $"Response from {systemName} exceeded the {maxBytes}-byte limit " + + $"(declared Content-Length {declaredLength.Value})."); + } + + await using var stream = await content.ReadAsStreamAsync(token); + + using var buffer = new MemoryStream(); + var chunk = new byte[16 * 1024]; + int read; + while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), token)) > 0) + { + buffer.Write(chunk, 0, read); + + // cap+1: as soon as we hold more than maxBytes we know it is oversized, + // without reading the remainder of the stream. + if (buffer.Length > maxBytes) + { + throw new PermanentExternalSystemException( + $"Response from {systemName} exceeded the {maxBytes}-byte limit."); + } + } + + return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + } + private static string Truncate(string value, int maxChars) { if (string.IsNullOrEmpty(value) || value.Length <= maxChars) diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptions.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptions.cs index 5061814c..7e0e4c75 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptions.cs @@ -10,4 +10,13 @@ public class ExternalSystemGatewayOptions /// Maximum number of concurrent HTTP connections per external system. public int MaxConcurrentConnectionsPerSystem { get; set; } = 10; + + /// + /// Upper bound (bytes) on an outbound HTTP response body the gateway will + /// buffer into memory. A response whose Content-Length (when present) + /// or streamed length exceeds this cap fails permanently — retrying will not + /// shrink it, and buffering it into store-and-forward would defeat the cap. + /// Default 10 MiB. + /// + public long MaxResponseBodyBytes { get; set; } = 10 * 1024 * 1024; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index c2913966..13c7b589 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -942,6 +942,59 @@ public class ExternalSystemClientTests } } + // ── ESG: bound outbound response buffering (MaxResponseBodyBytes) ── + + [Fact] + public async Task OversizedResponseBody_FailsPermanently_WithoutBufferingWholeBody() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; + var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; + StubResolution(system, method); + + // The endpoint returns a 2 MiB body — far above the 1 KiB cap below. + // Hand out a fresh HttpClient per CreateClient call: the client mutates + // HttpClient.Timeout before its first request, so a single shared instance + // cannot serve the two calls this test makes. + var hugeBody = new string('x', 2 * 1024 * 1024); + _httpClientFactory.CreateClient(Arg.Any()) + .Returns(_ => new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, hugeBody))); + + // A real S&F service so we can assert the oversized response is NOT buffered. + var dbName = $"EsgOversize_{Guid.NewGuid():N}"; + var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; + using var keepAlive = new SqliteConnection(connStr); + keepAlive.Open(); + var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + await storage.InitializeAsync(); + var sfOptions = new StoreAndForwardOptions + { + DefaultRetryInterval = TimeSpan.FromMinutes(10), + RetryTimerInterval = TimeSpan.FromMinutes(10), + }; + var sf = new StoreAndForwardService(storage, sfOptions, NullLogger.Instance); + + var options = new ExternalSystemGatewayOptions { MaxResponseBodyBytes = 1024 }; + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance, + storeAndForward: sf, + options: Microsoft.Extensions.Options.Options.Create(options)); + + // Sync call: oversize is a permanent failure — retrying will not shrink it. + var result = await client.CallAsync("TestAPI", "postData"); + Assert.False(result.Success); + Assert.Contains("exceeded", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase); + + // Cached call of the same shape: a permanent failure must NOT be buffered + // into S&F (buffering it would defeat the response-size cap). + var cached = await client.CachedCallAsync("TestAPI", "postData"); + Assert.False(cached.WasBuffered); + + var depth = await storage.GetBufferDepthByCategoryAsync(); + Assert.False( + depth.TryGetValue(ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0, + "An oversized (permanent-failure) response must not be buffered for retry"); + } + /// /// Test helper: mock HTTP message handler. ///