TcpMessageParsers: bound ReadReverseHelloMessage strings to MaxEndpointUrlLength (audit F1)

The public-API XML doc on TcpMessageParsers promises "every decoder is
deliberately allocation-bounded (limits drawn from TcpMessageLimits)".
ReadReverseHelloMessage was the only parser in the file that did not
honour that contract: both `decoder.ReadString(null)` calls fell back to
the decoder context's `MaxStringLength`, which is `ushort.MaxValue =
65535` (DefaultEncodingLimits.MaxStringLength). That is 16x larger than
`TcpMessageLimits.MaxEndpointUrlLength = 4096`, which is the customary
stack ceiling for ApplicationUri / EndpointUrl-class fields and which
ReadHelloMessage already passes explicitly.

Per call, a malformed RHE chunk could allocate up to ~128 KiB of string
data instead of the ~8 KiB the contract documents -- a 16x allocation
amplification on the public-API surface for any consumer that trusts the
documented bound (offline tooling, pcap decoders, CTT-style harnesses).

Fix: pass `TcpMessageLimits.MaxEndpointUrlLength` to both ReadString
calls in ReadReverseHelloMessage. Behaviour for valid inputs unchanged
(typical ServerUri / EndpointUrl is well under 4 KiB). The XML
`<exception>` clause updated to include BadEncodingLimitsExceeded so the
contract matches the implementation.

Added regression tests in
Tests/Opc.Ua.Core.Tests/Stack/Transport/TcpMessageParsersTests.cs:

  - ReadReverseHelloMessageOversizedServerUriThrowsBadEncodingLimitsExceeded
  - ReadReverseHelloMessageOversizedEndpointUrlThrowsBadEncodingLimitsExceeded
  - ReadReverseHelloMessageValidStringsAreAccepted (positive path)

Verified (net10.0):
  - Opc.Ua.Core.Tests TcpMessageParsersTests: 3/3 passed, 0 failed.
  - Opc.Ua.Network.Fuzz.Tests baseline preserved: 330/330 passed, 0 failed.

Audit ref: F1 in plans/security-audit (medium severity, fix-before-merge).

Out of scope: the in-channel TcpReverseConnectChannel.ProcessReverseHelloMessage
has the same unbounded read at lines 137-138. That is behaviour-parity
with the public API before this fix; aligning the in-channel path with
the new bound is a separate compat decision and is deferred to a
follow-up PR.
This commit is contained in:
Marc Schier
2026-06-11 10:31:33 +02:00
parent 608a9103ef
commit 77441468b3
2 changed files with 102 additions and 3 deletions
@@ -456,13 +456,16 @@ namespace Opc.Ua.Bindings
/// </returns>
/// <exception cref="ServiceResultException">
/// The body is malformed
/// (<see cref="StatusCodes.BadDecodingError"/>).
/// (<see cref="StatusCodes.BadDecodingError"/>) or either of the
/// length-prefixed string fields exceeds
/// <see cref="TcpMessageLimits.MaxEndpointUrlLength"/>
/// (<see cref="StatusCodes.BadEncodingLimitsExceeded"/>).
/// </exception>
public static ReverseHelloMessage ReadReverseHelloMessage(ArraySegment<byte> body)
{
using var decoder = CreateDecoder(body);
string? serverUri = decoder.ReadString(null);
string? endpointUrl = decoder.ReadString(null);
string? serverUri = decoder.ReadString(null, TcpMessageLimits.MaxEndpointUrlLength);
string? endpointUrl = decoder.ReadString(null, TcpMessageLimits.MaxEndpointUrlLength);
return new ReverseHelloMessage(serverUri, endpointUrl);
}
@@ -0,0 +1,96 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using NUnit.Framework;
using Opc.Ua.Bindings;
namespace Opc.Ua.Core.Tests.Stack.Transport
{
/// <summary>
/// Tests for the stateless UA-TCP / UA-SC chunk parsers in
/// <see cref="TcpMessageParsers"/>.
/// </summary>
[TestFixture]
[Category("TcpMessageParsersTests")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class TcpMessageParsersTests
{
[Test]
public void ReadReverseHelloMessageOversizedServerUriThrowsBadEncodingLimitsExceeded()
{
byte[] body = BuildReverseHelloBody(
serverUri: new string('A', TcpMessageLimits.MaxEndpointUrlLength + 1),
endpointUrl: "opc.tcp://localhost:4840");
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => TcpMessageParsers.ReadReverseHelloMessage(new ArraySegment<byte>(body)));
Assert.That(ex.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadEncodingLimitsExceeded));
}
[Test]
public void ReadReverseHelloMessageOversizedEndpointUrlThrowsBadEncodingLimitsExceeded()
{
byte[] body = BuildReverseHelloBody(
serverUri: "urn:test:server",
endpointUrl: new string('B', TcpMessageLimits.MaxEndpointUrlLength + 1));
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => TcpMessageParsers.ReadReverseHelloMessage(new ArraySegment<byte>(body)));
Assert.That(ex.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadEncodingLimitsExceeded));
}
[Test]
public void ReadReverseHelloMessageValidStringsAreAccepted()
{
const string serverUri = "urn:reverse:server";
const string endpointUrl = "opc.tcp://gateway.example.com:4840/UA/Server";
byte[] body = BuildReverseHelloBody(serverUri, endpointUrl);
ReverseHelloMessage decoded = TcpMessageParsers.ReadReverseHelloMessage(
new ArraySegment<byte>(body));
Assert.That(decoded.ServerUri, Is.EqualTo(serverUri));
Assert.That(decoded.EndpointUrl, Is.EqualTo(endpointUrl));
}
private static byte[] BuildReverseHelloBody(string serverUri, string endpointUrl)
{
using var encoder = new BinaryEncoder(ServiceMessageContext.CreateEmpty(null));
encoder.WriteString(null, serverUri);
encoder.WriteString(null, endpointUrl);
return encoder.CloseAndReturnBuffer();
}
}
}