From d549a782fdcce80ba39da253f8861f79aa4a03c0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 14:46:25 -0500 Subject: [PATCH] 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 --- ZB.MOM.WW.Auth/CLAUDE.md | 2 + ZB.MOM.WW.Auth/Directory.Build.props | 2 +- ZB.MOM.WW.Auth/README.md | 10 + .../Ldap/LdapContracts.cs | 15 +- .../Internal/ILdapConnection.cs | 8 + .../Internal/NovellLdapConnection.cs | 13 ++ .../ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs | 132 ++++++++++++- .../LdapOptionsValidator.cs | 4 +- .../FakeLdapConnection.cs | 18 ++ .../GLAuthUserBindIdentityTests.cs | 85 +++++++++ .../Integration/glauth-userbind/config.toml | 49 +++++ .../glauth-userbind/docker-compose.yml | 10 + .../LdapAuthServiceUserBindTests.cs | 179 +++++++++++++++++- code-reviews/Auth/findings.md | 23 ++- code-reviews/README.md | 11 +- 15 files changed, 530 insertions(+), 31 deletions(-) create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthUserBindIdentityTests.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/config.toml create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/docker-compose.yml diff --git a/ZB.MOM.WW.Auth/CLAUDE.md b/ZB.MOM.WW.Auth/CLAUDE.md index 1fbb568..562d6d7 100644 --- a/ZB.MOM.WW.Auth/CLAUDE.md +++ b/ZB.MOM.WW.Auth/CLAUDE.md @@ -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 ''` 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=,ou=users,`). 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` 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. diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props index fb93f6e..f78941d 100644 --- a/ZB.MOM.WW.Auth/Directory.Build.props +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -12,7 +12,7 @@ in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. --> - 0.4.3 + 0.4.4 true diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md index d3b961c..a7457f2 100644 --- a/ZB.MOM.WW.Auth/README.md +++ b/ZB.MOM.WW.Auth/README.md @@ -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:` 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=,ou=users,`, not `cn={0},ou=,`. 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. diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs index ec397bf..b4760aa 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs @@ -45,10 +45,17 @@ public sealed record LdapOptions /// normalised login name. Must contain {0} exactly once. /// /// Prefer the down-level form EXAMPLE\{0}: it maps exactly to the sAMAccountName the - /// group search uses, so the account that binds is the account whose groups are read. A UPN - /// template ({0}@example.com) is only correct where every account's UPN prefix equals its - /// sAMAccountName — AD resolves a UPN bind against the explicit userPrincipalName, 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 ({0}@example.com) only signs in accounts whose UPN + /// prefix equals their sAMAccountName — AD resolves a UPN bind against the explicit + /// userPrincipalName, so it can bind a different account than the one the search finds. + /// + /// + /// Since 0.4.4 the service proves the entry it reads is the account that bound, and refuses the + /// sign-in () otherwise. It asks the directory + /// (RFC 4532 "Who am I?") and compares the answer with the entry's DN or, for AD's + /// u:DOMAIN\name answer, its sAMAccountName. A server without Who-am-I (e.g. GLAuth) + /// is only supported with a DN template (containing =), which must produce the entry's DN + /// exactly as the search reports it; {0} is RFC 4514-escaped in a DN template. /// /// public string UserBindTemplate { get; init; } = ""; diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs index 67f3855..0dd2239 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs @@ -45,6 +45,14 @@ internal interface ILdapConnection : IDisposable /// Executes a subtree search and returns all matching entries. IReadOnlyList Search(string searchBase, string filter, IReadOnlyList attributes); + + /// + /// RFC 4532 "Who am I?": the authorization identity the directory associates with the current + /// bind, as the server sends it (dn:<dn>, u:DOMAIN\name, or empty for an + /// anonymous bind). Throws LdapException when the server does not support the operation + /// (GLAuth answers protocol error 2). + /// + string WhoAmI(); } /// Factory that produces instances. diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs index 839b188..cf4e9ef 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs @@ -102,6 +102,19 @@ internal sealed class NovellLdapConnection : ILdapConnection return ReadEntries(results); } + /// + 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); + } + /// /// Drains a result set into entries, skipping continuation referrals. /// diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs index 927edd8..7643a0d 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs @@ -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. /// + /// + /// The bind name (from ) and the search filter + /// () 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 userPrincipalName is jsmith@corp while the search finds + /// the account whose sAMAccountName is jsmith (Auth-007). The entry is therefore + /// only accepted when it is provably the account that bound: see + /// and . Anything else is . + /// 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); } + /// The AD attribute that an RFC 4532 u:DOMAIN\name authorization identity names. + private const string SamAccountNameAttribute = "sAMAccountName"; + + /// + /// The account a user bind authenticated: either its DN, or (AD's Who-am-I form) its + /// sAMAccountName. Exactly one member is set. + /// + private sealed record BoundIdentity(string? Dn, string? AccountName) + { + public override string ToString() => Dn is not null ? $"dn:{Dn}" : $"u:{AccountName}"; + } + + /// A template is a DN template when it contains an attribute assertion (=); UPN and down-level forms never do. + private static bool IsDnTemplate(string template) => template.Contains('=', StringComparison.Ordinal); + + /// + /// Establishes which account the bind authenticated. The directory's own answer wins: RFC 4532 + /// Who-am-I (dn:<dn>, or AD's u:DOMAIN\name). 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 and the sign-in fails closed. + /// + 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; + } + + /// + /// only when is the account that bound: its DN + /// equals the bound DN (RFC 4514 distinguishedNameMatch, case- and space-insensitive), or + /// its sAMAccountName equals the Who-am-I account name (case-insensitive, as AD compares it). + /// + 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); + } + /// Step 7: search for the user entry; a search fault is system-side. - private IReadOnlyList SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username) + private IReadOnlyList 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 } /// - /// 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, . 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. /// - private IReadOnlyList BuildSearchAttributes() + private IReadOnlyList BuildSearchAttributes(string? extraAttribute = null) { - var attributes = new List(3); - foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute }) + var attributes = new List(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); } diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs index ed4c26f..8e0ea4e 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs @@ -74,8 +74,8 @@ public sealed class LdapOptionsValidator : IValidateOptions { 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)) diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs index 5075200..d7a26fb 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs @@ -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; } + /// + /// Scripts the RFC 4532 Who-am-I answer (e.g. dn:CN=Jane,DC=x or u:CORP\jane). + /// Unscripted, throws protocol error 2 — what GLAuth answers. + /// + public FakeLdapConnection WithWhoAmI(string authzId) + { + _whoAmI = authzId; + return this; + } + /// Throw from (unhealthy directory). 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 */ } } diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthUserBindIdentityTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthUserBindIdentityTests.cs new file mode 100644 index 0000000..9a98aaa --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthUserBindIdentityTests.cs @@ -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, + }; + } + + /// + /// mallory binds with her own password; (mail=mallory) finds the boss entry, which + /// is in Admins. Before the fix this signed mallory in with boss's groups. + /// + [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); + } + + /// Positive control: the same mechanism admits a user whose bind DN is the entry's DN. + [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=,ou=,ou=users,; 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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/config.toml b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/config.toml new file mode 100644 index 0000000..c955a32 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/config.toml @@ -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 = "*" diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/docker-compose.yml b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/docker-compose.yml new file mode 100644 index 0000000..6fb004f --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/docker-compose.yml @@ -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 diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs index 7350867..b74c739 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs @@ -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 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(), "Jane")); + new FakeLdapConnection().WithWhoAmI("dn:CN=Jane,DC=x").WithUserEntry("CN=Jane,DC=x", Array.Empty(), "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 { ["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 ---- + + /// + /// The review's scenario: a UPN bind for jsmith is resolved by AD to Mallory (explicit UPN + /// jsmith@corp.test, sAMAccountName jsmith2), while (sAMAccountName=jsmith) + /// finds the admin. Before the fix this returned the admin's groups to Mallory. + /// + [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 { ["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 { ["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 { ["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); + } } diff --git a/code-reviews/Auth/findings.md b/code-reviews/Auth/findings.md index 32dd55a..4e8e846 100644 --- a/code-reviews/Auth/findings.md +++ b/code-reviews/Auth/findings.md @@ -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 | | | diff --git a/code-reviews/README.md b/code-reviews/README.md index e7125dd..e8361af 100644 --- a/code-reviews/README.md +++ b/code-reviews/README.md @@ -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 |