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:
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 15 |
|
||||
| Open findings | 14 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
| Severity | Critical |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs:1791` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `WriteNodeIdUnknown` calls itself unconditionally as its first statement, then sets `errors[i]`. Unbounded recursion with no base case overflows the stack. Called from all four `HistoryRead*` overrides whenever a HistoryRead targets a node whose `NodeId` cannot be resolved to a driver full reference. Any client issuing such a HistoryRead triggers an uncatchable `StackOverflowException` that terminates the process — a remotely-triggerable DoS.
|
||||
|
||||
**Recommendation:** Replace the self-call with the result-slot assignment mirroring `WriteUnsupported`/`WriteInternalError`: `results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadNodeIdUnknown };` then `errors[i] = StatusCodes.BadNodeIdUnknown;`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-22 — replaced the unconditional self-call in `WriteNodeIdUnknown` with the result-slot assignment (`results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadNodeIdUnknown }`), mirroring `WriteUnsupported`/`WriteInternalError`; the helper is now `internal` for testability. Regression test `DriverNodeManagerHistoryMappingTests.WriteNodeIdUnknown_returns_BadNodeIdUnknown_without_unbounded_recursion` runs the helper on a small-stack worker thread and asserts it returns promptly with `BadNodeIdUnknown`.
|
||||
|
||||
### Server-002
|
||||
| Field | Value |
|
||||
|
||||
@@ -1788,9 +1788,10 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
errors[i] = StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
// Internal so the test suite can pin the StatusCode without booting a server fixture.
|
||||
internal static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadNodeIdUnknown };
|
||||
errors[i] = StatusCodes.BadNodeIdUnknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -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