CVE-2026-42348
Description
OpenTelemetry.OpAmp.Client is the OpAMP client for OpenTelemetry .NET. Prior to 0.2.0-alpha.1, when receiving responses from the OpAMP server over HTTP, the OpAMP client allocates an unbounded buffer to read all bytes from the server, with no upper-bound on the number of bytes consumed. This could cause memory exhaustion in the consuming application if the configured OpAMP server is attacker-controlled (or a network attacker can MitM the connection) and an extremely large body is returned in the response. This vulnerability is fixed in 0.2.0-alpha.1.
Affected products
1- Range: < 0.2.0-alpha.1
Patches
1bf1fad4fa298[OpAMP] Apply response size limits for oversized responses (#4116)
8 files changed · +448 −18
src/OpenTelemetry.OpAmp.Client/CHANGELOG.md+4 −1 modified@@ -8,9 +8,12 @@ * Add ability to send custom messages. ([#3809](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3809)) -* Add support for sticky HTTP connections via the `OpAMP-Instance-UID` header +* Add support for sticky HTTP connections via the `OpAMP-Instance-UID` header. ([#3830](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3830)) +* Apply response size limits for oversized OpAMP responses. + ([#4116](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4116)) + ## 0.1.0-alpha.4 Released 2026-Jan-14
src/OpenTelemetry.OpAmp.Client/Internal/FrameDispatcher.cs+12 −3 modified@@ -139,9 +139,18 @@ await this.transport.SendAsync(message, token) } catch (Exception ex) { - exceptionLogger(ex); - - this.frameBuilder.Reset(); // Reset the builder in case of failure + // Exceptions are deliberately swallowed to prevent transport errors from crashing the + // host application. The frame builder is reset so the next dispatch re-sends full state. + + // OpAmpOversizedResponseException is already logged at the transport layer with + // accurate semantics (the request was delivered; only the response was discarded). + // Suppress the generic "Failed to send" event here to avoid a misleading duplicate. + if (ex is not OpAmpOversizedResponseException) + { + exceptionLogger(ex); + } + + this.frameBuilder.Reset(); } finally {
src/OpenTelemetry.OpAmp.Client/Internal/OpAmpClientEventSource.cs+48 −0 modified@@ -14,6 +14,9 @@ internal sealed class OpAmpClientEventSource : EventSource // General events 1-499 private const int EventIdInvalidWsFrame = 1; private const int EventIdTransportCloseFailure = 2; + private const int EventIdOversizedResponseContentLength = 3; + private const int EventIdOversizedResponseBody = 4; + private const int EventIdHttpResponseReceived = 5; // Service events 500-999 private const int EventIdHeartbeatServiceStart = 500; @@ -59,6 +62,51 @@ public void TransportCloseFailure(string exception) this.WriteEvent(EventIdTransportCloseFailure, exception); } + [NonEvent] + public void OversizedResponseContentLengthReceived(long contentLengthBytes, int limitBytes) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.OversizedResponseContentLength(contentLengthBytes, limitBytes); + } + } + + [Event(EventIdOversizedResponseContentLength, Message = "OpAMP server response discarded: Content-Length ({0} bytes) exceeds the {1}-byte limit. The request was delivered but the server response was not processed.", Level = EventLevel.Warning)] + public void OversizedResponseContentLength(long contentLengthBytes, int limitBytes) + { + this.WriteEvent(EventIdOversizedResponseContentLength, contentLengthBytes, limitBytes); + } + + [NonEvent] + public void OversizedResponseBodyReceived(int minimumBytes, int limitBytes) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.OversizedResponseBody(minimumBytes, limitBytes); + } + } + + [Event(EventIdOversizedResponseBody, Message = "OpAMP server response discarded: response body is at least {0} bytes, exceeding the {1}-byte limit. The request was delivered but the server response was not processed.", Level = EventLevel.Warning)] + public void OversizedResponseBody(int minimumBytes, int limitBytes) + { + this.WriteEvent(EventIdOversizedResponseBody, minimumBytes, limitBytes); + } + + [NonEvent] + public void HttpResponseBytesReceived(int bytes) + { + if (this.IsEnabled(EventLevel.Verbose, EventKeywords.All)) + { + this.HttpResponseReceived(bytes); + } + } + + [Event(EventIdHttpResponseReceived, Message = "OpAMP HTTP response received: {0} bytes.", Level = EventLevel.Verbose)] + public void HttpResponseReceived(int bytes) + { + this.WriteEvent(EventIdHttpResponseReceived, bytes); + } + [Event(EventIdHeartbeatServiceStart, Message = "Heartbeat service started.", Level = EventLevel.Informational)] public void HeartbeatServiceStart() {
src/OpenTelemetry.OpAmp.Client/Internal/Transport/Http/PlainHttpTransport.cs+94 −9 modified@@ -5,6 +5,7 @@ using System.Net.Http; #endif +using System.Buffers; using Google.Protobuf; using OpenTelemetry.Internal; using OpenTelemetry.OpAmp.Client.Internal.Utils; @@ -42,19 +43,30 @@ public async Task SendAsync<T>(T message, CancellationToken token) byteContent.Headers.Add(HeaderContentType, "application/x-protobuf"); byteContent.Headers.Add(HeaderOpAmpInstanceUUID, this.settings.InstanceUid.ToString()); - var response = await this.httpClient - .PostAsync(this.uri, byteContent, cancellationToken: token) + using var request = new HttpRequestMessage(HttpMethod.Post, this.uri) + { + Content = byteContent, + }; + + // ResponseHeadersRead prevents HttpClient from buffering the entire response body + // before we can enforce the transport size limit. + using var response = await this.httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var responseMessage = await response.Content -#if NET - .ReadAsByteArrayAsync(token) -#else - .ReadAsByteArrayAsync() -#endif - .ConfigureAwait(false); + // Check Content-Length before reading if the header is present. + if (response.Content.Headers.ContentLength > TransportConstants.MaxMessageSize) + { + OpAmpClientEventSource.Log.OversizedResponseContentLengthReceived(response.Content.Headers.ContentLength.Value, TransportConstants.MaxMessageSize); + throw new OpAmpOversizedResponseException( + $"OpAMP server response Content-Length ({response.Content.Headers.ContentLength}) exceeds the maximum allowed size of {TransportConstants.MaxMessageSize} bytes."); + } + + // Read the response body with a size cap to prevent uncontrolled memory allocation (CWE-789). + // Content-Length can be absent or spoofed, so we enforce the limit during the read as well. + var responseMessage = await ReadBoundedResponseAsync(response, token).ConfigureAwait(false); this.processor.OnServerFrame(responseMessage.AsSequence()); } @@ -63,4 +75,77 @@ public void Dispose() { this.httpClient?.Dispose(); } + + private static async Task<byte[]> ReadBoundedResponseAsync(HttpResponseMessage response, CancellationToken token) + { + var stream = await response.Content +#if NET + .ReadAsStreamAsync(token) +#else + .ReadAsStreamAsync() +#endif + .ConfigureAwait(false); + +#if NET + await using (stream.ConfigureAwait(false)) +#else + using (stream) +#endif + { + var buffer = ArrayPool<byte>.Shared.Rent(TransportConstants.MaxMessageSize); + try + { + var totalRead = 0; + while (totalRead < TransportConstants.MaxMessageSize) + { + var bytesRead = await stream +#if NET + .ReadAsync(buffer.AsMemory(totalRead, TransportConstants.MaxMessageSize - totalRead), token) +#else + .ReadAsync(buffer, totalRead, TransportConstants.MaxMessageSize - totalRead, token) +#endif + .ConfigureAwait(false); + + if (bytesRead == 0) + { + // End of stream - copy the exact number of bytes read. + OpAmpClientEventSource.Log.HttpResponseBytesReceived(totalRead); + var result = new byte[totalRead]; + Buffer.BlockCopy(buffer, 0, result, 0, totalRead); + return result; + } + + totalRead += bytesRead; + } + + // We've read exactly MaxMessageSize bytes. Check if there's more data. + var probe = new byte[1]; + var extra = await stream +#if NET + .ReadAsync(probe.AsMemory(0, 1), token) +#else + .ReadAsync(probe, 0, 1, token) +#endif + .ConfigureAwait(false); + + if (extra > 0) + { + // + 1: we read exactly MaxMessageSize bytes and confirmed at least one more byte exists. + OpAmpClientEventSource.Log.OversizedResponseBodyReceived(TransportConstants.MaxMessageSize + 1, TransportConstants.MaxMessageSize); + throw new OpAmpOversizedResponseException( + $"OpAMP server response body exceeds the maximum allowed size of {TransportConstants.MaxMessageSize} bytes."); + } + + OpAmpClientEventSource.Log.HttpResponseBytesReceived(totalRead); + var exactResult = new byte[totalRead]; + Buffer.BlockCopy(buffer, 0, exactResult, 0, totalRead); + return exactResult; + } + finally + { + // Clear the rented buffer to avoid leaking sensitive data, then return it to the pool. + ArrayPool<byte>.Shared.Return(buffer, clearArray: true); + } + } + } }
src/OpenTelemetry.OpAmp.Client/Internal/Transport/OpAmpOversizedResponseException.cs+26 −0 added@@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.OpAmp.Client.Internal.Transport; + +/// <summary> +/// Thrown when an OpAMP server response body exceeds <see cref="TransportConstants.MaxMessageSize"/>. +/// Distinct from a send failure - the HTTP request was accepted by the server, but the client +/// deliberately discarded the oversized response to prevent uncontrolled memory allocation. +/// </summary> +internal sealed class OpAmpOversizedResponseException : InvalidOperationException +{ + public OpAmpOversizedResponseException() + { + } + + public OpAmpOversizedResponseException(string message) + : base(message) + { + } + + public OpAmpOversizedResponseException(string message, Exception innerException) + : base(message, innerException) + { + } +}
src/OpenTelemetry.OpAmp.Client/Internal/Transport/TransportConstants.cs+14 −0 added@@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.OpAmp.Client.Internal.Transport; + +internal static class TransportConstants +{ + /// <summary> + /// Maximum allowed size of a single OpAMP message received from the server (128 KB). + /// Applies to both HTTP and WebSocket transports. Responses exceeding this limit + /// are rejected to prevent uncontrolled memory allocation. + /// </summary> + public const int MaxMessageSize = 128 * 1024; +}
src/OpenTelemetry.OpAmp.Client/Internal/Transport/WebSocket/WsReceiver.cs+1 −2 modified@@ -12,7 +12,6 @@ internal sealed class WsReceiver : IDisposable { private const int RentalBufferSize = 4 * 1024; // 4 KB private const int ReceiveBufferSize = 8 * 1024; // 8 KB - private const int MaxMessageSize = 128 * 1024; // 128 KB private readonly ClientWebSocket ws; private readonly Thread receiveThread; @@ -114,7 +113,7 @@ private async Task ReceiveAsync() isClosed = true; } - if (totalCount > MaxMessageSize) + if (totalCount > TransportConstants.MaxMessageSize) { // Message too large, abort the connection. await this.ws
test/OpenTelemetry.OpAmp.Client.Tests/PlainHttpTransportTests.cs+249 −3 modified@@ -2,15 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 #if NETFRAMEWORK +using System.Diagnostics.CodeAnalysis; using System.Net.Http; #endif +using System.IO.Compression; using System.Text; using OpenTelemetry.OpAmp.Client.Internal; +using OpenTelemetry.OpAmp.Client.Internal.Transport; using OpenTelemetry.OpAmp.Client.Internal.Transport.Http; using OpenTelemetry.OpAmp.Client.Settings; using OpenTelemetry.OpAmp.Client.Tests.Mocks; using OpenTelemetry.OpAmp.Client.Tests.Tools; +using OpenTelemetry.Tests; using Xunit; namespace OpenTelemetry.OpAmp.Client.Tests; @@ -31,7 +35,7 @@ public async Task PlainHttpTransport_SendReceiveCommunication(bool useSmallPacke var frameProcessor = new FrameProcessor(); frameProcessor.Subscribe(mockListener); - var httpTransport = new PlainHttpTransport(settings, frameProcessor); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); var mockFrame = FrameGenerator.GenerateMockAgentFrame(useSmallPackets); @@ -76,7 +80,7 @@ public async Task PlainHttpTransport_UsesConfiguredHttpClientFactory() var frameProcessor = new FrameProcessor(); frameProcessor.Subscribe(mockListener); - var httpTransport = new PlainHttpTransport(settings, frameProcessor); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); var mockFrame = FrameGenerator.GenerateMockAgentFrame(false); // Act @@ -87,6 +91,248 @@ public async Task PlainHttpTransport_UsesConfiguredHttpClientFactory() Assert.Contains(serverReceivedHeaders, headers => headers["X-Custom-Header"] == "CustomValue"); } + [Fact] + public async Task PlainHttpTransport_RejectsOversizedResponse() + { + // Arrange - stand up a fake server that returns a response body larger than the 128 KB transport limit. + // SendChunked suppresses the Content-Length header so this test exercises the body-read limit, + // not the Content-Length pre-check (which has its own test below). + var oversizedBody = new byte[TransportConstants.MaxMessageSize + 1]; + using var opAmpServer = TestHttpServer.RunServer( + context => + { + context.Response.StatusCode = (int)System.Net.HttpStatusCode.OK; + context.Response.ContentType = "application/x-protobuf"; + context.Response.SendChunked = true; + context.Response.OutputStream.Write(oversizedBody, 0, oversizedBody.Length); + context.Response.Close(); + }, + out var host, + out var port); + + var settings = new OpAmpClientSettings + { + ServerUrl = new Uri($"http://{host}:{port}"), + }; + + var frameProcessor = new FrameProcessor(); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); + var mockFrame = FrameGenerator.GenerateMockAgentFrame(true); + + // Act & Assert + await Assert.ThrowsAsync<OpAmpOversizedResponseException>( + () => httpTransport.SendAsync(mockFrame.Frame, CancellationToken.None)); + } + + [Fact] +#if NETFRAMEWORK + [SuppressMessage("Security", "CA5399:Enable HttpClient certificate revocation list check", Justification = "Causes PlatformNotSupportedException at runtime on net462")] +#endif + public async Task PlainHttpTransport_RejectsOversizedCompressedResponse() + { + // Arrange - server sends a gzip-compressed response where the compressed payload is within + // MaxMessageSize (so the Content-Length pre-check is bypassed) but the decompressed body + // exceeds it. When HttpClient transparently decompresses the stream, the body-read loop + // must still enforce the limit on the decompressed bytes. + var largeBody = new byte[TransportConstants.MaxMessageSize + 1]; + + byte[] compressedBody; + using (var ms = new MemoryStream()) + { + using (var gzip = new GZipStream(ms, CompressionLevel.Optimal)) + { + gzip.Write(largeBody, 0, largeBody.Length); + } + + compressedBody = ms.ToArray(); + } + + // All-zeroes compress extremely well; assert the compressed size is within the limit so + // the test genuinely exercises the body-read path rather than the Content-Length check. + Assert.True( + compressedBody.Length < TransportConstants.MaxMessageSize, + $"Compressed body ({compressedBody.Length} bytes) must be smaller than MaxMessageSize ({TransportConstants.MaxMessageSize}) for this test to be meaningful."); + + using var opAmpServer = TestHttpServer.RunServer( + context => + { + context.Response.StatusCode = (int)System.Net.HttpStatusCode.OK; + context.Response.ContentType = "application/x-protobuf"; + context.Response.Headers["Content-Encoding"] = "gzip"; + context.Response.ContentLength64 = compressedBody.Length; + context.Response.OutputStream.Write(compressedBody, 0, compressedBody.Length); + context.Response.Close(); + }, + out var host, + out var port); + + var settings = new OpAmpClientSettings + { + ServerUrl = new Uri($"http://{host}:{port}"), + HttpClientFactory = () => + { + var handler = new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip, +#if NET + CheckCertificateRevocationList = true, +#endif + }; + return new HttpClient(handler); + }, + }; + + var frameProcessor = new FrameProcessor(); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); + var mockFrame = FrameGenerator.GenerateMockAgentFrame(true); + + // Act & Assert - the decompressed body exceeds MaxMessageSize so the body-read + // limit must fire even though the Content-Length (showing compressed size) does not. + await Assert.ThrowsAsync<OpAmpOversizedResponseException>( + () => httpTransport.SendAsync(mockFrame.Frame, CancellationToken.None)); + } + + [Fact] + public async Task PlainHttpTransport_RejectsOversizedChunkedResponseBeforeServerCompletesBody() + { + using var thresholdReached = new ManualResetEventSlim(); + using var allowServerToFinish = new ManualResetEventSlim(); + + using var opAmpServer = TestHttpServer.RunServer( + context => + { + try + { + context.Response.StatusCode = (int)System.Net.HttpStatusCode.OK; + context.Response.ContentType = "application/x-protobuf"; + context.Response.SendChunked = true; + + var chunk = new byte[4096]; + var remaining = TransportConstants.MaxMessageSize + 1; + while (remaining > 0) + { + var bytesToWrite = Math.Min(chunk.Length, remaining); + context.Response.OutputStream.Write(chunk, 0, bytesToWrite); + context.Response.OutputStream.Flush(); + remaining -= bytesToWrite; + } + + thresholdReached.Set(); + + allowServerToFinish.Wait(TimeSpan.FromSeconds(10)); + + context.Response.OutputStream.WriteByte(0); + context.Response.Close(); + } + catch (System.Net.HttpListenerException) + { + thresholdReached.Set(); + } + catch (ObjectDisposedException) + { + thresholdReached.Set(); + } + }, + out var host, + out var port); + + var settings = new OpAmpClientSettings + { + ServerUrl = new Uri($"http://{host}:{port}"), + }; + + var frameProcessor = new FrameProcessor(); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); + var mockFrame = FrameGenerator.GenerateMockAgentFrame(true); + + var sendTask = httpTransport.SendAsync(mockFrame.Frame, CancellationToken.None); + + Assert.True(thresholdReached.Wait(TimeSpan.FromSeconds(5)), "The server did not send enough bytes to exceed the transport limit."); + + try + { + var completedTask = await Task.WhenAny(sendTask, Task.Delay(TimeSpan.FromSeconds(2))); + + Assert.Same(sendTask, completedTask); + await Assert.ThrowsAsync<OpAmpOversizedResponseException>(async () => await sendTask); + } + finally + { + allowServerToFinish.Set(); + } + } + + [Fact] + public async Task PlainHttpTransport_RejectsResponseWithOversizedContentLength() + { + // Arrange - server advertises and sends a Content-Length larger than the limit. + // The Content-Length pre-check in the transport should reject this before reading the body. + var oversizedBody = new byte[TransportConstants.MaxMessageSize + 1]; + using var opAmpServer = TestHttpServer.RunServer( + context => + { + context.Response.StatusCode = (int)System.Net.HttpStatusCode.OK; + context.Response.ContentType = "application/x-protobuf"; + context.Response.ContentLength64 = oversizedBody.Length; + context.Response.OutputStream.Write(oversizedBody, 0, oversizedBody.Length); + context.Response.Close(); + }, + out var host, + out var port); + + var settings = new OpAmpClientSettings + { + ServerUrl = new Uri($"http://{host}:{port}"), + }; + + var frameProcessor = new FrameProcessor(); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); + var mockFrame = FrameGenerator.GenerateMockAgentFrame(true); + + // Act & Assert + await Assert.ThrowsAsync<OpAmpOversizedResponseException>( + () => httpTransport.SendAsync(mockFrame.Frame, CancellationToken.None)); + } + + [Fact] + public async Task PlainHttpTransport_AcceptsResponseAtExactMaxSize() + { + // Arrange - response body is exactly MaxMessageSize bytes (the boundary). + // The bounded read should accept this; only responses strictly exceeding the limit are rejected. + var body = new byte[TransportConstants.MaxMessageSize]; + using var opAmpServer = TestHttpServer.RunServer( + context => + { + context.Response.StatusCode = (int)System.Net.HttpStatusCode.OK; + context.Response.ContentType = "application/x-protobuf"; + context.Response.SendChunked = true; + context.Response.OutputStream.Write(body, 0, body.Length); + context.Response.Close(); + }, + out var host, + out var port); + + var settings = new OpAmpClientSettings + { + ServerUrl = new Uri($"http://{host}:{port}"), + }; + + var frameProcessor = new FrameProcessor(); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); + var mockFrame = FrameGenerator.GenerateMockAgentFrame(true); + + // Act - the response is exactly at the limit so it should NOT be rejected as oversized. + // The body (zeroed bytes) is not a valid ServerToAgent message, so protobuf parsing may + // throw, but the key assertion is that OpAmpOversizedResponseException is not thrown. + var ex = await Record.ExceptionAsync( + () => httpTransport.SendAsync(mockFrame.Frame, CancellationToken.None)); + + // Assert + Assert.False( + ex is OpAmpOversizedResponseException, + "A response at exactly MaxMessageSize should not be rejected as oversized."); + } + [Fact] public async Task PlainHttpTransport_SendsInstanceUUIDHeader() { @@ -104,7 +350,7 @@ public async Task PlainHttpTransport_SendsInstanceUUIDHeader() var frameProcessor = new FrameProcessor(); frameProcessor.Subscribe(mockListener); - var httpTransport = new PlainHttpTransport(settings, frameProcessor); + using var httpTransport = new PlainHttpTransport(settings, frameProcessor); var mockFrame = FrameGenerator.GenerateMockAgentFrame(false); // Act
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-w2jh-77fq-7gp8ghsaADVISORY
- github.com/open-telemetry/opentelemetry-dotnet-contrib/commit/bf1fad4fa298ff451cda0efb0ee9c7a7eb46212aghsa
- github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4116nvd
- github.com/open-telemetry/opentelemetry-dotnet-contrib/security/advisories/GHSA-w2jh-77fq-7gp8nvd
- nvd.nist.gov/vuln/detail/CVE-2026-42348ghsa
News mentions
0No linked articles in our index yet.