fix(server): stop WriteNodeIdUnknown infinite recursion (Server-001)
WriteNodeIdUnknown called itself unconditionally as its first statement — unbounded recursion with no base case → StackOverflowException, an uncatchable process crash reachable by any client issuing a HistoryRead on an unresolvable NodeId (remote DoS). Replace the self-call with the result-slot assignment, mirroring WriteUnsupported / WriteInternalError. The helper is now internal so the regression test can pin the StatusCode without a server fixture. Resolves code-review finding Server-001 (Critical). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -143,6 +145,39 @@ public sealed class DriverNodeManagerHistoryMappingTests
|
||||
dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteNodeIdUnknown_returns_BadNodeIdUnknown_without_unbounded_recursion()
|
||||
{
|
||||
// Regression for Server-001: WriteNodeIdUnknown previously called itself unconditionally
|
||||
// as its first statement — unbounded recursion with no base case → StackOverflowException,
|
||||
// an uncatchable crash of the whole server process. A HistoryRead targeting an
|
||||
// unresolvable NodeId reaches this helper (HistoryReadRawModified / HistoryReadProcessed /
|
||||
// HistoryReadAtTime all call it when ResolveFullRef yields null), so the bug was a
|
||||
// remotely-triggerable DoS. The helper must instead just populate the result + error
|
||||
// slots with BadNodeIdUnknown, mirroring WriteUnsupported / WriteInternalError.
|
||||
//
|
||||
// The call runs on a worker thread with a deliberately small (256 KiB) stack: if the
|
||||
// self-recursion ever returns, the StackOverflowException tears down only that thread's
|
||||
// worker rather than crashing the test host, and the join below times out instead.
|
||||
var results = new HistoryReadResultCollection { new() };
|
||||
var errors = new List<ServiceResult> { ServiceResult.Good };
|
||||
|
||||
var completed = false;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
DriverNodeManager.WriteNodeIdUnknown(results, errors, 0);
|
||||
completed = true;
|
||||
}, maxStackSize: 256 * 1024);
|
||||
worker.IsBackground = true;
|
||||
worker.Start();
|
||||
worker.Join(TimeSpan.FromSeconds(5));
|
||||
|
||||
completed.ShouldBeTrue("WriteNodeIdUnknown must return promptly, not recurse until the stack overflows");
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user