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.
///