fix(security): id-form credential references match by id only; id-shaped driver names refused (#849 review)
A driver named 'drv' (env form DRV) matched every sibling's id-form variable env:<PREFIX>DRV_<12HEX>_... as 'another instance', breaking the recommended id convention fleet-wide - including for a name that pre-dates the rule. ReferenceNames is split into ReferenceNamesId / ReferenceNamesName; a reference whose instance part is id-shaped (secret: DRV-<12 hex>, env: DRV_<12 HEX>[_...]) is matched by id only. DescribeNameChangeProblemsAsync also refuses new names whose env form is DRV or starts with an id form (IsReservedInstanceName) at create, /raw rename and config-modal update. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,7 @@ public static class DriverInstanceIdentities
|
||||
/// <summary>
|
||||
/// lmxopcua#848: the problems of giving a driver the name <paramref name="newName"/> (create, or a rename via the
|
||||
/// /raw rename or the driver config modal). Two rules, both about a name being the instance part of a stored
|
||||
/// credential reference (<see cref="DriverCredentialPolicy.ReferenceNames"/>):
|
||||
/// credential reference (<see cref="DriverCredentialPolicy.ReferenceNamesName"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Claim</b> — the new name may not be named by a reference STORED on another same-type driver (its
|
||||
@@ -123,6 +123,19 @@ public static class DriverInstanceIdentities
|
||||
if (!DriverCredentialPolicy.TryGet(driverType, out var rules)) return [];
|
||||
if (currentName is not null && string.Equals(currentName, newName, StringComparison.OrdinalIgnoreCase)) return [];
|
||||
|
||||
// #849 review: a name in the system-generated id form (env form 'DRV' or 'DRV_<12 HEX>…') would shadow the id
|
||||
// convention every driver's own references are recommended to use. The matcher already ignores such a name for
|
||||
// id-shaped references (so a pre-existing one breaks nothing); this keeps new ones from being created at all.
|
||||
if (DriverCredentialPolicy.IsReservedInstanceName(newName))
|
||||
{
|
||||
return
|
||||
[
|
||||
$"The name '{newName}' has the form of a system-generated driver id ('{DriverCredentialPolicy.DriverIdPrefix}' or "
|
||||
+ $"'{DriverCredentialPolicy.DriverIdPrefix}-<12 hex>…'), which {rules.Scope.Label} credential references use to name a "
|
||||
+ "driver by its id — choose another name (lmxopcua#848/#849).",
|
||||
];
|
||||
}
|
||||
|
||||
// Same shape as LoadAsync: the type filter stays client-side (case-insensitive), the device read is a plain IN.
|
||||
var drivers = (await db.DriverInstances.AsNoTracking()
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
|
||||
@@ -154,7 +167,7 @@ public static class DriverInstanceIdentities
|
||||
var where = device is null ? "" : $" device '{device}'";
|
||||
foreach (var reference in DriverCredentialPolicy.CollectInstanceReferences(driverType, json))
|
||||
{
|
||||
if (!own && DriverCredentialPolicy.ReferenceNames(reference, newName))
|
||||
if (!own && DriverCredentialPolicy.ReferenceNamesName(reference, newName))
|
||||
{
|
||||
problems.Add(
|
||||
$"The name '{newName}' is the instance part of the credential reference {reference.FieldPath} on "
|
||||
@@ -163,9 +176,9 @@ public static class DriverInstanceIdentities
|
||||
+ "Choose another name, or move that reference first (lmxopcua#848).");
|
||||
}
|
||||
else if (own && currentName is not null
|
||||
&& DriverCredentialPolicy.ReferenceNames(reference, currentName)
|
||||
&& !DriverCredentialPolicy.ReferenceNames(reference, ownDriverInstanceId)
|
||||
&& !DriverCredentialPolicy.ReferenceNames(reference, newName))
|
||||
&& DriverCredentialPolicy.ReferenceNamesName(reference, currentName)
|
||||
&& !DriverCredentialPolicy.ReferenceNamesId(reference, ownDriverInstanceId)
|
||||
&& !DriverCredentialPolicy.ReferenceNamesName(reference, newName))
|
||||
{
|
||||
problems.Add(
|
||||
$"This driver's credential reference {reference.FieldPath}{where} is named after its current name "
|
||||
|
||||
@@ -236,7 +236,7 @@ public static class DriverCredentialPolicy
|
||||
/// <c>env:OTOPCUA_GALAXY_<REST></c> ⇒ <c>REST</c>. The per-instance convention is
|
||||
/// <c>env:<PREFIX><INSTANCE>_…</c>, INSTANCE being a driver id or name in
|
||||
/// <see cref="NormalizeEnvironmentIdentifier"/> form; the part NAMES an instance when it equals that form or starts
|
||||
/// with it followed by <c>_</c> (see <see cref="ReferenceNames"/>). Null for anything but an in-scope <c>env:</c>.
|
||||
/// with it followed by <c>_</c> (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>). Null for anything but an in-scope <c>env:</c>.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored field value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
@@ -269,15 +269,81 @@ public static class DriverCredentialPolicy
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The prefix of every system-generated driver instance id (<c>DRV-</c> + 12 hex, <c>RawTreeService.NewId</c>).</summary>
|
||||
public const string DriverIdPrefix = "DRV";
|
||||
|
||||
private const int DriverIdHexLength = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an instance reference (<see cref="CollectInstanceReferences"/>) names the driver id or name
|
||||
/// <paramref name="identifier"/>: a <c>secret:</c> segment equal to it (case-insensitive), or an <c>env:</c> part
|
||||
/// equal to its <see cref="NormalizeEnvironmentIdentifier"/> form or starting with that form plus <c>_</c>.
|
||||
/// Whether an instance reference (<see cref="CollectInstanceReferences"/>) names the driver id
|
||||
/// <paramref name="driverInstanceId"/>: a <c>secret:</c> segment equal to it (case-insensitive), or an <c>env:</c>
|
||||
/// part equal to its <see cref="NormalizeEnvironmentIdentifier"/> form or starting with that form plus <c>_</c>.
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <param name="identifier">A driver id or name.</param>
|
||||
/// <param name="driverInstanceId">A driver instance id.</param>
|
||||
/// <returns>Whether it names it.</returns>
|
||||
public static bool ReferenceNames(InstanceReference reference, string? identifier)
|
||||
public static bool ReferenceNamesId(InstanceReference reference, string? driverInstanceId) =>
|
||||
Matches(reference, driverInstanceId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether an instance reference names the driver NAME <paramref name="name"/> — as <see cref="ReferenceNamesId"/>,
|
||||
/// except that a reference whose instance part has the shape of a system-generated id
|
||||
/// (<see cref="IsIdShaped"/>: <c>secret:…/DRV-<12 hex>/…</c>, <c>env:<PREFIX>DRV_<12 HEX>[_…]</c>) is
|
||||
/// matched by id only. Otherwise a driver NAMED <c>drv</c> (env form <c>DRV</c>) would "own" the id-form variable of
|
||||
/// every other driver of its type, and the recommended id convention would break fleet-wide — including for a
|
||||
/// name that pre-dates the rule (lmxopcua#849 review).
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <param name="name">A driver name.</param>
|
||||
/// <returns>Whether it names it.</returns>
|
||||
public static bool ReferenceNamesName(InstanceReference reference, string? name) =>
|
||||
!IsIdShaped(reference) && Matches(reference, name);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reference's instance part has the shape of a system-generated driver id: a <c>secret:</c> segment
|
||||
/// <c>DRV-<12 hex></c> (case-insensitive), or an <c>env:</c> part <c>DRV_<12 HEX></c> alone or followed by <c>_</c>.
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <returns>Whether it is id-shaped.</returns>
|
||||
public static bool IsIdShaped(InstanceReference reference) =>
|
||||
reference.Kind == CredentialReferenceKind.Secret
|
||||
? reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength && StartsWithIdForm(reference.Part, '-', ignoreCase: true)
|
||||
: StartsWithIdForm(reference.Part, '_', ignoreCase: false)
|
||||
&& (reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength
|
||||
|| reference.Part[DriverIdPrefix.Length + 1 + DriverIdHexLength] == '_');
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#849 review: a driver NAME that may not be given to a credential-bearing driver — one whose environment
|
||||
/// form (<see cref="NormalizeEnvironmentIdentifier"/>) is <c>DRV</c> or starts with an id form <c>DRV_<12 HEX></c>.
|
||||
/// Such a name would collide with the system-generated id convention every driver's own references are
|
||||
/// recommended to use.
|
||||
/// </summary>
|
||||
/// <param name="name">The proposed name.</param>
|
||||
/// <returns>Whether the name is reserved.</returns>
|
||||
public static bool IsReservedInstanceName(string? name)
|
||||
{
|
||||
var env = NormalizeEnvironmentIdentifier(name);
|
||||
return env == DriverIdPrefix
|
||||
|| IsIdShaped(new InstanceReference("", CredentialReferenceKind.Environment, env));
|
||||
}
|
||||
|
||||
private static bool StartsWithIdForm(string part, char separator, bool ignoreCase)
|
||||
{
|
||||
var length = DriverIdPrefix.Length + 1 + DriverIdHexLength;
|
||||
if (part.Length < length
|
||||
|| !part.StartsWith(DriverIdPrefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
|
||||
|| part[DriverIdPrefix.Length] != separator)
|
||||
return false;
|
||||
for (var i = DriverIdPrefix.Length + 1; i < length; i++)
|
||||
{
|
||||
var c = part[i];
|
||||
if (!(char.IsAsciiDigit(c) || c is >= 'A' and <= 'F' || (ignoreCase && c is >= 'a' and <= 'f'))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Matches(InstanceReference reference, string? identifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identifier)) return false;
|
||||
if (reference.Kind == CredentialReferenceKind.Secret)
|
||||
@@ -300,7 +366,7 @@ public static class DriverCredentialPolicy
|
||||
CollectInstanceReferences(driverType, json).Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#835/#849: inside one namespace, a reference that names (see <see cref="ReferenceNames"/>) a DIFFERENT
|
||||
/// lmxopcua#835/#849: inside one namespace, a reference that names (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>) a DIFFERENT
|
||||
/// existing instance of the same driver type — by its <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its
|
||||
/// <see cref="DriverInstanceIdentity.Name"/> — is refused: otherwise a Designer could create (or Test Connect /
|
||||
/// browse) a driver that points at their own endpoint with <c>secret:galaxy/<other-instance>/apikey</c> (or
|
||||
@@ -333,7 +399,7 @@ public static class DriverCredentialPolicy
|
||||
.ToList();
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var other = others.FirstOrDefault(o => ReferenceNames(reference, o.DriverInstanceId) || ReferenceNames(reference, o.Name));
|
||||
var other = others.FirstOrDefault(o => ReferenceNamesId(reference, o.DriverInstanceId) || ReferenceNamesName(reference, o.Name));
|
||||
if (other is null) continue;
|
||||
problems.Add(reference.Kind == CredentialReferenceKind.Secret
|
||||
? $"{reference.FieldPath} names a secret under '{reference.Part}/', which is another {rules.Scope.Label} driver "
|
||||
|
||||
+8
@@ -71,6 +71,14 @@ public sealed class DraftValidatorCredentialInstanceAndPathTests
|
||||
Drv("DRV-c", "C", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
/// <summary>#849 review: a pre-existing driver named 'drv' must not fail the id-form variables of its siblings.</summary>
|
||||
[Fact]
|
||||
public void A_driver_named_drv_does_not_break_id_form_env_variables() =>
|
||||
Validate(
|
||||
Drv("DRV-0123456789ab", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"}}"""),
|
||||
Drv("DRV-fedcba987654", "drv", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_FEDCBA987654_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_keys_pass() =>
|
||||
Validate(
|
||||
|
||||
+62
-3
@@ -181,8 +181,67 @@ public sealed class CredentialInstanceScopeTests
|
||||
refs.Count.ShouldBe(2);
|
||||
refs[0].ShouldBe(new InstanceReference("Gateway.ApiKeySecretRef", CredentialReferenceKind.Secret, "gw"));
|
||||
refs[1].ShouldBe(new InstanceReference("AlarmAck.FallbackPasswordSecretRef", CredentialReferenceKind.Environment, "ACK_PW"));
|
||||
DriverCredentialPolicy.ReferenceNames(refs[1], "ack").ShouldBeTrue();
|
||||
DriverCredentialPolicy.ReferenceNames(refs[1], "ac").ShouldBeFalse();
|
||||
DriverCredentialPolicy.ReferenceNames(refs[0], "GW").ShouldBeTrue();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ack").ShouldBeTrue();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ac").ShouldBeFalse();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[0], "GW").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #849 review — a driver NAMED 'drv' (or id-shaped) must not shadow the id convention
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string RealId = "DRV-0123456789ab"; // the RawTreeService.NewId shape
|
||||
|
||||
private static readonly DriverInstanceIdentity[] WithDrvNamed =
|
||||
[
|
||||
new(RealId, "PlantA", DriverTypeNames.Galaxy),
|
||||
new("DRV-fedcba987654", "drv", DriverTypeNames.Galaxy), // a pre-existing driver named 'drv'
|
||||
new("DRV-111111111111", "DRV-0123456789AB", DriverTypeNames.Galaxy), // …or named like another's id
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_0123456789AB_API_KEY")]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/apikey")]
|
||||
public void An_id_form_reference_is_matched_by_id_only_so_a_pre_existing_drv_name_does_not_break_it(string reference) =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), RealId, WithDrvNamed)
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void An_id_form_reference_still_names_the_other_instance_by_its_id() =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"), "DRV-fedcba987654", WithDrvNamed)
|
||||
.ShouldHaveSingleItem().ShouldContain(RealId);
|
||||
|
||||
[Fact]
|
||||
public void A_drv_name_still_matches_a_reference_that_is_not_id_shaped() =>
|
||||
// 'DRV_FOO' is not an id form, so the name 'drv' does claim it (the create/rename check refuses that name anyway).
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_FOO_KEY"), RealId, WithDrvNamed)
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789ABC_KEY", false)] // 13 hex: not an id form
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AG_KEY", false)] // not hex
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_KEY", false)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/k", true)]
|
||||
[InlineData("secret:galaxy/drv-0123456789AB/k", true)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab-x/k", false)]
|
||||
public void Id_shape_detection(string reference, bool expected) =>
|
||||
DriverCredentialPolicy.IsIdShaped(DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy, Galaxy(reference)).Single())
|
||||
.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("drv", true)]
|
||||
[InlineData("DRV", true)]
|
||||
[InlineData("drv-", false)] // env form 'DRV_' — neither 'DRV' nor an id form
|
||||
[InlineData("DRV-0123456789ab", true)]
|
||||
[InlineData("drv 0123456789AB extra", true)]
|
||||
[InlineData("DRV-0123456789", false)]
|
||||
[InlineData("Driver", false)]
|
||||
[InlineData("PlantA", false)]
|
||||
public void Reserved_instance_names(string name, bool expected) =>
|
||||
DriverCredentialPolicy.IsReservedInstanceName(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
@@ -140,4 +140,28 @@ public sealed class CredentialNameClaimSavePathTests
|
||||
|
||||
renamed.Ok.ShouldBeTrue(renamed.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #849 review: a name in the system-generated id form ('drv', 'DRV-<12 hex>…') would shadow every driver's
|
||||
/// id-form references — refused at create, the /raw rename and the config modal, even with no reference stored.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("drv")]
|
||||
[InlineData("DRV")]
|
||||
[InlineData("DRV-0123456789ab")]
|
||||
[InlineData("drv-0123456789AB-line")]
|
||||
public async Task An_id_shaped_name_is_refused_on_every_name_path(string newName)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY");
|
||||
|
||||
var create = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Galaxy, "{}", Ct);
|
||||
create.Ok.ShouldBeFalse();
|
||||
create.Error!.ShouldContain("system-generated driver id");
|
||||
|
||||
(await svc.RenameDriverAsync(sharerId, newName, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.UpdateDriverAsync(sharerId, newName, "{}", null, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
// A type without credential rules is not affected.
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Modbus, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user