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:
Joseph Doherty
2026-05-22 05:53:44 -04:00
parent 8568f5cd85
commit 571066130b
3 changed files with 41 additions and 5 deletions

View File

@@ -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()
{