diff --git a/src/CommonLib/Cache.cs b/src/CommonLib/Cache.cs index 3ff4d563..f94a9bfa 100644 --- a/src/CommonLib/Cache.cs +++ b/src/CommonLib/Cache.cs @@ -46,10 +46,12 @@ private Cache() [IgnoreDataMember] private static Cache CacheInstance { get; set; } /// - /// Add a SID/Domain-name pair to the cache. Writes both directions (SID→Name and - /// Name→SID) so a successful resolution by any tier benefits subsequent lookups in - /// either direction. Existing entries are preserved (TryAdd semantics) — first - /// resolver wins. + /// Add a SID/Domain-name pair to the cache. The Name→SID direction is always written + /// (NetBIOS aliases are valid lookup keys). The SID→Name direction is only written + /// when the name is a DNS-shaped FQDN: a NetBIOS-keyed reverse write would poison + /// the slot for downstream consumers that depend on the SID→Name lookup yielding a + /// DNS name (LDAP base DN construction, server selection, GetDomainInfoAsync hints). + /// Existing entries are preserved (TryAdd semantics) — first resolver wins. /// /// A SID or a domain name. /// The corresponding domain name or SID. @@ -57,11 +59,36 @@ internal static void AddDomainSidMapping(string key, string value) { if (CacheInstance == null) return; if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) return; - CacheInstance.SIDToDomainCache.TryAdd(key, value); - // Bidirectional: SIDs (S-…) and DNS domain names cannot collide as keys, so writing - // the reverse mapping makes a successful resolution by any tier visible to lookups - // in either direction. - CacheInstance.SIDToDomainCache.TryAdd(value, key); + + var keyIsSid = LooksLikeDomainSid(key); + var valueIsSid = LooksLikeDomainSid(value); + + if (keyIsSid == valueIsSid) + { + // Both look like SIDs or neither does — caller misuse or an unexpected input + // shape. Throw this data out + return; + } + + var sid = keyIsSid ? key : value; + var name = keyIsSid ? value : key; + + CacheInstance.SIDToDomainCache.TryAdd(name, sid); + + if (LooksLikeDnsDomainName(name)) + { + CacheInstance.SIDToDomainCache.TryAdd(sid, name); + } + } + + private static bool LooksLikeDomainSid(string value) + { + return value != null && value.StartsWith("S-1-", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksLikeDnsDomainName(string value) + { + return value != null && value.IndexOf('.') >= 0; } /// @@ -177,19 +204,33 @@ private static void NormalizeCaseInsensitiveCaches() if (CacheInstance == null) return; if (CacheInstance.SIDToDomainCache != null) { - CacheInstance.SIDToDomainCache = new ConcurrentDictionary( - CacheInstance.SIDToDomainCache, StringComparer.OrdinalIgnoreCase); + CacheInstance.SIDToDomainCache = CopyCaseInsensitive(CacheInstance.SIDToDomainCache); } if (CacheInstance.GlobalCatalogCache != null) { - CacheInstance.GlobalCatalogCache = new ConcurrentDictionary( - CacheInstance.GlobalCatalogCache, StringComparer.OrdinalIgnoreCase); + CacheInstance.GlobalCatalogCache = CopyCaseInsensitive(CacheInstance.GlobalCatalogCache); } if (CacheInstance.ValueToIdCache != null) { - CacheInstance.ValueToIdCache = new ConcurrentDictionary( - CacheInstance.ValueToIdCache, StringComparer.OrdinalIgnoreCase); + CacheInstance.ValueToIdCache = CopyCaseInsensitive(CacheInstance.ValueToIdCache); + } + } + + /// + /// Copies into a new keyed + /// by . Entries are added with TryAdd so keys that + /// collide only by case (introduced before the case-insensitive invariant was reapplied) are + /// silently dropped — first writer wins — rather than throwing from the constructor. + /// + private static ConcurrentDictionary CopyCaseInsensitive( + ConcurrentDictionary source) + { + var copy = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in source) + { + copy.TryAdd(kvp.Key, kvp.Value); } + return copy; } /// diff --git a/src/CommonLib/ConnectionPoolManager.cs b/src/CommonLib/ConnectionPoolManager.cs index 19d92909..9de2cca2 100644 --- a/src/CommonLib/ConnectionPoolManager.cs +++ b/src/CommonLib/ConnectionPoolManager.cs @@ -146,6 +146,13 @@ private string ResolveIdentifier(string identifier) { .GetAwaiter().GetResult(); if (infoOk && !string.IsNullOrEmpty(info?.DomainSid)) { Cache.AddDomainSidMapping(domainName, info.DomainSid); + // Also seed the canonical FQDN keyed write so the SID->Name slot is populated + // even when the caller passed a NetBIOS alias. AddDomainSidMapping gates the + // SID->Name direction on the name being DNS-shaped. + if (!string.IsNullOrEmpty(info.Name) && + !string.Equals(domainName, info.Name, StringComparison.OrdinalIgnoreCase)) { + Cache.AddDomainSidMapping(info.Name, info.DomainSid); + } return (true, info.DomainSid); } diff --git a/src/CommonLib/LdapConnectionPool.cs b/src/CommonLib/LdapConnectionPool.cs index 6b933709..b12ffe95 100644 --- a/src/CommonLib/LdapConnectionPool.cs +++ b/src/CommonLib/LdapConnectionPool.cs @@ -36,14 +36,18 @@ internal class LdapConnectionPool : IDisposable { private static readonly TimeSpan MaxBackoffDelay = TimeSpan.FromSeconds(20); private const int BackoffDelayMultiplier = 2; private const int MaxRetries = 3; - private static readonly ConcurrentDictionary DCInfoCache = new(); + private static readonly ConcurrentDictionary DCInfoCache = new(StringComparer.OrdinalIgnoreCase); // Metrics private readonly IMetricRouter _metric; - // Tracks domains we know we've determined we shouldn't try to connect to + // Tracks domains we know we've determined we shouldn't try to connect to. private static readonly ConcurrentHashSet ExcludedDomains = new(StringComparer.OrdinalIgnoreCase); + // Drops every exclusion record. Called from LdapUtils.ResetUtils so a fresh enumeration + // pass after a configuration change isn't shadowed by stale exclusion state. + internal static void ClearExclusions() => ExcludedDomains.Clear(); + public LdapConnectionPool(string identifier, string poolIdentifier, LdapConfig config, IPortScanner scanner = null, NativeMethods nativeMethods = null, ILogger log = null, IMetricRouter metric = null) { _connections = []; @@ -693,7 +697,7 @@ private static TimeSpan GetNextBackoff(int retryCount) { string tempPath; if (CallDsGetDcName(queryParameters.DomainName, out var info) && info != null) { tempPath = Helpers.DomainNameToDistinguishedName(info.Value.DomainName); - connectionWrapper.SaveContext(queryParameters.NamingContext, basePath); + connectionWrapper.SaveContext(queryParameters.NamingContext, tempPath); } else { // Controlled replacement for LdapUtils.GetDomain + DomainNameToDistinguishedName. diff --git a/src/CommonLib/LdapQueryParameters.cs b/src/CommonLib/LdapQueryParameters.cs index 354bf730..2f2f09f6 100644 --- a/src/CommonLib/LdapQueryParameters.cs +++ b/src/CommonLib/LdapQueryParameters.cs @@ -3,46 +3,47 @@ using System.Threading; using SharpHoundCommonLib.Enums; -namespace SharpHoundCommonLib { - public class LdapQueryParameters { - private static int _queryIDIndex; - private string _searchBase; - private string _relativeSearchBase; - public string LDAPFilter { get; set; } - public SearchScope SearchScope { get; set; } = SearchScope.Subtree; - public string[] Attributes { get; set; } = Array.Empty(); - public string DomainName { get; set; } - public bool GlobalCatalog { get; set; } - public bool IncludeSecurityDescriptor { get; set; } = false; - public bool IncludeDeleted { get; set; } = false; - private int QueryID { get; } - - public LdapQueryParameters() { - QueryID = _queryIDIndex; - Interlocked.Increment(ref _queryIDIndex); - } +namespace SharpHoundCommonLib; + +public class LdapQueryParameters { + private static int _queryIDIndex; + private string _relativeSearchBase; + private string _searchBase; + + public LdapQueryParameters() { + QueryID = _queryIDIndex; + Interlocked.Increment(ref _queryIDIndex); + } - public string SearchBase { - get => _searchBase; - set { - _relativeSearchBase = null; - _searchBase = value; - } + public string LDAPFilter { get; set; } + public SearchScope SearchScope { get; set; } = SearchScope.Subtree; + public string[] Attributes { get; set; } = Array.Empty(); + public string DomainName { get; set; } + public bool GlobalCatalog { get; set; } + public bool IncludeSecurityDescriptor { get; set; } = false; + public bool IncludeDeleted { get; set; } = false; + private int QueryID { get; } + + public string SearchBase { + get => _searchBase; + set { + _relativeSearchBase = null; + _searchBase = value; } + } - public string RelativeSearchBase { - get => _relativeSearchBase; - set { - _relativeSearchBase = value; - _searchBase = null; - } + public string RelativeSearchBase { + get => _relativeSearchBase; + set { + _relativeSearchBase = value; + _searchBase = null; } + } - public NamingContext NamingContext { get; set; } = NamingContext.Default; + public NamingContext NamingContext { get; set; } = NamingContext.Default; - public string GetQueryInfo() - { - return $"Query Information - Filter: {LDAPFilter}, Domain: {DomainName}, GlobalCatalog: {GlobalCatalog}, ADSPath: {SearchBase}, ID: {QueryID}"; - } + public string GetQueryInfo() { + return + $"Query Information - Filter: {LDAPFilter}, Domain: {DomainName}, GlobalCatalog: {GlobalCatalog}, ADSPath: {SearchBase}, ID: {QueryID}"; } } \ No newline at end of file diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 5e376912..1038fce9 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -30,23 +30,26 @@ namespace SharpHoundCommonLib { public class LdapUtils : ILdapUtils { - //This cache is indexed by domain sid - private static ConcurrentDictionary _domainCache = - new(StringComparer.OrdinalIgnoreCase); private static ConcurrentDictionary _domainInfoCache = new(StringComparer.OrdinalIgnoreCase); private static ConcurrentHashSet _domainControllers = new(StringComparer.OrdinalIgnoreCase); private static ConcurrentHashSet _unresolvablePrincipals = new(StringComparer.OrdinalIgnoreCase); - private static readonly ConcurrentDictionary DomainToForestCache = - new(StringComparer.OrdinalIgnoreCase); - - // Single-shot cache for the uncontrolled Domain.GetDomain() hint tier in - // ResolveEffectiveDomainHint. null = not attempted; "" = attempted and failed; otherwise the - // resolved domain name. Guarded by _uncontrolledGetDomainHintLock because the underlying RPC - // is slow and we only want it to fire once per process. Reset from ResetUtils. - private static string _uncontrolledGetDomainHint; - private static readonly object _uncontrolledGetDomainHintLock = new(); + // Coalesces concurrent first-time domain resolutions so that N callers asking for the + // same domain trigger one tier walk, not N. The first caller's pool/config/log is captured + // by the lazy; subsequent callers await the same task and see the same result. Entries + // are removed once the task settles, so a later cache miss for the same domain (e.g., + // post-ResetUtils) starts a fresh resolution rather than reusing a completed task. + private static readonly ConcurrentDictionary>> + _inFlightDomainResolutions = new(StringComparer.OrdinalIgnoreCase); + + // Tracks which domains are currently being resolved on the calling logical execution + // context. Reentrant resolution is by design: the pool tier's GetLdapConnection path + // calls back into GetDomainInfoStaticAsync to resolve the domain SID before binding. + // Without this guard the inner call would await the outer lazy that hasn't published + // yet, deadlocking on Lazy's self-recursion detection. AsyncLocal flows through async + // continuations, so it captures the recursion chain across any number of awaits. + private static readonly AsyncLocal> _resolvingDomains = new(); private static readonly ConcurrentDictionary SeenWellKnownPrincipals = new(); @@ -67,12 +70,11 @@ private readonly ConcurrentDictionary private readonly ILogger _log; private readonly IPortScanner _portScanner; private readonly NativeMethods _nativeMethods; - private readonly string _nullCacheKey = Guid.NewGuid().ToString(); // Per-instance cache for the no-hint Domain resolution. The OS-side resolution depends // on the calling thread's auth context and/or _ldapConfig credentials, neither of which - // is a usable cache key, so this state cannot live in the static _domainCache. Failures - // are not cached; the next call retries. + // is a usable cache key for a process-wide store. Failures are not cached; the next call + // retries. private Domain _currentDomain; private readonly object _currentDomainLock = new(); private static readonly Regex SIDRegex = new(@"^(S-\d+-\d+-\d+-\d+-\d+-\d+)(-\d+)?$"); @@ -316,23 +318,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame } public virtual async Task<(bool Success, string ForestName)> GetForest(string domain) { - if (DomainToForestCache.TryGetValue(domain, out var cachedForest)) { - return (true, cachedForest); - } - + // DomainInfo.ForestName is already memoized in _domainInfoCache, so a separate + // domain-to-forest dictionary would be a duplicate cache over the same key space. if (await GetDomainInfoAsync(domain) is (true, var domainInfo) && !string.IsNullOrEmpty(domainInfo?.ForestName)) { - DomainToForestCache.TryAdd(domain, domainInfo.ForestName); return (true, domainInfo.ForestName); } - var (success, forest) = await GetForestFromLdap(domain); - if (success) { - DomainToForestCache.TryAdd(domain, forest); - return (true, forest); - } - - return (false, null); + return await GetForestFromLdap(domain); } private async Task<(bool Success, string ForestName)> GetForestFromLdap(string domain) { @@ -472,6 +465,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame if (await GetDomainInfoAsync(domainName) is (true, var domainInfo) && !string.IsNullOrEmpty(domainInfo?.DomainSid)) { Cache.AddDomainSidMapping(domainName, domainInfo.DomainSid); + // Also seed the canonical FQDN keyed write so the SID->Name slot is populated + // even when the caller passed a NetBIOS alias. AddDomainSidMapping gates the + // SID->Name direction on the name being DNS-shaped, so the NetBIOS-keyed call + // above only writes Name->SID; this second call fills in the FQDN side. + if (!string.IsNullOrEmpty(domainInfo.Name) && + !string.Equals(domainName, domainInfo.Name, StringComparison.OrdinalIgnoreCase)) { + Cache.AddDomainSidMapping(domainInfo.Name, domainInfo.DomainSid); + } return (true, domainInfo.DomainSid); } @@ -527,14 +528,11 @@ public bool GetDomain(string domainName, out Domain domain) { } // A blank/whitespace name is the no-target form. Delegate so the per-instance - // _currentDomain handles it without a per-instance GUID sentinel against the - // process-wide static cache. + // _currentDomain handles it. if (string.IsNullOrWhiteSpace(domainName)) { return GetDomain(out domain); } - if (_domainCache.TryGetValue(domainName, out domain)) return true; - try { var context = _ldapConfig.Username != null ? new DirectoryContext(DirectoryContextType.Domain, domainName, _ldapConfig.Username, @@ -543,17 +541,7 @@ public bool GetDomain(string domainName, out Domain domain) { // Blocking External Call domain = Domain.GetDomain(context); - if (domain == null) return false; - - // Cache under the caller's input AND the canonical name returned by SDS. The - // two may differ (e.g. NetBIOS alias vs DNS canonical), so writing both keys - // ensures alias-form callers don't miss while future canonical-form callers - // still benefit. - _domainCache.TryAdd(domainName, domain); - if (!string.IsNullOrWhiteSpace(domain.Name)) { - _domainCache.TryAdd(domain.Name, domain); - } - return true; + return domain != null; } catch (Exception e) { _log.LogDebug(e, "GetDomain call failed for domain name {Name}", domainName); @@ -579,9 +567,8 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai return false; } - // The static overload has no per-instance state to cache a no-hint resolution - // against, and ConcurrentDictionary throws on a null key. Reject up front rather - // than proceeding with an unkeyable resolution. + // The static overload has no per-instance state to anchor a no-hint resolution to. + // Reject up front rather than proceeding with a resolution we can't attribute. if (string.IsNullOrWhiteSpace(domainName)) { Logging.Logger.LogDebug( "Static GetDomain short-circuited: domainName is null or whitespace"); @@ -589,8 +576,6 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai return false; } - if (_domainCache.TryGetValue(domainName, out domain)) return true; - try { var context = ldapConfig.Username != null ? new DirectoryContext(DirectoryContextType.Domain, domainName, ldapConfig.Username, @@ -599,13 +584,7 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai // Blocking External Call domain = Domain.GetDomain(context); - if (domain == null) return false; - - _domainCache.TryAdd(domainName, domain); - if (!string.IsNullOrWhiteSpace(domain.Name)) { - _domainCache.TryAdd(domain.Name, domain); - } - return true; + return domain != null; } catch (Exception e) { Logging.Logger.LogDebug("Static GetDomain call failed for domain {DomainName}: {Error}", domainName, @@ -618,9 +597,7 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai /// /// Attempts to get the Domain object representing the user's current domain. The /// resolution depends on the calling thread's auth context and configured credentials, - /// so the result is cached per instance rather than in the - /// shared static cache. Successful resolutions also seed the static cache under the - /// canonical DNS name so that future explicit-name callers benefit. + /// so the result is cached per instance. /// public bool GetDomain(out Domain domain) { if (!_ldapConfig.AllowFallbackToUncontrolledLdap) { @@ -660,16 +637,8 @@ public bool GetDomain(out Domain domain) { // Blocking External Call var resolved = Domain.GetDomain(context); - if (resolved == null) { - domain = null; - return false; - } _currentDomain = resolved; - if (!string.IsNullOrWhiteSpace(resolved.Name)) { - _domainCache.TryAdd(resolved.Name, resolved); - } - domain = resolved; return true; } @@ -1167,6 +1136,12 @@ await GetDomainSidFromDomainName(forestName) is (true, var forestDomainSid)) { public void SetLdapConfig(LdapConfig config) { _ldapConfig = config; _log.LogInformation("New LDAP Config Set:\n {ConfigString}", config.ToString()); + // _currentDomain was resolved under the previous credentials/server, both of which + // can have just changed. Drop it so the next GetDomain(out _) re-resolves against the + // new auth context instead of returning a stale Domain bound to the old config. + lock (_currentDomainLock) { + _currentDomain = null; + } _connectionPool.Dispose(); _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); } @@ -1252,17 +1227,47 @@ internal static int CompletenessScore(DomainInfo info) { /// CN=Partitions for . Because the static helper /// is invoked re-entrantly from /// during pool acquisition, the sparser one-shot direct-LDAP record reaches the cache first; - /// without this helper the subsequent pool-derived record would be silently discarded by + /// without the score guard the subsequent pool-derived record would be silently discarded by /// TryAdd and every later cache hit would observe the partial record. + /// + /// The cross-domain guard () prevents cache poisoning when + /// a misconfigured pin or a cross-forest leak causes a tier + /// to return a describing a different domain than the one the + /// caller asked about. On every successful write the entry is also mirrored under + /// when that differs from , so + /// subsequent lookups by a different alias form (NetBIOS short name vs. DNS FQDN) hit the + /// same richest-available record. + /// /// internal static void CacheDomainInfo(string key, DomainInfo candidate) { if (key == null || candidate == null) return; - _domainInfoCache.AddOrUpdate( - key, - candidate, - (_, existing) => CompletenessScore(candidate) > CompletenessScore(existing) - ? candidate - : existing); + if (!KeyMatchesCandidate(key, candidate)) return; + + DomainInfo PickRicher(string _, DomainInfo existing) + => CompletenessScore(candidate) > CompletenessScore(existing) ? candidate : existing; + + _domainInfoCache.AddOrUpdate(key, candidate, PickRicher); + + if (!string.IsNullOrWhiteSpace(candidate.Name) + && !string.Equals(key, candidate.Name, StringComparison.OrdinalIgnoreCase)) { + _domainInfoCache.AddOrUpdate(candidate.Name, candidate, PickRicher); + } + } + + /// + /// Returns true when identifies the same domain that + /// describes, comparing case-insensitively against + /// and . A candidate + /// with no is treated as unverifiable and accepted; the + /// resolution tiers always populate Name on success, so this branch only matters for + /// hand-built records in tests. + /// + private static bool KeyMatchesCandidate(string key, DomainInfo candidate) { + if (string.IsNullOrEmpty(candidate.Name)) return true; + if (string.Equals(key, candidate.Name, StringComparison.OrdinalIgnoreCase)) return true; + if (!string.IsNullOrEmpty(candidate.NetBiosName) + && string.Equals(key, candidate.NetBiosName, StringComparison.OrdinalIgnoreCase)) return true; + return false; } /// @@ -1297,87 +1302,15 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en /// AuthType, signing, cert verification, credentials). /// /// - /// Resolution order: - /// - /// Static cache lookup keyed by the effective domain hint (the explicit - /// when supplied, otherwise the resolved current-user - /// domain via ). Falls back to - /// only when no hint can be resolved. - /// Controlled LDAP resolution via the connection pool (see ). - /// Uncontrolled fallback via System.DirectoryServices.ActiveDirectory.Domain.GetDomain, - /// gated on (see ). - /// - /// Successful results from either path are cached for the lifetime of the process (until - /// is invoked). + /// Walks with this instance's connection pool and + /// config, after substituting a current-user domain hint via + /// when is empty. + /// Successful results are cached in the static for the + /// lifetime of the process (until is invoked). /// - public async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) { - // Canonicalize the cache key BEFORE the lookup. Without this, a null domainName was - // stored under a per-instance GUID sentinel, fragmenting the static _domainInfoCache: - // N LdapUtils instances each wrote a separate entry for the same effective domain, - // and a later call with the explicit DNS name missed the entry written under null. - // ResolveEffectiveDomainHint returns explicit arguments verbatim and single-shot-caches - // the uncontrolled tier in _uncontrolledGetDomainHint, so this is essentially free. - var effectiveDomain = ResolveEffectiveDomainHint(domainName); - var cacheKey = !string.IsNullOrWhiteSpace(effectiveDomain) ? effectiveDomain : _nullCacheKey; - if (_domainInfoCache.TryGetValue(cacheKey, out var cached)) { - return (true, cached); - } - - // Preferred path: every LDAP call made here flows through LdapConnectionPool and - // therefore honors every flag on LdapConfig. Pass the already-resolved hint so the - // controlled adapter does not redo ResolveEffectiveDomainHint internally. - var (controlledOk, controlledInfo) = await ResolveDomainInfoControlledAsync(effectiveDomain); - if (controlledOk) { - CacheDomainInfo(cacheKey, controlledInfo); - return (true, controlledInfo); - } - - // Secondary controlled path: bind directly to the domain name with a one-shot - // LdapConnection honoring LdapConfig. Tried before ADSI because this tier is the - // only one that can express fine-grained LdapConfig.AuthType and - // LdapConfig.DisableCertVerification - running ADSI first would silently ignore - // those flags whenever the ADSI bind happened to succeed. - if (!string.IsNullOrWhiteSpace(effectiveDomain)) { - var (directOk, directInfo) = - await TryResolveDomainInfoViaDirectLdapAsync(effectiveDomain, _ldapConfig, _log); - if (directOk) { - CacheDomainInfo(cacheKey, directInfo); - return (true, directInfo); - } - } - - // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding invokes - // DsGetDcName internally, so NetBIOS short names and domains without a direct DNS - // A record still bind here after the raw-LDAP tier above could not resolve them. - if (!string.IsNullOrWhiteSpace(effectiveDomain)) { - var (adsiOk, adsiInfo) = - await TryResolveDomainInfoViaDirectoryEntryAsync(effectiveDomain, _ldapConfig, _log); - if (adsiOk) { - // ADSI populates PrimaryDomainController via DsGetDcName. Retrying the - // direct-LDAP path against that discovered DC closes the score gap when the - // original raw-LDAP attempt failed only because the domain name itself had - // no resolvable A/SRV record from this host. - adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync( - effectiveDomain, adsiInfo, _ldapConfig, _log); - CacheDomainInfo(cacheKey, adsiInfo); - return (true, adsiInfo); - } - } - - // Opt-in fallback. TryGetDomainInfoViaUncontrolledFallback short-circuits when - // AllowFallbackToUncontrolledLdap is disabled, so when the flag is off this call is a no-op. - if (TryGetDomainInfoViaUncontrolledFallback(effectiveDomain, _ldapConfig, _log, out var fallbackInfo)) { - // Same enrichment opportunity as the ADSI tier - the uncontrolled fallback - // populates both PrimaryDomainController and DomainControllers but never - // NetBiosName, so the discovered DC name is enough to reach a score-7 record - // via a controlled retry. - fallbackInfo = await TryEnrichDomainInfoViaDirectLdapAsync( - effectiveDomain, fallbackInfo, _ldapConfig, _log); - CacheDomainInfo(cacheKey, fallbackInfo); - return (true, fallbackInfo); - } - - return (false, null); + public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) { + var hint = ResolveEffectiveDomainHint(domainName); + return ResolveDomainInfoAsync(hint, _connectionPool, _ldapConfig, _log); } /// @@ -1398,41 +1331,118 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en /// Config used to honor the gate and to build fallback credentials. /// Logger used for debug-level diagnostics on failure paths. /// - /// Results from both paths are stored in the same static - /// used by the instance overload, so a successful lookup here also benefits later - /// calls on the same process. + /// Results are stored in the same static used by the + /// instance overload, so a successful lookup here also benefits later + /// calls on the same process. The pool tier is + /// skipped here because no is available; the remaining + /// three tiers (one-shot direct LDAP, ADSI, uncontrolled fallback) still run. /// - internal static async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoStaticAsync( + internal static Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoStaticAsync( string domainName, LdapConfig config, ILogger log = null) { - // Unlike the instance overload, the static entry point does not fabricate a current-domain - // hint - its callers always know which domain they are asking about. + return ResolveDomainInfoAsync(domainName, pool: null, config, log); + } + + /// + /// Shared resolution entry point that drives the four-tier walk for both + /// and . + /// Tier 1 (pool-driven controlled LDAP) is skipped when is null. + /// Tiers 2-4 (one-shot direct LDAP, ADSI, uncontrolled fallback) run unconditionally. + /// + /// + /// Concurrent first-time resolutions for the same domain are coalesced through + /// so duplicate callers observe the same + /// richest-tier-published record instead of each issuing the full tier walk independently. + /// The first caller's , , and + /// are captured by the lazy; subsequent awaiters inherit those. + /// In practice all instances in a process share the same effective + /// config, so this matches the ambient assumption already made by the static + /// . + /// + /// A pool-equipped caller treats a cached record produced by a non-pool tier + /// (signaled by an absent , since only the pool tier + /// reads CN=Partitions) as still re-resolvable. Otherwise an earlier sparse write + /// from a re-entrant call would permanently shadow + /// the richer record this caller's pool could produce. + /// + /// + private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoAsync( + string domainName, ConnectionPoolManager pool, LdapConfig config, ILogger log) { if (string.IsNullOrWhiteSpace(domainName)) { return (false, null); } - if (_domainInfoCache.TryGetValue(domainName, out var cached)) { + if (_domainInfoCache.TryGetValue(domainName, out var cached) + && (pool == null || !string.IsNullOrEmpty(cached.NetBiosName))) { return (true, cached); } - // Secondary controlled path: a one-shot LdapConnection bound directly to the domain - // name, honoring LdapConfig. Tried before ADSI because this tier is the only one - // that can express fine-grained LdapConfig.AuthType and LdapConfig.DisableCertVerification. + // Reentrant resolution on the same async chain (pool tier -> GetDomainSidFromDomainName -> + // GetDomainInfoStaticAsync -> back here) bypasses the lazy: the outer lazy hasn't published + // a Task yet, so awaiting it would self-deadlock. The recursion is bounded because the + // inner call always runs with pool=null and so cannot re-enter the pool tier. + var resolving = _resolvingDomains.Value ??= new HashSet(StringComparer.OrdinalIgnoreCase); + if (!resolving.Add(domainName)) { + return await ResolveDomainInfoCoreAsync(domainName, pool, config, log); + } + + try { + var lazy = _inFlightDomainResolutions.GetOrAdd(domainName, + key => new Lazy>( + () => ResolveDomainInfoCoreAsync(key, pool, config, log), + LazyThreadSafetyMode.ExecutionAndPublication)); + + try { + return await lazy.Value.ConfigureAwait(false); + } + finally { + _inFlightDomainResolutions.TryRemove(domainName, out _); + } + } + finally { + resolving.Remove(domainName); + } + } + + /// + /// Drives the actual four-tier walk for . Wrapped by a + /// per-domain so concurrent first-time callers share the result. + /// + /// + /// Tier ordering rationale: the pool tier honors every flag and + /// is preferred when available. The one-shot direct-LDAP tier is tried before ADSI because + /// it is the only tier outside the pool that can express fine-grained + /// and - + /// running ADSI first would silently ignore those flags whenever its serverless bind + /// happened to succeed. The ADSI and uncontrolled-fallback tiers are followed by a + /// direct-LDAP enrichment pass against the discovered PDC so the cached record reaches the + /// same shape as the pool and one-shot tiers. + /// + private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoCoreAsync( + string domainName, ConnectionPoolManager pool, LdapConfig config, ILogger log) { + // Re-check inside the lazy: another caller may have published a satisfying record + // between our outer cache miss and this lazy's first execution. + if (_domainInfoCache.TryGetValue(domainName, out var cached) + && (pool == null || !string.IsNullOrEmpty(cached.NetBiosName))) { + return (true, cached); + } + + if (pool != null) { + var (poolOk, poolInfo) = await ResolveDomainInfoControlledAsyncCore(domainName, pool, log); + if (poolOk) { + CacheDomainInfo(domainName, poolInfo); + return (true, poolInfo); + } + } + var (directOk, directInfo) = await TryResolveDomainInfoViaDirectLdapAsync(domainName, config, log); if (directOk) { CacheDomainInfo(domainName, directInfo); return (true, directInfo); } - // Tertiary controlled path: ADSI. DirectoryEntry's serverless binding handles - // NetBIOS short names and domains whose DNS layout does not expose an A record on - // the domain name itself, at the cost of not honoring every LdapConfig flag. var (adsiOk, adsiInfo) = await TryResolveDomainInfoViaDirectoryEntryAsync(domainName, config, log); if (adsiOk) { - // Retry the direct-LDAP path against the DC discovered by ADSI so the cached - // record reaches the same shape as the pool/one-shot tiers. Mirrors the matching - // enrichment in the instance overload. - adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync( - domainName, adsiInfo, config, log); + adsiInfo = await TryEnrichDomainInfoViaDirectLdapAsync(domainName, adsiInfo, config, log); CacheDomainInfo(domainName, adsiInfo); return (true, adsiInfo); } @@ -1447,28 +1457,6 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en return (false, null); } - /// - /// Instance-level adapter over that supplies - /// this utils' connection pool and logger, and substitutes a current-domain hint when - /// is null/empty. - /// - /// - /// The hint is taken from when set, otherwise - /// from . The config override is the only workable - /// hint in netonly contexts (runas /netonly) and on workgroup machines, where the - /// OS API reports the local machine name rather than the target AD domain. If neither is - /// usable the connection pool will fail to resolve the hint and this method returns - /// (false, null) so the orchestrator can fall through to the uncontrolled fallback. - /// - private Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoControlledAsync(string domainName) { - var hint = ResolveEffectiveDomainHint(domainName); - if (string.IsNullOrWhiteSpace(hint)) { - return Task.FromResult<(bool, DomainInfo)>((false, null)); - } - - return ResolveDomainInfoControlledAsyncCore(hint, _connectionPool, _log); - } - /// /// Resolves the domain name to use when the caller did not supply one. Walks a five-step /// preference order designed to maximize resolution success without paying an RPC on the @@ -1494,7 +1482,9 @@ internal static DomainInfo SelectRicherDomainInfo(DomainInfo seed, DomainInfo en /// target, letting the SDS/DsGetDcName stack resolve the current user's domain from the /// thread's outbound authentication context. In runas /netonly that context is the /// alt credential (not the local primary token that step 3 failed on), so this recovers - /// the real target domain. Result cached process-wide after the first successful attempt. + /// the real target domain. The RPC is issued on every call that reaches this tier; the + /// resolved hint becomes the key on success so subsequent + /// resolutions for the same domain short-circuit at the controlled-LDAP tier. /// Last-resort even when it equals the /// machine name. Downstream tiers will almost certainly fail to bind against this, but /// returning the env var here keeps behavior identical to the pre-change code for users who @@ -1551,12 +1541,6 @@ internal string ResolveEffectiveDomainHint(string domainName) { /// supplying it to could misdirect the locator; the /// credential-less form instead lets Windows use whatever authentication context is already /// bound to the thread. - /// - /// Result is cached process-wide in under - /// . The lock serializes the RPC so it fires at - /// most once per cycle even under concurrent callers. A stored empty - /// string is a "tried and failed" sentinel so repeat callers short-circuit without retrying. - /// /// private static bool TryResolveHintViaUncontrolledGetDomain( LdapConfig config, ILogger log, out string domainName) { @@ -1564,43 +1548,31 @@ private static bool TryResolveHintViaUncontrolledGetDomain( // Hard gate: uncontrolled calls require explicit opt-in. if (config is not { AllowFallbackToUncontrolledLdap: true }) return false; - + if (!string.IsNullOrWhiteSpace(config.Server)) { - log.LogDebug( + log?.LogDebug( "TryResolveHintViaUncontrolledGetDomain(\"{Name}\", out string DomainName) short-circuited: Specific Server is set", domainName); return false; } - lock (_uncontrolledGetDomainHintLock) { - // Previously resolved or previously failed - either way, no new RPC. - if (_uncontrolledGetDomainHint != null) { - if (_uncontrolledGetDomainHint.Length == 0) return false; - domainName = _uncontrolledGetDomainHint; + try { + // No name, no credentials: SDS resolves via the thread's outbound auth context, + // which is what surfaces the alt-creds domain under runas /netonly. + var ctx = new DirectoryContext(DirectoryContextType.Domain); + var domain = Domain.GetDomain(ctx); + var name = domain?.Name; + if (!string.IsNullOrEmpty(name)) { + domainName = name; return true; } - - try { - // No name, no credentials: SDS resolves via the thread's outbound auth context, - // which is what surfaces the alt-creds domain under runas /netonly. - var ctx = new DirectoryContext(DirectoryContextType.Domain); - var domain = Domain.GetDomain(ctx); - var name = domain?.Name; - if (!string.IsNullOrEmpty(name)) { - _uncontrolledGetDomainHint = name; - domainName = name; - return true; - } - } - catch (Exception e) { - log?.LogDebug(e, - "TryResolveHintViaUncontrolledGetDomain: Domain.GetDomain failed"); - } - - // Negative-cache the failure so repeat cache-miss paths don't re-issue the RPC. - _uncontrolledGetDomainHint = string.Empty; - return false; } + catch (Exception e) { + log?.LogDebug(e, + "TryResolveHintViaUncontrolledGetDomain: Domain.GetDomain failed"); + } + + return false; } /// @@ -2369,15 +2341,12 @@ private static (bool Success, DomainInfo DomainInfo) ResolveDomainInfoFromConnec public void ResetUtils() { _unresolvablePrincipals.Clear(); - _domainCache.Clear(); _domainInfoCache.Clear(); _domainControllers.Clear(); lock (_currentDomainLock) { _currentDomain = null; } - lock (_uncontrolledGetDomainHintLock) { - _uncontrolledGetDomainHint = null; - } + LdapConnectionPool.ClearExclusions(); _connectionPool?.Dispose(); _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); diff --git a/test/unit/CacheTest.cs b/test/unit/CacheTest.cs index 97814ada..324cd4d8 100644 --- a/test/unit/CacheTest.cs +++ b/test/unit/CacheTest.cs @@ -128,6 +128,71 @@ public void AddDomainSidMapping_NullOrEmptyArgumentsAreIgnored(string key, strin Cache.SetCacheInstance(null); } + [Fact] + public void AddDomainSidMapping_NetBiosName_DoesNotPoisonSidToNameSlot() + { + // A NetBIOS-only write must not populate the SID->Name slot, because consumers of + // that slot expect a DNS-shaped FQDN (LDAP base DN construction, server selection, + // GetDomainInfoAsync hint resolution). Leaving the slot empty lets a later + // FQDN-keyed write populate it correctly. + Cache.SetCacheInstance(Cache.CreateNewCache()); + + const string sid = "S-1-5-21-1212121212-3434343434-5656565656"; + const string netbios = "CONTOSO"; + + Cache.AddDomainSidMapping(sid, netbios); + + Assert.False(Cache.GetDomainSidMapping(sid, out _)); + Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedSid)); + Assert.Equal(sid, resolvedSid); + + Cache.SetCacheInstance(null); + } + + [Fact] + public void AddDomainSidMapping_NetBiosName_ReverseArgumentOrder_DoesNotPoisonSidToNameSlot() + { + // Same poisoning vector with arguments reversed: AddDomainSidMapping(netbios, sid). + Cache.SetCacheInstance(Cache.CreateNewCache()); + + const string sid = "S-1-5-21-7878787878-9090909090-1212121212"; + const string netbios = "FABRIKAM"; + + Cache.AddDomainSidMapping(netbios, sid); + + Assert.False(Cache.GetDomainSidMapping(sid, out _)); + Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedSid)); + Assert.Equal(sid, resolvedSid); + + Cache.SetCacheInstance(null); + } + + [Fact] + public void AddDomainSidMapping_NetBiosThenFqdn_FqdnPopulatesSidToNameSlot() + { + // After a NetBIOS-keyed write leaves the SID->Name slot empty, a subsequent + // FQDN-keyed write must fill it with the canonical DNS name. This is the key + // behavior that makes the gating safe: the slot stays available for the richer + // resolver to populate. + Cache.SetCacheInstance(Cache.CreateNewCache()); + + const string sid = "S-1-5-21-3434343434-5656565656-7878787878"; + const string netbios = "CONTOSO"; + const string fqdn = "CONTOSO.LOCAL"; + + Cache.AddDomainSidMapping(netbios, sid); + Cache.AddDomainSidMapping(fqdn, sid); + + Assert.True(Cache.GetDomainSidMapping(sid, out var resolvedName)); + Assert.Equal(fqdn, resolvedName); + Assert.True(Cache.GetDomainSidMapping(netbios, out var resolvedFromNetbios)); + Assert.Equal(sid, resolvedFromNetbios); + Assert.True(Cache.GetDomainSidMapping(fqdn, out var resolvedFromFqdn)); + Assert.Equal(sid, resolvedFromFqdn); + + Cache.SetCacheInstance(null); + } + [Fact] public void SetCacheInstance_AfterDeserialization_RestoresCaseInsensitiveSidToDomain() { @@ -230,5 +295,68 @@ public void SetCacheInstance_AfterDeserialization_RestoresCaseInsensitiveValueTo Cache.SetCacheInstance(null); } + + [Fact] + public void SetCacheInstance_AfterDeserialization_HealsCaseCollidingKeys() + { + // A persisted cache produced before the case-insensitive invariant was applied + // (or hand-edited / corrupted on disk) can contain two keys that differ only by + // case in the same dictionary. Deserialization rebuilds a case-sensitive + // ConcurrentDictionary, so both keys survive the round trip. The rewrap must + // tolerate the collision (first writer wins) instead of throwing from the + // ConcurrentDictionary(IEnumerable, IEqualityComparer) constructor. + // Each dictionary contains a pair of keys differing only by case. In a + // case-sensitive ConcurrentDictionary (what the deserializer rebuilds) both keys + // survive; rewrapping with OrdinalIgnoreCase against that source is the path that + // previously threw ArgumentException. + const string json = @"{ + ""SIDToDomainCache"": { + ""CONTOSO.LOCAL"": ""S-1-5-21-1-2-3"", + ""contoso.local"": ""S-1-5-21-1-2-3"" + }, + ""GlobalCatalogCache"": { + ""CONTOSO.LOCAL"": [""gc1.contoso.local""], + ""contoso.local"": [""gc1.contoso.local""] + }, + ""ValueToIdCache"": { + ""Administrator|CONTOSO.LOCAL"": ""S-1-5-21-1-2-3-500"", + ""administrator|contoso.local"": ""S-1-5-21-1-2-3-500"" + }, + ""IdToTypeCache"": {}, + ""MachineSidCache"": {}, + ""CacheCreationDate"": ""0001-01-01T00:00:00"", + ""CacheCreationVersion"": ""1.0.0"" + }"; + var settings = new JsonSerializerSettings + { + ObjectCreationHandling = ObjectCreationHandling.Replace + }; + var deserialized = JsonConvert.DeserializeObject(json, settings); + + // Sanity: the deserialized dictionaries actually contain the colliding keys. + // Without this, the test would not exercise the constructor's duplicate-key path. + Assert.Equal(2, deserialized.SIDToDomainCache.Count); + Assert.Equal(2, deserialized.GlobalCatalogCache.Count); + Assert.Equal(2, deserialized.ValueToIdCache.Count); + + // Pre-fix this call threw ArgumentException from the rewrap constructor. + var ex = Record.Exception(() => Cache.SetCacheInstance(deserialized)); + Assert.Null(ex); + + // Each cache collapses to a single entry per case-insensitive key (first wins). + Assert.Single(deserialized.SIDToDomainCache); + Assert.Single(deserialized.GlobalCatalogCache); + Assert.Single(deserialized.ValueToIdCache); + + // Lookups succeed under either casing after the rewrap. + Assert.True(Cache.GetDomainSidMapping("contoso.local", out var resolvedSid)); + Assert.Equal("S-1-5-21-1-2-3", resolvedSid); + Assert.True(Cache.GetGCCache("contoso.local", out var gcs)); + Assert.Single(gcs); + Assert.True(Cache.GetPrefixedValue("administrator", "contoso.local", out var resolvedId)); + Assert.Equal("S-1-5-21-1-2-3-500", resolvedId); + + Cache.SetCacheInstance(null); + } } } diff --git a/test/unit/LDAPUtilsTest.cs b/test/unit/LDAPUtilsTest.cs index 6f2321c3..b9f56391 100644 --- a/test/unit/LDAPUtilsTest.cs +++ b/test/unit/LDAPUtilsTest.cs @@ -946,6 +946,121 @@ public async Task CacheDomainInfo_NullKeyOrCandidate_NoOp() { Assert.Null(cached); } + [Fact] + public async Task CacheDomainInfo_KeyMismatchesCandidateName_NoOp() { + new LdapUtils().ResetUtils(); + const string key = "h2-contoso.test"; + + // Simulates a misconfigured LdapConfig.Server pin that lands on a DC outside the + // requested domain - the candidate's Name describes fabrikam, but the caller asked + // about contoso. The guard must reject the write to prevent cache poisoning. + var fabrikam = new DomainInfo( + name: "H2-FABRIKAM.TEST", + distinguishedName: "DC=h2-fabrikam,DC=test", + domainSid: "S-1-5-21-9-9-9", + netBiosName: "FABRIKAM"); + LdapUtils.CacheDomainInfo(key, fabrikam); + + var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(key, new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + Assert.False(ok); + Assert.Null(cached); + } + + [Fact] + public async Task CacheDomainInfo_KeyMatchesNetBiosName_Cached() { + new LdapUtils().ResetUtils(); + const string netBiosKey = "H2NETBIOS"; + + // NetBIOS short-name keys are a legitimate alias form - the guard must accept the + // write when key matches candidate.NetBiosName even though it differs from Name. + var info = new DomainInfo( + name: "H2-NETBIOS-MATCH.TEST", + distinguishedName: "DC=h2-netbios-match,DC=test", + domainSid: "S-1-5-21-1-1-1", + netBiosName: "H2NETBIOS"); + LdapUtils.CacheDomainInfo(netBiosKey, info); + + var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(netBiosKey, new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + Assert.True(ok); + Assert.Equal("H2-NETBIOS-MATCH.TEST", cached.Name); + } + + [Fact] + public async Task CacheDomainInfo_KeyMatchesCandidateNameCaseInsensitive_Cached() { + new LdapUtils().ResetUtils(); + const string lowerKey = "h2-case.test"; + + var info = new DomainInfo(name: "H2-CASE.TEST", domainSid: "S-1-5-21-2-2-2"); + LdapUtils.CacheDomainInfo(lowerKey, info); + + var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(lowerKey, new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + Assert.True(ok); + Assert.Equal("S-1-5-21-2-2-2", cached.DomainSid); + } + + [Fact] + public async Task CacheDomainInfo_MirrorsEntryUnderCandidateName() { + new LdapUtils().ResetUtils(); + const string netBiosKey = "H1MIRROR"; + + // Write under the NetBIOS alias - the helper must mirror the entry under the + // canonical DNS Name so a later lookup by FQDN hits the same record without redoing + // resolution. + var info = new DomainInfo( + name: "H1-MIRROR.TEST", + distinguishedName: "DC=h1-mirror,DC=test", + domainSid: "S-1-5-21-3-3-3", + netBiosName: "H1MIRROR"); + LdapUtils.CacheDomainInfo(netBiosKey, info); + + var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync("H1-MIRROR.TEST", new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + Assert.True(ok); + Assert.Equal("H1MIRROR", cached.NetBiosName); + Assert.Equal("S-1-5-21-3-3-3", cached.DomainSid); + } + + [Fact] + public async Task CacheDomainInfo_RicherAliasWriteUpgradesNameKeyEntry() { + new LdapUtils().ResetUtils(); + const string fqdnKey = "h1-upgrade.test"; + const string netBiosKey = "H1UPGRADE"; + + // Pre-seed the canonical-Name entry with a sparse record (e.g. an earlier ADSI tier + // result). A later richer write under the NetBIOS alias must propagate through the + // mirror so a FQDN lookup observes the upgrade rather than the stale sparse entry. + var sparse = new DomainInfo(name: "H1-UPGRADE.TEST", domainSid: "S-1-5-21-4-4-4"); + LdapUtils.CacheDomainInfo(fqdnKey, sparse); + + var rich = new DomainInfo( + name: "H1-UPGRADE.TEST", + distinguishedName: "DC=h1-upgrade,DC=test", + domainSid: "S-1-5-21-4-4-4", + netBiosName: "H1UPGRADE", + primaryDomainController: "dc01.h1-upgrade.test", + domainControllers: new[] { "dc01.h1-upgrade.test" }); + LdapUtils.CacheDomainInfo(netBiosKey, rich); + + var (ok, cached) = await LdapUtils.GetDomainInfoStaticAsync(fqdnKey, new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + Assert.True(ok); + Assert.Equal("H1UPGRADE", cached.NetBiosName); + Assert.Equal("dc01.h1-upgrade.test", cached.PrimaryDomainController); + } + // --------------------------------------------------------------------------- // SelectRicherDomainInfo / TryEnrichDomainInfoViaDirectLdapAsync coverage // ---------------------------------------------------------------------------