fix(auth): user bind proves the bound account is the entry whose groups it returns (scadaproj#40)

BindMode=User bound as UserBindTemplate({0}) and then returned the groups of
whatever entry (UserNameAttribute={0}) found - two directory lookups that can
name two accounts (Auth-007). Reproduced live against GLAuth: mallory bound as
herself and signed in with boss's Admins group.

After the bind the service now asks RFC 4532 Who-am-I and requires the entry
to be that account (dn: -> DN match; AD u:DOMAIN\name -> sAMAccountName match).
Without a Who-am-I answer only a DN template is provable (bind DN must equal
the entry DN); anything else fails closed as GroupLookupFailed. {0} is
RFC 4514-escaped in DN templates; validator message leads with EXAMPLE\{0}.

Auth 0.4.3 -> 0.4.4 (unpublished).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:46:25 -05:00
parent 5f5d684c44
commit d549a782fd
15 changed files with 530 additions and 31 deletions
+2
View File
@@ -42,6 +42,8 @@ MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those
**`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
**`0.4.4` (unpublished, scadaproj#40, Auth-007):** `BindMode=User` now proves the entry whose groups it returns is the account that bound — RFC 4532 Who-am-I (`dn:` → DN match; AD `u:DOMAIN\name` → `sAMAccountName` match), falling back to the bind DN only for a DN template (GLAuth has no Who-am-I); otherwise `GroupLookupFailed`. `{0}` is RFC 4514-escaped in DN templates. Before the fix a user could sign in with another account's groups whenever the UPN/DN bind and the `UserNameAttribute` search named different accounts — reproduced live against a throwaway GLAuth (`tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/`, `ZB_LDAP_USERBIND_IT=1`, port 3897): pre-fix `mallory` signed in with `Admins`. **Consumer impact:** AD + down-level/UPN template unchanged for consistent accounts (BTDB Manager is the only user-bind consumer); non-AD user-bind needs a DN template that yields the entry's DN exactly as searched (GLAuth: `cn={0},ou=<group>,ou=users,<base>`). README §0.4.4.
**`0.4.3` (unpublished, scadaproj#26):** `Microsoft.Data.Sqlite` 10.0.7 -> 10.0.13 (efcore#39009 pool fault); `SQLitePCLRaw.lib.e_sqlite3` transitive pin 2.1.12 -> 2.1.13 (10.0.13 floors 2.1.13). No API change.
**`0.4.2` (unpublished, scadaproj#23 + #22):** `ZB.MOM.WW.Auth.ApiKeys.ApiKeyPepperUnavailableException : InvalidOperationException` (message still `"pepper unavailable"`) is now thrown by `ApiKeyAdminCommands` create/rotate when the pepper is missing, so consumers can catch it by type; existing `catch (InvalidOperationException)` blocks keep working (only exact-type assertions like xunit `ThrowsAsync<InvalidOperationException>` need updating). **Release note: consumers can drop their own `IApiKeyPepperProvider` pre-checks** — mxaccessgw's `DashboardApiKeyManagementService` and any similar code. Also pins `System.Security.Cryptography.Pkcs 9.0.21` (transitive, via SqlClient 6.0.2) to clear NuGetAudit NU1902/NU1903. Not yet published to the feed.
+1 -1
View File
@@ -12,7 +12,7 @@
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
<!-- 0.4.2: typed ApiKeyPepperUnavailableException (: InvalidOperationException) for a missing pepper on
create/rotate; patched System.Security.Cryptography.Pkcs transitive pin (SqlServer package). -->
<Version>0.4.3</Version>
<Version>0.4.4</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
+10
View File
@@ -32,6 +32,16 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
### 0.4.4 — user bind proves the bound account is the one whose groups it returns (unpublished, scadaproj#40)
- **Security fix (Auth-007), `BindMode = User` only.** The bind name (`UserBindTemplate`) and the group search (`UserNameAttribute`) are two separate directory lookups and could name two accounts — e.g. AD resolves `jsmith@corp.com` to the account with that explicit UPN while `(sAMAccountName=jsmith)` finds a different one, so a user could sign in with another account's groups. The service now only accepts the entry it found when it is provably the account that bound:
- it asks the directory with RFC 4532 **Who-am-I** after the bind; a `dn:<dn>` answer must equal the entry's DN (`distinguishedNameMatch`), and AD's `u:DOMAIN\name` answer must equal the entry's `sAMAccountName` (requested automatically);
- a server without Who-am-I (GLAuth answers protocol error 2) is supported only with a **DN template** (contains `=`), whose bind DN must equal the entry's DN;
- anything else — a mismatch, an unanswered Who-am-I with a UPN or down-level template — is `GroupLookupFailed`, logged at Warning, and never retried on a fallback endpoint.
- **`{0}` is now RFC 4514-escaped in a DN template**, so a typed name such as `x,ou=Admins` cannot rewrite the bind DN.
- **Behaviour change for consumers:** AD with a down-level or UPN template is unaffected for accounts whose bound identity and searched entry agree (AD has supported Who-am-I since Windows Server 2003). A UPN template now refuses accounts whose UPN prefix differs from `sAMAccountName` instead of risking the wrong account. A **non-AD directory** in `User` mode needs either Who-am-I support or a DN template that yields the entry's DN *exactly as the search returns it* — for GLAuth that is `cn={0},ou=<group>,ou=users,<base>`, not `cn={0},ou=<group>,<base>`. ServiceAccount mode is unchanged.
- The validator's `UserBindTemplate` message now leads with `EXAMPLE\{0}`. No `Abstractions` contract change (XML docs only).
### 0.4.1 — LDAP review fixes
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
@@ -45,10 +45,17 @@ public sealed record LdapOptions
/// normalised login name. Must contain <c>{0}</c> exactly once.
/// <para>
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
/// group search uses, so the account that binds is the account whose groups are read. A UPN
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
/// group search uses. A UPN template (<c>{0}@example.com</c>) only signs in accounts whose UPN
/// prefix equals their <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit
/// <c>userPrincipalName</c>, so it can bind a different account than the one the search finds.
/// </para>
/// <para>
/// Since 0.4.4 the service proves the entry it reads is the account that bound, and refuses the
/// sign-in (<see cref="LdapAuthFailure.GroupLookupFailed"/>) otherwise. It asks the directory
/// (RFC 4532 "Who am I?") and compares the answer with the entry's DN or, for AD's
/// <c>u:DOMAIN\name</c> answer, its <c>sAMAccountName</c>. A server without Who-am-I (e.g. GLAuth)
/// is only supported with a DN template (containing <c>=</c>), which must produce the entry's DN
/// exactly as the search reports it; <c>{0}</c> is RFC 4514-escaped in a DN template.
/// </para>
/// </summary>
public string UserBindTemplate { get; init; } = "";
@@ -45,6 +45,14 @@ internal interface ILdapConnection : IDisposable
/// <summary>Executes a subtree search and returns all matching entries.</summary>
IReadOnlyList<LdapSearchEntry> Search(string searchBase, string filter, IReadOnlyList<string> attributes);
/// <summary>
/// RFC 4532 "Who am I?": the authorization identity the directory associates with the current
/// bind, as the server sends it (<c>dn:&lt;dn&gt;</c>, <c>u:DOMAIN\name</c>, or empty for an
/// anonymous bind). Throws <c>LdapException</c> when the server does not support the operation
/// (GLAuth answers protocol error 2).
/// </summary>
string WhoAmI();
}
/// <summary>Factory that produces <see cref="ILdapConnection"/> instances.</summary>
@@ -102,6 +102,19 @@ internal sealed class NovellLdapConnection : ILdapConnection
return ReadEntries(results);
}
/// <inheritdoc/>
public string WhoAmI()
{
// ExtendedOperation throws LdapException on a non-success result (e.g. protocol error 2
// from a server without RFC 4532 support), which the caller treats as "no answer".
var response = _conn.ExtendedOperation(new LdapWhoAmIOperation());
if (response is LdapWhoAmIResponse whoAmI)
return whoAmI.AuthzId ?? "";
var value = response.Value;
return value is null ? "" : System.Text.Encoding.UTF8.GetString(value);
}
/// <summary>
/// Drains a result set into entries, <b>skipping continuation referrals</b>.
/// </summary>
@@ -324,9 +324,23 @@ public sealed class LdapAuthService : ILdapAuthService
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
/// </summary>
/// <remarks>
/// The bind name (from <see cref="LdapOptions.UserBindTemplate"/>) and the search filter
/// (<see cref="LdapOptions.UserNameAttribute"/>) are resolved by two different directory
/// lookups, so they can name two different accounts — e.g. a UPN bind that AD resolves to the
/// account whose explicit <c>userPrincipalName</c> is <c>jsmith@corp</c> while the search finds
/// the account whose <c>sAMAccountName</c> is <c>jsmith</c> (Auth-007). The entry is therefore
/// only accepted when it is provably the account that bound: see <see cref="ResolveBoundIdentity"/>
/// and <see cref="IsBoundAccount"/>. Anything else is <see cref="LdapAuthFailure.GroupLookupFailed"/>.
/// </remarks>
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
var dnTemplate = IsDnTemplate(_options.UserBindTemplate);
// A DN template splices the name into a DN, so the name is RFC 4514-escaped: unescaped, a
// name like "x,ou=other" would bind a DN of the caller's choosing.
var bindName = _options.UserBindTemplate.Replace(
"{0}", dnTemplate ? LdapEscaping.Dn(username) : username, StringComparison.Ordinal);
try
{
conn.Bind(bindName, password);
@@ -340,17 +354,114 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
}
var bound = ResolveBoundIdentity(conn, bindName, dnTemplate);
if (bound is null)
{
_logger?.LogWarning(
"LDAP user bind succeeded but the bound identity could not be established (no RFC 4532 " +
"Who-am-I answer and UserBindTemplate is not a DN); refusing the sign-in.");
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
// After a good bind, anything but exactly one readable entry is a failure to establish the
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
var entries = SearchUser(conn, username);
var entries = SearchUser(conn, username, bound.AccountName is null ? null : SamAccountNameAttribute);
if (entries.Count != 1)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
if (!IsBoundAccount(entries[0], bound))
{
_logger?.LogWarning(
"LDAP user bind authenticated {BoundIdentity} but the entry found for the typed name is " +
"{EntryDn}; refusing the sign-in rather than returning another account's groups.",
bound, entries[0].Dn);
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
return SucceedFromEntry(entries[0], username);
}
/// <summary>The AD attribute that an RFC 4532 <c>u:DOMAIN\name</c> authorization identity names.</summary>
private const string SamAccountNameAttribute = "sAMAccountName";
/// <summary>
/// The account a user bind authenticated: either its DN, or (AD's Who-am-I form) its
/// <c>sAMAccountName</c>. Exactly one member is set.
/// </summary>
private sealed record BoundIdentity(string? Dn, string? AccountName)
{
public override string ToString() => Dn is not null ? $"dn:{Dn}" : $"u:{AccountName}";
}
/// <summary>A template is a DN template when it contains an attribute assertion (<c>=</c>); UPN and down-level forms never do.</summary>
private static bool IsDnTemplate(string template) => template.Contains('=', StringComparison.Ordinal);
/// <summary>
/// Establishes which account the bind authenticated. The directory's own answer wins: RFC 4532
/// Who-am-I (<c>dn:&lt;dn&gt;</c>, or AD's <c>u:DOMAIN\name</c>). Without one — the server does
/// not support the operation (GLAuth), or answers empty — a DN template is still provable,
/// because the directory accepted exactly that DN; a UPN or down-level template is not, so the
/// result is <see langword="null"/> and the sign-in fails closed.
/// </summary>
private static BoundIdentity? ResolveBoundIdentity(
ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string bindName, bool dnTemplate)
{
string authzId;
try
{
authzId = conn.WhoAmI()?.Trim() ?? "";
}
catch (LdapException)
{
// Unsupported (protocol error / unwilling) or a transport fault. Either way there is no
// answer; a dead connection will fail the following search, which is system-side.
authzId = "";
}
if (authzId.StartsWith("dn:", StringComparison.OrdinalIgnoreCase) && authzId.Length > 3)
return new BoundIdentity(authzId[3..].Trim(), null);
if (authzId.StartsWith("u:", StringComparison.OrdinalIgnoreCase) && authzId.Length > 2)
{
var account = authzId[2..].Trim();
var slash = account.LastIndexOf('\\');
if (slash >= 0)
account = account[(slash + 1)..];
if (account.Length > 0)
return new BoundIdentity(null, account);
}
return dnTemplate ? new BoundIdentity(bindName, null) : null;
}
/// <summary>
/// <see langword="true"/> only when <paramref name="entry"/> is the account that bound: its DN
/// equals the bound DN (RFC 4514 <c>distinguishedNameMatch</c>, case- and space-insensitive), or
/// its <c>sAMAccountName</c> equals the Who-am-I account name (case-insensitive, as AD compares it).
/// </summary>
private static bool IsBoundAccount(LdapSearchEntry entry, BoundIdentity bound)
{
if (bound.Dn is not null)
{
try
{
return LdapDn.Equals(entry.Dn, bound.Dn);
}
catch (Exception)
{
// An unparseable DN on either side cannot be proven equal.
return false;
}
}
return entry.Attributes.TryGetValue(SamAccountNameAttribute, out var values)
&& values.Count == 1
&& string.Equals(values[0], bound.AccountName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
private IReadOnlyList<LdapSearchEntry> SearchUser(
ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string? extraAttribute = null)
{
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
@@ -359,7 +470,7 @@ public sealed class LdapAuthService : ILdapAuthService
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes(extraAttribute));
}
catch (LdapException)
{
@@ -411,17 +522,18 @@ public sealed class LdapAuthService : ILdapAuthService
}
/// <summary>
/// Builds the distinct attribute list requested from the directory: display name, groups and
/// the username attribute (read back as the canonical username). They are de-duplicated,
/// Builds the distinct attribute list requested from the directory: display name, groups,
/// the username attribute (read back as the canonical username) and, when the user-bind
/// identity check needs it, <paramref name="extraAttribute"/>. They are de-duplicated,
/// case-insensitively, so we never request the same attribute twice when an operator
/// configures two of them to the same value.
/// </summary>
private IReadOnlyList<string> BuildSearchAttributes()
private IReadOnlyList<string> BuildSearchAttributes(string? extraAttribute = null)
{
var attributes = new List<string>(3);
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
var attributes = new List<string>(4);
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute, extraAttribute })
{
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
if (name is not null && !attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
attributes.Add(name);
}
@@ -74,8 +74,8 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
"or \"EXAMPLE\\{0}\".");
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"EXAMPLE\\{{0}}\" " +
"(preferred) or \"{0}@example.com\".");
}
}
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
@@ -18,6 +18,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
private bool _throwOnUserBind;
private int? _firstBindResultCode;
private bool _throwOnSearch;
private string? _whoAmI;
// ---- observation -----
@@ -112,6 +113,16 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this;
}
/// <summary>
/// Scripts the RFC 4532 Who-am-I answer (e.g. <c>dn:CN=Jane,DC=x</c> or <c>u:CORP\jane</c>).
/// Unscripted, <see cref="WhoAmI"/> throws protocol error 2 — what GLAuth answers.
/// </summary>
public FakeLdapConnection WithWhoAmI(string authzId)
{
_whoAmI = authzId;
return this;
}
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
public FakeLdapConnection ThrowOnSearch()
{
@@ -197,6 +208,13 @@ internal sealed class FakeLdapConnection : ILdapConnection
return _scriptedEntries.AsReadOnly();
}
public string WhoAmI()
{
Calls.Add("whoami");
return _whoAmI ?? throw new Novell.Directory.Ldap.LdapException(
"Protocol Error", Novell.Directory.Ldap.LdapException.ProtocolError, null);
}
public void Dispose() { /* nothing to clean up */ }
}
@@ -0,0 +1,85 @@
// Live user-bind identity test (Auth-007, scadaproj#40) — opt-in only.
//
// Start the throwaway fixture (NOT the shared rig — it needs users that can search):
// cd tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind && docker compose up -d
// Run:
// ZB_LDAP_USERBIND_IT=1 dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
// --filter "FullyQualifiedName~GLAuthUserBindIdentityTests"
// Optional: ZB_LDAP_USERBIND_SERVER (default localhost), ZB_LDAP_USERBIND_PORT (default 3897).
//
// GLAuth does not implement RFC 4532 Who-am-I (it answers protocol error 2), so these tests also
// prove the DN-template fallback path against a real directory.
using System.Net.Sockets;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
public sealed class GLAuthUserBindIdentityTests
{
private static LdapOptions Options(string template, string userNameAttribute)
{
var server = Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_SERVER") ?? "localhost";
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_PORT"), out var p) ? p : 3897;
try
{
using var tcp = new TcpClient();
if (!tcp.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3)))
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}.");
}
catch (Exception ex)
{
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}: {ex.Message}");
}
return new LdapOptions
{
Server = server,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
BindMode = LdapBindMode.User,
UserBindTemplate = template,
UserNameAttribute = userNameAttribute,
GroupAttribute = "memberOf",
ConnectionTimeoutMs = 3000,
};
}
/// <summary>
/// mallory binds with her own password; <c>(mail=mallory)</c> finds the <c>boss</c> entry, which
/// is in Admins. Before the fix this signed mallory in with boss's groups.
/// </summary>
[SkippableFact]
public async Task BoundAccount_DifferentFromTheSearchedEntry_IsRefused()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
var svc = new LdapAuthService(Options("cn={0},ou=Users,dc=zb,dc=local", "mail"));
var result = await svc.AuthenticateAsync("mallory", "password", CancellationToken.None);
Assert.False(result.Succeeded, $"signed in as {result.Username} with [{string.Join(",", result.Groups)}]");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.DoesNotContain("Admins", result.Groups);
}
/// <summary>Positive control: the same mechanism admits a user whose bind DN is the entry's DN.</summary>
[SkippableFact]
public async Task BoundAccount_SameAsTheSearchedEntry_Succeeds()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
// GLAuth reports search DNs as cn=<user>,ou=<group>,ou=users,<base>; it also accepts that
// form as a bind DN, so this template binds the exact DN the search returns.
var svc = new LdapAuthService(Options("cn={0},ou=Users,ou=users,dc=zb,dc=local", "cn"));
var result = await svc.AuthenticateAsync("boss", "password", CancellationToken.None);
Assert.True(result.Succeeded, $"failed: {result.Failure}");
Assert.Equal("boss", result.Username);
Assert.Contains("Admins", result.Groups);
}
}
@@ -0,0 +1,49 @@
# GLAuth fixture for the user-bind identity test (Auth-007, scadaproj#40).
# Start: docker compose up -d (listens on localhost:3897)
# All passwords are "password" (sha256 below).
#
# mallory binds as herself (cn=mallory,ou=Users,...). The admin entry carries mail=mallory,
# so with UserNameAttribute=mail a search for the typed name "mallory" finds the ADMIN — the
# account that bound and the account searched for differ. The library must refuse.
[ldap]
enabled = true
listen = "0.0.0.0:3893"
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=zb,dc=local"
[behaviors]
LimitFailedBinds = false
[[groups]]
name = "Users"
gidnumber = 6001
[[groups]]
name = "Admins"
gidnumber = 6002
[[users]]
name = "mallory"
mail = "mallory-real@zb.local"
uidnumber = 6101
primarygroup = 6001
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "boss"
mail = "mallory"
uidnumber = 6102
primarygroup = 6001
othergroups = [6002]
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users.capabilities]]
action = "search"
object = "*"
@@ -0,0 +1,10 @@
# Throwaway GLAuth for GLAuthUserBindIdentityTests (Auth-007). Not the shared rig.
name: zb-auth-userbind-it
services:
glauth:
image: glauth/glauth:latest
container_name: zb-auth-userbind-it
ports:
- "3897:3893"
volumes:
- ./config.toml:/app/config/config.cfg:ro
@@ -27,8 +27,11 @@ public class LdapAuthServiceUserBindTests
FallbackServers = fallbacks,
};
private const string JaneDn = "CN=Jane Doe,OU=u,DC=x";
private static FakeLdapConnection Jane() => new FakeLdapConnection()
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
.WithWhoAmI("dn:" + JaneDn)
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
svc.AuthenticateAsync(user, pw, CancellationToken.None);
@@ -44,7 +47,7 @@ public class LdapAuthServiceUserBindTests
Assert.Equal("Jane Doe", result.DisplayName);
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
var conn = Assert.Single(factory.Created);
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "whoami", "search:(sAMAccountName=jdoe)" }, conn.Calls);
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
}
@@ -153,7 +156,7 @@ public class LdapAuthServiceUserBindTests
[Fact]
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI("dn:" + JaneDn).WithNoMatch());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
@@ -163,7 +166,7 @@ public class LdapAuthServiceUserBindTests
[Fact]
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI("dn:cn=dup1,dc=x").WithDuplicateMatch());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
@@ -173,7 +176,7 @@ public class LdapAuthServiceUserBindTests
public async Task NoGroups_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
new FakeLdapConnection().WithWhoAmI("dn:CN=Jane,DC=x").WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
@@ -182,7 +185,7 @@ public class LdapAuthServiceUserBindTests
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI(@"u:NAM\jdoe").WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
@@ -219,4 +222,168 @@ public class LdapAuthServiceUserBindTests
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
}
// ---- Auth-007 (scadaproj#40): the entry whose groups are returned must be the account that bound ----
/// <summary>
/// The review's scenario: a UPN bind for <c>jsmith</c> is resolved by AD to Mallory (explicit UPN
/// <c>jsmith@corp.test</c>, sAMAccountName <c>jsmith2</c>), while <c>(sAMAccountName=jsmith)</c>
/// finds the admin. Before the fix this returned the admin's groups to Mallory.
/// </summary>
[Fact]
public async Task UpnBind_ResolvedToAnotherAccount_DoesNotReturnTheSearchedEntrysGroups()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jsmith2")
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jsmith" } });
var factory = new SequenceLdapConnectionFactory(fake);
var result = await Login(new LdapAuthService(Opts(), factory), "jsmith", "mallorys-own-password");
Assert.False(result.Succeeded);
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Empty(result.Groups);
Assert.Equal(new[] { "jsmith@corp.test" }, fake.BoundDns);
}
[Fact]
public async Task WhoAmIAccountName_MatchingTheEntry_Succeeds_AndRequestsSamAccountName()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\JSmith")
.WithUserEntry("CN=John Smith,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "John Smith",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jsmith" } });
var factory = new SequenceLdapConnectionFactory(fake);
var svc = new LdapAuthService(Opts() with { UserNameAttribute = "cn" }, factory);
var result = await Login(svc, "John Smith");
Assert.True(result.Succeeded);
Assert.Contains("sAMAccountName", fake.SearchAttributes!);
}
[Fact]
public async Task WhoAmIAccountName_WithoutDomain_IsAccepted()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("u:jdoe")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } });
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.True(result.Succeeded);
}
[Fact]
public async Task WhoAmIAccountName_EntryWithoutSamAccountName_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jdoe")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task WhoAmIDn_NamingAnotherEntry_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("dn:CN=Mallory,OU=u,DC=x")
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Empty(result.Groups);
}
[Fact]
public async Task WhoAmIDn_IsComparedAsADn_NotAsAString()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("dn:cn=jane doe, ou=U, dc=X")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.True(result.Succeeded);
}
[Theory]
[InlineData(null)] // server does not support Who-am-I (GLAuth: protocol error 2)
[InlineData("")] // anonymous / empty answer
[InlineData("x:garbage")]
public async Task UpnTemplate_WithoutAWhoAmIAnswer_FailsClosed_WithoutSearching(string? whoAmI)
{
var fake = new FakeLdapConnection()
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
if (whoAmI is not null)
fake.WithWhoAmI(whoAmI);
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.DoesNotContain(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal));
}
[Fact]
public async Task DownLevelTemplate_WithoutAWhoAmIAnswer_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var svc = new LdapAuthService(Opts() with { UserBindTemplate = @"CORP\{0}" }, new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task DnTemplate_WithoutWhoAmI_AcceptsTheEntryAtTheBoundDn()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=jdoe,ou=Users,dc=x", new[] { "cn=UG_A,ou=g,dc=x" }, "Jane Doe");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "jdoe");
Assert.True(result.Succeeded);
}
[Fact]
public async Task DnTemplate_WithoutWhoAmI_RefusesAnEntryAtAnotherDn()
{
// Mallory binds as cn=mallory with her own password; (mail=mallory) finds the admin.
var fake = new FakeLdapConnection()
.WithUserEntry("cn=admin,ou=Users,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "mail" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "mallory");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Equal(new[] { "cn=mallory,ou=users,dc=x" }, fake.BoundDns);
}
[Fact]
public async Task DnTemplate_EscapesTheName_SoItCannotRewriteTheBindDn()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=admin,ou=Admins,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "admin,ou=Admins");
Assert.Equal(new[] { @"cn=admin\,ou=Admins,ou=users,dc=x" }, fake.BoundDns);
Assert.False(result.Succeeded);
}
}
+21 -2
View File
@@ -10,7 +10,7 @@
| Last reviewed | 2026-11-09 (re-review; baseline 2026-06-01 at `5f75cd4`) |
| Reviewer | Claude (2026-11-09 re-review, scadaproj#15; baseline: Claude automated) |
| Commit reviewed | `91fb2b4` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -315,7 +315,7 @@ Resolved in `544a6dd`, 2026-06-01 — added an optional `LdapOptions.ServerCerti
| Severity | Medium |
| Priority | P2 |
| Category | Security & secret handling |
| Status | Open |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:329` |
**Description**
@@ -354,6 +354,25 @@ After the bind, identify the entry by the bound identity, not the typed name:
Fail with `GroupLookupFailed` on a mismatch. At minimum, reorder the validator example to lead with
`EXAMPLE\{0}`. Add a test where the bind name and the searched entry differ.
**Resolution**
Resolved 2026-11-09 (scadaproj#40, Auth `0.4.4`, unpublished). Re-verified first: through the fake seam, the pre-fix
service returned the searched entry's groups for a UPN bind that resolved to another account, and live against a
throwaway GLAuth (`tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/`), `mallory` bound as herself and signed
in with `boss`'s `Admins` group because `(mail=mallory)` found `boss`. `CompleteAsUser` now establishes the bound
identity and refuses any entry that is not it, with `GroupLookupFailed`:
- RFC 4532 Who-am-I (new `ILdapConnection.WhoAmI`, backed by Novell's `LdapWhoAmIOperation`). A `dn:` answer must
equal the entry's DN (`LdapDn.Equals`, `distinguishedNameMatch`). AD's `u:DOMAIN\name` answer must equal the
entry's `sAMAccountName`, which is requested automatically.
- With no Who-am-I answer (GLAuth answers protocol error 2), only a DN template is provable, and its bind DN must
equal the entry's DN. A UPN or down-level template fails closed.
- `{0}` is RFC 4514-escaped in DN templates. The validator message now leads with `EXAMPLE\{0}`.
Tests: 13 new cases in `LdapAuthServiceUserBindTests`, 11 of which fail against the pre-fix service, plus
`GLAuthUserBindIdentityTests` (live, `ZB_LDAP_USERBIND_IT=1`): the mismatch case fails pre-fix and passes after, and
the positive control passes both ways. Consumer note (README §0.4.4): a non-AD directory in `User` mode needs a DN
template that yields the entry's DN exactly as the search reports it.
### Auth-008 — SQL Server store silently truncates over-length values and matches key ids case-insensitively, diverging from SQLite
| | |
+5 -6
View File
@@ -40,7 +40,7 @@ code-reviews/
## Summary
10 of 10 libraries reviewed. 24 pending findings across all libraries.
10 of 10 libraries reviewed. 23 pending findings across all libraries.
Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health,
Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also
@@ -50,16 +50,16 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low).
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 5 |
| Medium | 4 |
| Low | 19 |
| **Total** | **24** |
| **Total** | **23** |
## Library Status
| Library | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|---------|---------------|--------|----------------|------|-------|
| [Audit](Audit/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 |
| [Auth](Auth/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/3 | 4 | 10 |
| [Auth](Auth/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/3 | 3 | 10 |
| [Configuration](Configuration/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 4 |
| [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 |
| [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 7 |
@@ -84,11 +84,10 @@ _None open._
_None open._
### Medium (5)
### Medium (4)
| ID | Library | Title |
|----|---------|-------|
| Auth-007 | [Auth](Auth/findings.md) | User-bind mode never checks that the entry whose groups it returns is the account that bound |
| LocalDb-001 | [LocalDb](LocalDb/findings.md) | Sync endpoint is unauthenticated by default, and `ApiKey` is documented as "expected from the peer" |
| LocalDb-002 | [LocalDb](LocalDb/findings.md) | Snapshot streams tables by name and rows by `pk_json` text, so FK children land before parents and are dead-lettered for good |
| LocalDb-003 | [LocalDb](LocalDb/findings.md) | `INSERT OR REPLACE` apply silently deletes a different row on a secondary `UNIQUE` conflict, so the pair diverges |