From 54f58c583ca39626b321a48119d4391ec860c6ff Mon Sep 17 00:00:00 2001
From: Joseph Doherty
Date: Tue, 10 Nov 2026 23:54:32 -0500
Subject: [PATCH] fix(heidenhain): refuse literal credentials and
writable/mistyped tags at the save path (#803 review)
- Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain
config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_...
(DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level,
Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password
never reaches the draft DB. The driver and device modals show the reason and disable Save.
- Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit)
refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver
publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType).
The tag modal and manual-entry grid fix the access level at Read and derive the data type from
the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch.
- docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy
enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes.
Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new
DraftValidatorHeidenhainTests cases.
---
docs/drivers/Heidenhain.md | 55 +++++--
.../Validation/HeidenhainDeployChecks.cs | 13 ++
.../HeidenhainTagRules.cs | 54 +++++++
.../DeviceForms/HeidenhainDeviceForm.razor | 6 +-
.../Shared/Drivers/DeviceModal.razor | 11 +-
.../Shared/Drivers/DriverConfigModal.razor | 11 +-
.../Drivers/Forms/HeidenhainDriverForm.razor | 6 +-
.../Shared/Raw/RawManualTagEntryModal.razor | 8 +-
.../Components/Shared/Raw/RawTagModal.razor | 25 ++-
.../Uns/DriverConfigSaveGate.cs | 29 ++++
.../Uns/RawTagRowDefaults.cs | 32 ++++
.../Uns/RawTreeService.cs | 28 ++++
.../DraftValidatorHeidenhainTests.cs | 23 ++-
.../Uns/HeidenhainAuthoringTests.cs | 8 +-
.../Uns/HeidenhainSavePathTests.cs | 151 ++++++++++++++++++
15 files changed, 427 insertions(+), 33 deletions(-)
create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs
create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/DriverConfigSaveGate.cs
create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs
create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainSavePathTests.cs
diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md
index a30864d84..978088a90 100644
--- a/docs/drivers/Heidenhain.md
+++ b/docs/drivers/Heidenhain.md
@@ -16,9 +16,14 @@ Three independent layers, each sufficient on its own for its concern:
1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every
OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`).
-2. **No writable nodes.** The deploy gate `HeidenhainTagWritable` refuses a `ReadWrite` access level on any tag
- under a Heidenhain driver, so no node is ever advertised writable. A `"writable": true` TagConfig key is
- rejected by the tag reader and dropped by the typed editor.
+2. **No writable nodes.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService`
+ create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the
+ manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
+ node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
+ the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
+ address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the
+ save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older
+ (one rule, `HeidenhainTagRules`).
3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks
command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s
public surface is pinned by a reflection test) and no configuration knob that widens it.
@@ -70,12 +75,31 @@ Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**):
`Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated
only once it carries tags, so an unconfigured default `Device1` does not fault the driver.
-**Credentials.** A password field is blank, `secret:` (the node's encrypted secret store, via
-`ISecretResolver`) or `env:`. It is resolved **at each login** and never held in options or session
-state. A literal is refused by the parser, both AdminUI forms and the deploy gate `HeidenhainCredentialPersisted`
-(the message never echoes the value). Fail-closed: an absent secret / unset variable fails the login rather than
-sending the reference text. Plain LSV2 is unencrypted — reach the control over an externally managed secure
-network; there is no SSH subsystem.
+**Credentials — namespaced references only.** A password field is one of:
+
+| Form | Resolved from |
+|---|---|
+| blank | no password |
+| `secret:heidenhain/` (e.g. `secret:heidenhain/line3/inspect`; `[A-Za-z0-9._-]` segments, no `..`) | the node's encrypted secret store (`ISecretResolver`), same slash-scoped naming as `galaxy//apikey` |
+| `env:OTOPCUA_HEIDENHAIN_` (exact upper-case prefix, `NAME` = `[A-Z0-9_]+`) | the process environment |
+
+**Why namespaced:** the reference is resolved on the node (for Test Connect: on the admin node) and sent in
+**cleartext** in `A_LG` to whatever Host the author configured. An unscoped reference would let anyone who can edit
+a driver exfiltrate `env:ConnectionStrings__ConfigDb`, `env:ServerHistorian__ApiKey` or any other secret by pointing
+Host at their own machine. Anything outside the namespace is refused by **every** layer: the parser
+(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time**
+(`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path**
+(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the
+driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the
+deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value.
+
+Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset
+variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the
+connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2
+login payload cannot carry (NUL, non-Latin-1, > 247 bytes) — never fails the session: the login is not sent, the
+reads that need that role read `BadConfigurationError` (capability `ConfigurationError`), and INSPECT telemetry
+continues. Plain LSV2 is unencrypted — reach the control over an externally managed secure network; there is no
+SSH subsystem.
## Addresses (TagConfig)
@@ -134,6 +158,7 @@ At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/
| Binding type mismatch | `BadTypeMismatch` |
| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) |
| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` |
+| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up |
| Not yet acquired | `BadWaitingForInitialData` |
| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` |
@@ -150,8 +175,14 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re
**generation**; a batch returning after its session was retired can never overwrite the outage invalidation.
- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change,
so a control returning with identical readings still re-publishes Good.
-- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`). Unlike the Java base, an
- initial connect failure also retries (no manual restart).
+- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`, clamped to 100 ms–10 min).
+ The backoff resets only after a session **published a poll batch** — a control that accepts login + version and
+ then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect
+ failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off);
+ JSON-`null` config sections are normalised to defaults by the parser.
+- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
+ receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
+ disposed loop is a no-op.
- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with
`LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and
decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one
@@ -174,7 +205,7 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet
parity suites.
- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional
- `HEIDENHAIN_LSV2_PORT`, `HEIDENHAIN_LSV2_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
+ `HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
driver end-to-end status poll. Record its output as .NET commissioning evidence.
## Hardware-gated — not yet proven
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
index 0518155e8..4b134c8ba 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
@@ -30,6 +30,9 @@ public static class HeidenhainDeployChecks
/// A literal password in a Heidenhain DriverConfig/DeviceConfig.
public const string CredentialCode = "HeidenhainCredentialPersisted";
+ /// A Heidenhain tag whose Tag.DataType disagrees with the type the driver publishes.
+ public const string DataTypeCode = "HeidenhainTagDataTypeMismatch";
+
/// Invalid Heidenhain settings or endpoint.
public const string ConfigCode = "HeidenhainConfigInvalid";
@@ -56,6 +59,16 @@ public static class HeidenhainDeployChecks
tag.TagId));
}
+ foreach (var tag in tags)
+ {
+ var expected = HeidenhainTagRules.ExpectedTagDataType(tag.TagConfig);
+ if (expected is null || string.Equals(expected, tag.DataType, StringComparison.OrdinalIgnoreCase)) continue;
+ errors.Add(new(DataTypeCode,
+ $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' has data type {tag.DataType}, but the driver " +
+ $"publishes {expected} for its address — the node would advertise a type its values never carry.",
+ tag.TagId));
+ }
+
HeidenhainDriverOptions options;
try
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs
new file mode 100644
index 000000000..8e454d000
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs
@@ -0,0 +1,54 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Rules about the Tag ROW (not its TagConfig) for a Heidenhain tag: the one authority shared by every AdminUI
+/// authoring path (RawTagModal, manual entry, CSV import, browse commit — all through RawTreeService)
+/// and the deploy gate, so authoring and deploy can never disagree.
+///
+public static class HeidenhainTagRules
+{
+ ///
+ /// The Tag.DataType (OPC UA built-in type name) a Heidenhain tag must carry for the value the driver
+ /// publishes, or null when the TagConfig does not resolve (the TagConfig gate reports that).
+ ///
+ /// The TagConfig JSON.
+ /// e.g. Int32, Double, String; null when unresolvable.
+ public static string? ExpectedTagDataType(string? tagConfig) =>
+ HeidenhainTagDefinitionFactory.TryFromTagConfig("rule", "", tagConfig, out var def, out _)
+ ? OpcTypeName(def.DataType)
+ : null;
+
+ /// The OPC UA built-in type name (the Tag.DataType vocabulary) for a published data type.
+ /// The driver data type.
+ /// The type name.
+ public static string OpcTypeName(DriverDataType type) => type switch
+ {
+ DriverDataType.Boolean => "Boolean",
+ DriverDataType.Int16 => "Int16",
+ DriverDataType.Int32 => "Int32",
+ DriverDataType.Int64 => "Int64",
+ DriverDataType.Float64 => "Double",
+ DriverDataType.String => "String",
+ _ => type.ToString(),
+ };
+
+ ///
+ /// Why a Heidenhain tag row may not be saved / deployed: a writable access level (the driver is read-only),
+ /// or a Tag.DataType that disagrees with what the driver publishes for its address.
+ ///
+ /// Whether the row's access level is ReadWrite.
+ /// The row's Tag.DataType.
+ /// The row's TagConfig.
+ /// The reason, or null when the row is acceptable.
+ public static string? DescribeRowProblem(bool readWrite, string? tagDataType, string? tagConfig)
+ {
+ if (readWrite)
+ return "Heidenhain tags are read-only — set the access level to Read (the driver never writes to the control).";
+ var expected = ExpectedTagDataType(tagConfig);
+ if (expected is not null && !string.Equals(expected, tagDataType, StringComparison.OrdinalIgnoreCase))
+ return $"Data type {tagDataType} does not match what the driver publishes for this address ({expected}).";
+ return null;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
index ecaeade4b..ec8829186 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor
@@ -27,15 +27,15 @@
-
+
-
+
-
+
Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.