From d422c3dae69a0700c7f08b1231538d7f8b6f47f2 Mon Sep 17 00:00:00 2001 From: MrAlaskan <1922345259@qq.com> Date: Sun, 19 Jul 2026 17:19:24 +0800 Subject: [PATCH 1/2] fix(server): enforce published HistoryUpdate operation limits --- src/Opc.Ua.Server/Server/StandardServer.cs | 37 +++++++++++-- .../HistoricalAccessTests.cs | 54 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/Opc.Ua.Server/Server/StandardServer.cs b/src/Opc.Ua.Server/Server/StandardServer.cs index e63b7e32a9..e88d21d69f 100644 --- a/src/Opc.Ua.Server/Server/StandardServer.cs +++ b/src/Opc.Ua.Server/Server/StandardServer.cs @@ -1877,10 +1877,10 @@ public override async ValueTask HistoryUpdateAsync( try { - // check only for BadNothingToDo here - // MaxNodesPerHistoryUpdateEvents & MaxNodesPerHistoryUpdateData - // must be checked in NodeManager (TODO) ValidateOperationLimits(historyUpdateDetails); + ValidateOperationLimits( + historyUpdateDetails.Count, + GetHistoryUpdateOperationLimit(historyUpdateDetails)); (ArrayOf results, ArrayOf diagnosticInfos) = await ServerInternal.NodeManager.HistoryUpdateAsync( @@ -1915,6 +1915,37 @@ await ServerInternal.NodeManager.HistoryUpdateAsync( } } + private PropertyState? GetHistoryUpdateOperationLimit( + ArrayOf historyUpdateDetails) + { + foreach (ExtensionObject details in historyUpdateDetails) + { + if (details.IsNull || !details.TryGetValue(out HistoryUpdateDetails? historyUpdateDetail)) + { + continue; + } + + Type detailsType = historyUpdateDetail!.GetType(); + if (detailsType == typeof(UpdateEventDetails) || + detailsType == typeof(DeleteEventDetails)) + { + return OperationLimits.MaxNodesPerHistoryUpdateEvents; + } + + if (detailsType == typeof(UpdateDataDetails) || + detailsType == typeof(UpdateStructureDataDetails) || + detailsType == typeof(DeleteRawModifiedDetails) || + detailsType == typeof(DeleteAtTimeDetails)) + { + return OperationLimits.MaxNodesPerHistoryUpdateData; + } + + break; + } + + return null; + } + /// public override async ValueTask CreateSubscriptionAsync( SecureChannelContext secureChannelContext, diff --git a/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs b/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs index 56d050b8b6..23e0182d01 100644 --- a/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs +++ b/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs @@ -869,6 +869,60 @@ await Session.HistoryUpdateAsync( } } + [Test] + public void HistoryUpdateRejectsTooManyDataOperationsAsync() + { + NodeId nodeId = ToNodeId(Constants.ScalarStaticDouble); + var details = new ExtensionObject[1001]; + + for (int i = 0; i < details.Length; i++) + { + details[i] = new ExtensionObject(new UpdateDataDetails + { + NodeId = nodeId, + PerformInsertReplace = PerformUpdateType.Insert, + UpdateValues = + [ + new DataValue( + new Variant((double)i), + StatusCodes.Good, + DateTime.UtcNow.AddSeconds(i)) + ] + }); + } + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await Session.HistoryUpdateAsync( + null, + details.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + } + + [Test] + public void HistoryUpdateRejectsTooManyEventOperationsAsync() + { + var details = new ExtensionObject[1001]; + + for (int i = 0; i < details.Length; i++) + { + details[i] = new ExtensionObject(new DeleteEventDetails + { + NodeId = ObjectIds.Server, + EventIds = [ByteString.From([(byte)(i & 0xff)])] + }); + } + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await Session.HistoryUpdateAsync( + null, + details.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + } + [Description("Verify that HistoryRead can read history for multiple nodes in a single request and returns one result per node.")] [Test] public async Task HistoryReadMultipleNodesAtOnceAsync() From 8cd7f209a62d432785ff5262d29125b447db50cf Mon Sep 17 00:00:00 2001 From: MrAlaskan <1922345259@qq.com> Date: Mon, 20 Jul 2026 11:50:07 +0800 Subject: [PATCH 2/2] Fix HistoryUpdate operation-limit regression tests --- .../HistoricalAccessTests.cs | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs b/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs index 23e0182d01..f7f2a02528 100644 --- a/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs +++ b/tests/Opc.Ua.History.Tests/HistoricalAccessTests.cs @@ -870,7 +870,7 @@ await Session.HistoryUpdateAsync( } [Test] - public void HistoryUpdateRejectsTooManyDataOperationsAsync() + public async Task HistoryUpdateRejectsTooManyDataOperationsAsync() { NodeId nodeId = ToNodeId(Constants.ScalarStaticDouble); var details = new ExtensionObject[1001]; @@ -891,17 +891,33 @@ public void HistoryUpdateRejectsTooManyDataOperationsAsync() }); } - ServiceResultException ex = Assert.ThrowsAsync( - async () => await Session.HistoryUpdateAsync( - null, - details.ToArrayOf(), - CancellationToken.None).ConfigureAwait(false)); + uint originalDataLimit = Session.OperationLimits.MaxNodesPerHistoryUpdateData; + uint originalEventLimit = Session.OperationLimits.MaxNodesPerHistoryUpdateEvents; - Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + try + { + // Disable client-side batching so the oversized request reaches + // the server as a single HistoryUpdate call. + Session.OperationLimits.MaxNodesPerHistoryUpdateData = 0; + Session.OperationLimits.MaxNodesPerHistoryUpdateEvents = 0; + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await Session.HistoryUpdateAsync( + null, + details.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + } + finally + { + Session.OperationLimits.MaxNodesPerHistoryUpdateData = originalDataLimit; + Session.OperationLimits.MaxNodesPerHistoryUpdateEvents = originalEventLimit; + } } [Test] - public void HistoryUpdateRejectsTooManyEventOperationsAsync() + public async Task HistoryUpdateRejectsTooManyEventOperationsAsync() { var details = new ExtensionObject[1001]; @@ -914,13 +930,29 @@ public void HistoryUpdateRejectsTooManyEventOperationsAsync() }); } - ServiceResultException ex = Assert.ThrowsAsync( - async () => await Session.HistoryUpdateAsync( - null, - details.ToArrayOf(), - CancellationToken.None).ConfigureAwait(false)); + uint originalDataLimit = Session.OperationLimits.MaxNodesPerHistoryUpdateData; + uint originalEventLimit = Session.OperationLimits.MaxNodesPerHistoryUpdateEvents; - Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + try + { + // Disable client-side batching so the oversized request reaches + // the server as a single HistoryUpdate call. + Session.OperationLimits.MaxNodesPerHistoryUpdateData = 0; + Session.OperationLimits.MaxNodesPerHistoryUpdateEvents = 0; + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await Session.HistoryUpdateAsync( + null, + details.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + } + finally + { + Session.OperationLimits.MaxNodesPerHistoryUpdateData = originalDataLimit; + Session.OperationLimits.MaxNodesPerHistoryUpdateEvents = originalEventLimit; + } } [Description("Verify that HistoryRead can read history for multiple nodes in a single request and returns one result per node.")]