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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:<dn></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:<dn></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 */ }
|
||||
}
|
||||
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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 = "*"
|
||||
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user