diff --git a/src/CommonLib/ConnectionPoolManager.cs b/src/CommonLib/ConnectionPoolManager.cs index 61136cc7f..a01bcf8dd 100644 --- a/src/CommonLib/ConnectionPoolManager.cs +++ b/src/CommonLib/ConnectionPoolManager.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.DirectoryServices; -using System.Runtime.CompilerServices; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; @@ -146,17 +144,19 @@ private string ResolveIdentifier(string identifier) { //we expect this to fail sometimes } - if (LdapUtils.GetDomain(domainName, _ldapConfig, out var domainObject)) - try { - // TODO: MC - Confirm GetDirectoryEntry is not a Blocking External Call - if (domainObject.GetDirectoryEntry().ToDirectoryObject().TryGetSecurityIdentifier(out domainSid)) { - Cache.AddDomainSidMapping(domainName, domainSid); - return (true, domainSid); - } - } - catch { - //we expect this to fail sometimes (not sure why, but better safe than sorry) - } + // Controlled replacement for LdapUtils.GetDomain + GetDirectoryEntry. We pass pool: null + // because this method is called from inside GetPool -> ResolveIdentifier while resolving + // the pool for this same domain; reusing the pool here would reenter GetLdapConnection and + // recurse into GetDomainSidFromDomainName. With pool: null, GetDomainInfoStaticAsync falls + // through to its direct-LDAP (one-shot LdapConnection) path, which still honors LdapConfig. + // The call is sync-over-async to match the sibling pattern in GetLdapConnectionForServer. + var (infoOk, info) = LdapUtils + .GetDomainInfoStaticAsync(domainName, _ldapConfig, _log) + .GetAwaiter().GetResult(); + if (infoOk && !string.IsNullOrEmpty(info?.DomainSid)) { + Cache.AddDomainSidMapping(domainName, info.DomainSid); + return (true, info.DomainSid); + } foreach (var name in _translateNames) try { diff --git a/src/CommonLib/DomainInfo.cs b/src/CommonLib/DomainInfo.cs new file mode 100644 index 000000000..493b0e743 --- /dev/null +++ b/src/CommonLib/DomainInfo.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace SharpHoundCommonLib +{ + /// + /// Lightweight, transport-agnostic description of an Active Directory domain populated either + /// from controlled LDAP queries (honoring ) or, when explicitly opted in + /// via , from + /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain. + /// + public sealed class DomainInfo + { + /// Upper-cased DNS name of the domain (e.g. CONTOSO.LOCAL). + public string Name { get; } + + /// Default naming context distinguished name (e.g. DC=contoso,DC=local). + public string DistinguishedName { get; } + + /// Upper-cased DNS name of the forest root domain, when known. + public string ForestName { get; } + + /// Domain SID (S-1-5-21-...) if resolved, otherwise null. + public string DomainSid { get; } + + /// Legacy NetBIOS domain name if resolved from the Partitions container, otherwise null. + public string NetBiosName { get; } + + /// DNS hostname of the PDC FSMO role owner if resolved, otherwise null. + public string PrimaryDomainController { get; } + + /// DNS hostnames of known domain controllers for this domain. + public IReadOnlyList DomainControllers { get; } + + public DomainInfo( + string name = null, + string distinguishedName = null, + string forestName = null, + string domainSid = null, + string netBiosName = null, + string primaryDomainController = null, + IReadOnlyList domainControllers = null) { + Name = name; + DistinguishedName = distinguishedName; + ForestName = forestName; + DomainSid = domainSid; + NetBiosName = netBiosName; + PrimaryDomainController = primaryDomainController; + DomainControllers = domainControllers ?? Array.Empty(); + } + } +} diff --git a/src/CommonLib/Enums/LDAPProperties.cs b/src/CommonLib/Enums/LDAPProperties.cs index 0bf6b726e..b9ee6f53e 100644 --- a/src/CommonLib/Enums/LDAPProperties.cs +++ b/src/CommonLib/Enums/LDAPProperties.cs @@ -96,5 +96,7 @@ public static class LDAPProperties public const string LockOutObservationWindow = "lockoutobservationwindow"; public const string PrincipalName = "msds-principalname"; public const string GroupType = "grouptype"; + public const string FSMORoleOwner = "fsmoroleowner"; + public const string NCName = "ncname"; } } diff --git a/src/CommonLib/ILdapUtils.cs b/src/CommonLib/ILdapUtils.cs index 753e53bd1..a6b17828d 100644 --- a/src/CommonLib/ILdapUtils.cs +++ b/src/CommonLib/ILdapUtils.cs @@ -89,6 +89,25 @@ IAsyncEnumerable> RangedRetrieval(string distinguishedName, /// True if the domain was found, false if not bool GetDomain(out System.DirectoryServices.ActiveDirectory.Domain domain); + /// + /// Resolves a for the specified domain using controlled LDAP queries + /// that honor the configured (server, port, SSL, auth, signing, cert verification). + /// Falls back to System.DirectoryServices.ActiveDirectory.Domain.GetDomain only when + /// is enabled. + /// + /// The domain name to resolve + /// A tuple containing success state as well as the populated DomainInfo if successful + Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName); + + /// + /// Resolves a for the user's current domain using controlled LDAP queries + /// that honor the configured . Falls back to + /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain only when + /// is enabled. + /// + /// A tuple containing success state as well as the populated DomainInfo if successful + Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(); + Task<(bool Success, string ForestName)> GetForest(string domain); /// /// Attempts to resolve an account name to its corresponding typed principal diff --git a/src/CommonLib/LdapConfig.cs b/src/CommonLib/LdapConfig.cs index 19e9995d0..6c33adb0a 100644 --- a/src/CommonLib/LdapConfig.cs +++ b/src/CommonLib/LdapConfig.cs @@ -15,6 +15,8 @@ public class LdapConfig public bool DisableCertVerification { get; set; } = false; public AuthType AuthType { get; set; } = AuthType.Kerberos; public int MaxConcurrentQueries { get; set; } = 15; + public bool AllowFallbackToUncontrolledLdap { get; set; } = false; + public string CurrentUserDomain { get; set; } = null; //Returns the port for connecting to LDAP. Will always respect a user's overridden config over anything else public int GetPort(bool ssl) @@ -56,6 +58,10 @@ public override string ToString() { sb.AppendLine($"ForceSSL: {ForceSSL}"); sb.AppendLine($"AuthType: {AuthType.ToString()}"); sb.AppendLine($"MaxConcurrentQueries: {MaxConcurrentQueries}"); + sb.AppendLine($"AllowFallbackToUncontrolledLdap: {AllowFallbackToUncontrolledLdap}"); + if (!string.IsNullOrWhiteSpace(CurrentUserDomain)) { + sb.AppendLine($"CurrentUserDomain: {CurrentUserDomain}"); + } if (!string.IsNullOrWhiteSpace(Username)) { sb.AppendLine($"Username: {Username}"); } diff --git a/src/CommonLib/LdapConnectionPool.cs b/src/CommonLib/LdapConnectionPool.cs index 4744cb402..fff843d9c 100644 --- a/src/CommonLib/LdapConnectionPool.cs +++ b/src/CommonLib/LdapConnectionPool.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.DirectoryServices.ActiveDirectory; using System.DirectoryServices.Protocols; using System.Linq; using System.Net; @@ -451,7 +450,7 @@ private async Task SetupLdapQuery(LdapQueryParameters quer return result; } - var (searchRequestSuccess, searchRequest) = CreateSearchRequest(queryParameters, connectionWrapper); + var (searchRequestSuccess, searchRequest) = await CreateSearchRequestAsync(queryParameters, connectionWrapper); if (!searchRequestSuccess) { result.Success = false; result.Message = "Failed to create search request"; @@ -492,7 +491,7 @@ public async IAsyncEnumerable> RangedRetrieval(string distinguish }; var connectionWrapper = connectionResult.ConnectionWrapper; - var (searchRequestSuccess, searchRequest) = CreateSearchRequest(queryParameters, connectionWrapper); + var (searchRequestSuccess, searchRequest) = await CreateSearchRequestAsync(queryParameters, connectionWrapper); if (!searchRequestSuccess) { ReleaseConnection(connectionWrapper); yield return Result.Fail("Failed to create search request"); @@ -685,23 +684,30 @@ private static TimeSpan GetNextBackoff(int retryCount) { MaxBackoffDelay.TotalSeconds)); } - private (bool, SearchRequest) CreateSearchRequest(LdapQueryParameters queryParameters, + private async Task<(bool, SearchRequest)> CreateSearchRequestAsync(LdapQueryParameters queryParameters, LdapConnectionWrapper connectionWrapper) { string basePath; if (!string.IsNullOrWhiteSpace(queryParameters.SearchBase)) { basePath = queryParameters.SearchBase; - } - else if (!connectionWrapper.GetSearchBase(queryParameters.NamingContext, out basePath)) { + } else if (!connectionWrapper.GetSearchBase(queryParameters.NamingContext, out basePath)) { string tempPath; if (CallDsGetDcName(queryParameters.DomainName, out var info) && info != null) { tempPath = Helpers.DomainNameToDistinguishedName(info.Value.DomainName); connectionWrapper.SaveContext(queryParameters.NamingContext, basePath); } - else if (LdapUtils.GetDomain(queryParameters.DomainName, _ldapConfig, out var domainObject)) { - tempPath = Helpers.DomainNameToDistinguishedName(domainObject.Name); - } else { - return (false, null); + // Controlled replacement for LdapUtils.GetDomain + DomainNameToDistinguishedName. + // Pass null for the pool: this code runs *inside* an LdapConnectionPool, so + // attempting controlled resolution via the pool would reenter us. The static + // helper will fall through to the uncontrolled fallback, which itself honors + // LdapConfig.AllowFallbackToUncontrolledLdap. + var (ok, domainInfo) = await LdapUtils + .GetDomainInfoStaticAsync(queryParameters.DomainName, _ldapConfig, _log); + if (!ok || string.IsNullOrWhiteSpace(domainInfo?.DistinguishedName)) { + return (false, null); + } + + tempPath = domainInfo.DistinguishedName; } basePath = queryParameters.NamingContext switch { @@ -873,16 +879,20 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti } } - if (!LdapUtils.GetDomain(_identifier, _ldapConfig, out var domainObject) || domainObject?.Name == null) { + // Controlled replacement for LdapUtils.GetDomain. The static helper still honors + // LdapConfig.AllowFallbackToUncontrolledLdap for the uncontrolled fallback. + var (infoOk, info) = + await LdapUtils.GetDomainInfoStaticAsync(_identifier, _ldapConfig, _log); + if (!infoOk || string.IsNullOrEmpty(info?.Name)) { //If we don't get a result here, we effectively have no other ways to resolve this domain, so we'll just have to exit out _log.LogDebug( - "Could not get domain object from GetDomain, unable to create ldap connection for domain {Domain}", + "Could not resolve domain info, unable to create ldap connection for domain {Domain}", _identifier); ExcludedDomains.Add(_identifier); - return (false, null, "Unable to get domain object for further strategies"); + return (false, null, "Unable to get domain info for further strategies"); } - tempDomainName = domainObject.Name.ToUpper().Trim(); + tempDomainName = info.Name.ToUpper().Trim(); if (!tempDomainName.Equals(_identifier, StringComparison.OrdinalIgnoreCase) && await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connectionWrapper4)) { @@ -892,24 +902,26 @@ await CreateLdapConnection(tempDomainName, globalCatalog) is (true, var connecti return (true, connectionWrapper4, ""); } - var primaryDomainController = domainObject.PdcRoleOwner.Name; - var portConnectionResult = - await CreateLDAPConnectionWithPortCheck(primaryDomainController, globalCatalog); - if (portConnectionResult.success) { - _log.LogDebug( - "Successfully created ldap connection for domain: {Domain} using strategy 5 with to pdc {Server}", - _identifier, primaryDomainController); - return (true, portConnectionResult.connection, ""); + if (!string.IsNullOrEmpty(info.PrimaryDomainController)) { + var primaryDomainController = info.PrimaryDomainController; + var portConnectionResult = + await CreateLDAPConnectionWithPortCheck(primaryDomainController, globalCatalog); + if (portConnectionResult.success) { + _log.LogDebug( + "Successfully created ldap connection for domain: {Domain} using strategy 5 with to pdc {Server}", + _identifier, primaryDomainController); + return (true, portConnectionResult.connection, ""); + } } - // Blocking External Call - Possible on domainObject.DomainControllers as it calls DsGetDcNameWrapper - foreach (DomainController dc in domainObject.DomainControllers) { - portConnectionResult = - await CreateLDAPConnectionWithPortCheck(dc.Name, globalCatalog); + foreach (var dcName in info.DomainControllers) { + if (string.IsNullOrEmpty(dcName)) continue; + var portConnectionResult = + await CreateLDAPConnectionWithPortCheck(dcName, globalCatalog); if (portConnectionResult.success) { _log.LogDebug( - "Successfully created ldap connection for domain: {Domain} using strategy 6 with to pdc {Server}", - _identifier, primaryDomainController); + "Successfully created ldap connection for domain: {Domain} using strategy 6 to dc {Server}", + _identifier, dcName); return (true, portConnectionResult.connection, ""); } } diff --git a/src/CommonLib/LdapUtils.cs b/src/CommonLib/LdapUtils.cs index 2ce52dd3e..684a17600 100644 --- a/src/CommonLib/LdapUtils.cs +++ b/src/CommonLib/LdapUtils.cs @@ -4,6 +4,7 @@ using System.DirectoryServices; using System.DirectoryServices.AccountManagement; using System.DirectoryServices.ActiveDirectory; +using System.DirectoryServices.Protocols; using System.Linq; using System.Net; using System.Net.Sockets; @@ -31,12 +32,21 @@ namespace SharpHoundCommonLib { public class LdapUtils : ILdapUtils { //This cache is indexed by domain sid private static ConcurrentDictionary _domainCache = new(); + 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(); + private static readonly ConcurrentDictionary SeenWellKnownPrincipals = new(); @@ -52,7 +62,7 @@ private readonly ConcurrentDictionary // Metrics private readonly IMetricRouter _metric; - + private readonly ILogger _log; private readonly IPortScanner _portScanner; private readonly NativeMethods _nativeMethods; @@ -280,8 +290,9 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame if (!securityIdentifier.Equals("S-1-5-9", StringComparison.OrdinalIgnoreCase)) { var tempDomain = domain; - if (GetDomain(tempDomain, out var domainObject) && domainObject.Name != null) { - tempDomain = domainObject.Name; + if (await GetDomainInfoAsync(tempDomain) is (true, var domainInfo) && + !string.IsNullOrEmpty(domainInfo?.Name)) { + tempDomain = domainInfo.Name; } return ($"{tempDomain}-{securityIdentifier}".ToUpper(), tempDomain); @@ -301,15 +312,10 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame return (true, cachedForest); } - if (GetDomain(domain, out var domainObject)) { - try { - var forestName = domainObject.Forest.Name.ToUpper(); - DomainToForestCache.TryAdd(domain, forestName); - return (true, forestName); - } - catch { - //pass - } + 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); @@ -395,12 +401,13 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame } private async Task<(bool Success, string DomainName)> ConvertDomainSidToDomainNameFromLdap(string domainSid) { - if (!GetDomain(out var domain) || domain?.Name == null) { + var (domainOk, domainInfo) = await GetDomainInfoAsync(); + if (!domainOk || string.IsNullOrEmpty(domainInfo?.Name)) { return (false, string.Empty); } var result = await Query(new LdapQueryParameters { - DomainName = domain.Name, + DomainName = domainInfo.Name, Attributes = new[] { LDAPProperties.DistinguishedName }, GlobalCatalog = true, LDAPFilter = new LdapFilter().AddDomains(CommonFilters.SpecificSID(domainSid)).GetFilter() @@ -411,7 +418,7 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame } result = await Query(new LdapQueryParameters { - DomainName = domain.Name, + DomainName = domainInfo.Name, Attributes = new[] { LDAPProperties.DistinguishedName, LDAPProperties.Name }, GlobalCatalog = true, LDAPFilter = new LdapFilter().AddFilter("(objectclass=trusteddomain)", true) @@ -423,7 +430,7 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame } result = await Query(new LdapQueryParameters { - DomainName = domain.Name, + DomainName = domainInfo.Name, Attributes = new[] { LDAPProperties.DistinguishedName }, LDAPFilter = new LdapFilter().AddFilter("(objectclass=domaindns)", true) .AddFilter(CommonFilters.SpecificSID(domainSid), true).GetFilter() @@ -452,17 +459,13 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame //we expect this to fail sometimes } - if (GetDomain(domainName, out var domainObject)) - try { - var entry = domainObject.GetDirectoryEntry().ToDirectoryObject(); - if (entry.TryGetSecurityIdentifier(out domainSid)) { - Cache.AddDomainSidMapping(domainName, domainSid); - return (true, domainSid); - } - } - catch { - //we expect this to fail sometimes (not sure why, but better safe than sorry) - } + // Replaces the legacy GetDomain(out Domain) + GetDirectoryEntry block with a + // controlled lookup via the connection pool. + if (await GetDomainInfoAsync(domainName) is (true, var domainInfo) && + !string.IsNullOrEmpty(domainInfo?.DomainSid)) { + Cache.AddDomainSidMapping(domainName, domainInfo.DomainSid); + return (true, domainInfo.DomainSid); + } foreach (var name in _translateNames) try { @@ -499,6 +502,14 @@ public IAsyncEnumerable> PagedQuery(LdapQueryParame /// /// public bool GetDomain(string domainName, out Domain domain) { + if (!_ldapConfig.AllowFallbackToUncontrolledLdap) { + _log.LogDebug( + "GetDomain(\"{Name}\", out Domain) short-circuited: AllowFallbackToUncontrolledLdap is disabled", + domainName); + domain = null; + return false; + } + var cacheKey = domainName ?? _nullCacheKey; if (_domainCache.TryGetValue(cacheKey, out domain)) return true; @@ -529,6 +540,14 @@ public bool GetDomain(string domainName, out Domain domain) { } public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domain domain) { + if (ldapConfig == null || !ldapConfig.AllowFallbackToUncontrolledLdap) { + Logging.Logger.LogDebug( + "Static GetDomain(\"{DomainName}\") short-circuited: AllowFallbackToUncontrolledLdap is disabled", + domainName); + domain = null; + return false; + } + if (_domainCache.TryGetValue(domainName, out domain)) return true; try { @@ -566,6 +585,13 @@ public static bool GetDomain(string domainName, LdapConfig ldapConfig, out Domai /// /// public bool GetDomain(out Domain domain) { + if (!_ldapConfig.AllowFallbackToUncontrolledLdap) { + _log.LogDebug( + "GetDomain(out Domain) short-circuited: AllowFallbackToUncontrolledLdap is disabled"); + domain = null; + return false; + } + if (_domainCache.TryGetValue(_nullCacheKey, out domain)) return true; try { @@ -724,7 +750,7 @@ public bool GetDomain(out Domain domain) { _log.LogTrace("CheckPort returned false for {HostName}.", hostname); return (false, default); } - + // Blocking External Call var result = await _callNetWkstaGetInfoAdaptiveTimeout.ExecuteNetAPIWithTimeout((_) => _nativeMethods.CallNetWkstaGetInfo(hostname)); @@ -1105,42 +1131,970 @@ public void SetLdapConfig(LdapConfig config) { //pass } - if (GetDomain(domain, out var domainObj)) { + // Controlled replacement for the old GetDomain(out Domain) + DomainNameToDistinguishedName + // block. DomainInfo.DistinguishedName is the default naming context DN; Configuration and + // Schema NCs are constructed from it the same way the old path constructed them. + if (await GetDomainInfoAsync(domain) is (true, var domainInfo) && + !string.IsNullOrWhiteSpace(domainInfo?.DistinguishedName)) { + var searchBase = context switch { + NamingContext.Configuration => $"CN=Configuration,{domainInfo.DistinguishedName}", + NamingContext.Schema => $"CN=Schema,CN=Configuration,{domainInfo.DistinguishedName}", + NamingContext.Default => domainInfo.DistinguishedName, + _ => throw new ArgumentOutOfRangeException() + }; + + return (true, searchBase); + } + + return (false, default); + } + + /// + /// Resolves a for the specified domain, preferring a controlled + /// LDAP path that honors the configured (server, port, SSL, + /// AuthType, signing, cert verification, credentials). + /// + /// + /// Resolution order: + /// + /// Static cache lookup keyed by (or when null). + /// 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). + /// + public async Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) { + // Null domain names are stored under a per-instance sentinel to match the existing + // _domainCache null-key convention used by the legacy GetDomain overloads. + var cacheKey = domainName ?? _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. + var (controlledOk, controlledInfo) = await ResolveDomainInfoControlledAsync(domainName); + if (controlledOk) { + _domainInfoCache.TryAdd(cacheKey, controlledInfo); + return (true, controlledInfo); + } + + // Resolve the effective domain hint so the remaining tiers can still operate when the + // caller passed null (netonly / workgroup hosts rely on LdapConfig.CurrentUserDomain here). + var effectiveDomain = ResolveEffectiveDomainHint(domainName); + + // 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) { + _domainInfoCache.TryAdd(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) { + _domainInfoCache.TryAdd(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)) { + _domainInfoCache.TryAdd(cacheKey, fallbackInfo); + return (true, fallbackInfo); + } + + return (false, null); + } + + /// + /// Convenience overload that resolves a for the user's current + /// domain. Equivalent to calling with null. + /// + public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync() { + return GetDomainInfoAsync(null); + } + + /// + /// Static counterpart to intended for internal + /// consumers that cannot hold an instance (notably + /// and , which are + /// themselves pieces of the connection infrastructure and can't reenter it transparently). + /// + /// The target domain. Must be non-empty; this entry point does not resolve a default. + /// 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. + /// + internal static async 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. + if (string.IsNullOrWhiteSpace(domainName)) { + return (false, null); + } + + if (_domainInfoCache.TryGetValue(domainName, out var cached)) { + 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. + var (directOk, directInfo) = await TryResolveDomainInfoViaDirectLdapAsync(domainName, config, log); + if (directOk) { + _domainInfoCache.TryAdd(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) { + _domainInfoCache.TryAdd(domainName, adsiInfo); + return (true, adsiInfo); + } + + if (TryGetDomainInfoViaUncontrolledFallback(domainName, config, log, out var fallbackInfo)) { + _domainInfoCache.TryAdd(domainName, fallbackInfo); + return (true, fallbackInfo); + } + + 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 + /// common path. + /// + /// + /// Order of preference: + /// + /// Explicit — caller-supplied; always wins. + /// — deterministic escape hatch for any + /// scenario where the OS-provided hint is wrong. Preferred over the uncontrolled tier below + /// because it's free and doesn't require opting into uncontrolled calls. + /// , only when it differs from + /// . The env var returning the machine name is a + /// reliable signal that the primary token carries no domain identity (netonly, workgroup, + /// LocalSystem with no stored creds). In every other case - normal interactive logons, + /// regular runas, service accounts in a joined machine's domain - the env var is + /// correct and this branch short-circuits the rest of the resolver at zero cost. + /// Uncontrolled DC-locator via Domain.GetDomain (see + /// ). Only fires when + /// is on. Issues + /// Domain.GetDomain(new DirectoryContext(Domain)) with no explicit credentials or + /// 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. + /// 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 + /// haven't opted into the uncontrolled fallback. + /// + /// + internal string ResolveEffectiveDomainHint(string domainName) { + // 1. Explicit argument wins unconditionally. + if (!string.IsNullOrWhiteSpace(domainName)) + return domainName; + + // 2. Config-provided override. Cheap, deterministic, no network I/O. + if (!string.IsNullOrWhiteSpace(_ldapConfig?.CurrentUserDomain)) + return _ldapConfig.CurrentUserDomain; + + // 3. Env var, but only when it actually carries a domain identity. UserDomainName == + // MachineName is the canonical signal for "no domain on the primary token" and covers + // netonly, workgroup, and LocalSystem cases without false positives on domain-joined + // setups (where the two always differ). + var envDomain = Environment.UserDomainName; + if (!string.IsNullOrWhiteSpace(envDomain) && + !string.Equals(envDomain, Environment.MachineName, StringComparison.OrdinalIgnoreCase)) { + return envDomain; + } + + // 4. Opt-in uncontrolled DC-locator. Only pays the RPC when step 3 determined the env var + // is unusable AND the caller has explicitly allowed uncontrolled calls. No credentials + // are passed - the SDS stack uses the thread's outbound auth context, which in netonly + // is the alt credential rather than the local primary token. + if (TryResolveHintViaUncontrolledGetDomain(_ldapConfig, _log, out var credDomain)) { + return credDomain; + } + + // 5. Preserve pre-change behavior: return the env var even if it's the machine name. + // Lets downstream tiers decide how to fail. + return envDomain; + } + + /// + /// Uncontrolled hint resolver that invokes Domain.GetDomain(new DirectoryContext(Domain)) + /// with no explicit credentials or target name, letting the SDS/DsGetDcName stack resolve the + /// domain from the current thread's outbound authentication context. Gated behind + /// because it makes an unmanaged + /// DC-locator RPC that bypasses every other flag. + /// + /// + /// Complements step 3 in : that step consults + /// , which reads the process's primary token. In + /// runas /netonly the primary token is the local machine, so that value is useless; + /// however the LSA still attaches the alt credential to the thread for outbound network auth, + /// and DsGetDcName uses that context to locate a DC for the alt credential's actual + /// domain. Passing no username/password here is deliberate - + /// is not guaranteed to be a UPN or downlevel name, so + /// 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) { + domainName = null; + + // Hard gate: uncontrolled calls require explicit opt-in. + if (config == null || !config.AllowFallbackToUncontrolledLdap) 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; + 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; + } + } + + /// + /// Core controlled-LDAP resolver. Builds a using only queries + /// that flow through the supplied , so every call + /// honors the attached to that pool. + /// + /// + /// Work performed, in order: + /// + /// Acquire a pooled connection to . The wrapper's cached rootDSE + /// supplies defaultNamingContext and configurationNamingContext without an extra round-trip. + /// Base search on the domain NC (objectClass=*) for objectSid (domain SID), + /// rootDomainNamingContext (forest root, used to compute ) + /// and fSMORoleOwner (PDC FSMO owner; stored as a DN to the NTDS Settings object). + /// If the PDC owner DN is resolved, a follow-up Base search on the owner's parent + /// server object retrieves dNSHostName to populate . + /// OneLevel search under CN=Partitions,<configNc> filtered by nCName + /// retrieves the legacy NetBIOS domain name from the matching crossRef object. + /// Subtree search on the domain NC for domain controllers + /// (userAccountControl:1.2.840.113556.1.4.803:=8192) collects DNS hostnames into + /// ; if the PDC lookup failed, the first DC is used as a fallback. + /// + /// All follow-up queries after the initial NC read are wrapped in try/catch and only + /// populate optional fields - a failure here still returns a partially-filled + /// . A failure + /// to acquire the connection or to read the default NC is fatal and returns (false, null). + /// + private static async Task<(bool Success, DomainInfo DomainInfo)> ResolveDomainInfoControlledAsyncCore( + string domainName, ConnectionPoolManager pool, ILogger log) { + if (string.IsNullOrWhiteSpace(domainName) || pool == null) { + return (false, null); + } + + // Acquire a pooled connection to harvest rootDSE-derived naming contexts. + var (ok, wrapper, _) = await pool.GetLdapConnection(domainName, false); + if (!ok || wrapper == null) { + return (false, null); + } + + // GetSearchBase reads from the wrapper's cached rootDSE entry populated when the + // connection was established - no additional LDAP traffic is issued here. We release + // the connection immediately so subsequent Query(...) calls can reuse it from the pool. + string defaultNc; + string configNc; + try { + wrapper.GetSearchBase(NamingContext.Default, out defaultNc); + wrapper.GetSearchBase(NamingContext.Configuration, out configNc); + } + finally { + pool.ReleaseConnection(wrapper); + } + + if (string.IsNullOrWhiteSpace(defaultNc)) { + return (false, null); + } + + // Canonical name is always derivable from the default NC (e.g. DC=contoso,DC=local -> CONTOSO.LOCAL). + var name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper(); + string domainSid = null; + string forestName = null; + string primaryDomainController = null; + string netBiosName = null; + IReadOnlyList domainControllers = null; + + // Base search on the domain NC harvests the domain SID, forest root NC, and PDC FSMO owner DN + // in a single round-trip. + try { + var baseRes = await pool.Query(new LdapQueryParameters { + DomainName = domainName, + SearchBase = defaultNc, + SearchScope = SearchScope.Base, + LDAPFilter = "(objectClass=*)", + Attributes = new[] { + LDAPProperties.ObjectSID, + LDAPProperties.RootDomainNamingContext, + LDAPProperties.FSMORoleOwner, + }, + }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync(); + + if (baseRes.IsSuccess) { + // objectSid on the domain NC itself is the domain SID (S-1-5-21-a-b-c). + if (baseRes.Value.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) { + domainSid = sid.ToUpper(); + } + + // rootDomainNamingContext points at the forest root domain's NC even when queried + // against a child domain, giving us the forest name without a separate GC lookup. + if (baseRes.Value.TryGetProperty(LDAPProperties.RootDomainNamingContext, out var rootNc) && + !string.IsNullOrEmpty(rootNc)) { + forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper(); + } + + // fSMORoleOwner on the domain NC is a DN to the NTDS Settings object of the PDC, + // e.g. "CN=NTDS Settings,CN=DC01,CN=Servers,CN=Default-First-Site-Name,...". + // Strip the NTDS Settings RDN to get the Server object DN, then read its dNSHostName. + if (baseRes.Value.TryGetProperty(LDAPProperties.FSMORoleOwner, out var fsmoOwner) && + TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) { + var pdcRes = await pool.Query(new LdapQueryParameters { + DomainName = domainName, + SearchBase = serverDn, + SearchScope = SearchScope.Base, + LDAPFilter = "(objectClass=*)", + Attributes = new[] { LDAPProperties.DNSHostName }, + }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync(); + + if (pdcRes.IsSuccess && + pdcRes.Value.TryGetProperty(LDAPProperties.DNSHostName, out var pdcName)) { + primaryDomainController = pdcName; + } + } + } + } + catch (Exception ex) { + log?.LogDebug(ex, "ResolveDomainInfoControlled: base query failed for {Domain}", domainName); + } + + // NetBIOS name lives on the crossRef entry whose nCName matches the domain NC. Cross-references + // live under CN=Partitions in the configuration NC, so this query only runs if we read the + // configuration NC above. + if (!string.IsNullOrWhiteSpace(configNc)) { + try { + var nbRes = await pool.Query(new LdapQueryParameters { + DomainName = domainName, + SearchBase = $"CN=Partitions,{configNc}", + SearchScope = SearchScope.OneLevel, + LDAPFilter = $"(&(objectClass=crossRef)({LDAPProperties.NCName}={defaultNc}))", + Attributes = new[] { LDAPProperties.NetbiosName }, + }).DefaultIfEmpty(LdapResult.Fail()).FirstOrDefaultAsync(); + + if (nbRes.IsSuccess && nbRes.Value.TryGetProperty(LDAPProperties.NetbiosName, out var nb)) { + netBiosName = nb; + } + } + catch (Exception ex) { + log?.LogDebug(ex, "ResolveDomainInfoControlled: netbios lookup failed for {Domain}", domainName); + } + } + + // DC enumeration uses the canonical "DC object = computer with SERVER_TRUST_ACCOUNT in UAC" + // bit test (0x2000). This is the same filter used elsewhere in the project via CommonFilters. + try { + var dcs = new List(); + var dcEnum = pool.Query(new LdapQueryParameters { + DomainName = domainName, + SearchBase = defaultNc, + LDAPFilter = CommonFilters.DomainControllers, + Attributes = new[] { LDAPProperties.DNSHostName }, + }); + await foreach (var dcRes in dcEnum) { + if (dcRes.IsSuccess && + dcRes.Value.TryGetProperty(LDAPProperties.DNSHostName, out var dcName) && + !string.IsNullOrEmpty(dcName)) { + dcs.Add(dcName); + } + } + + domainControllers = dcs; + // Last-resort PDC: if the FSMO resolution above failed, any DC is a reasonable + // fallback target for callers that just want a working DC name. + if (string.IsNullOrEmpty(primaryDomainController) && dcs.Count > 0) { + primaryDomainController = dcs[0]; + } + } + catch (Exception ex) { + log?.LogDebug(ex, "ResolveDomainInfoControlled: DC enumeration failed for {Domain}", domainName); + } + + return (true, new DomainInfo( + name: name, + distinguishedName: defaultNc, + forestName: forestName, + domainSid: domainSid, + netBiosName: netBiosName, + primaryDomainController: primaryDomainController, + domainControllers: domainControllers)); + } + + /// + /// Strips the leading CN=NTDS Settings, RDN from a FSMO role owner DN, yielding the + /// DN of the parent Server object. AD stores FSMO ownership as a DN pointing at the NTDS + /// Settings object nested under the server, but the dNSHostName attribute lives on + /// the server one level up. + /// + /// True if the prefix was found and a non-empty parent DN was produced. + internal static bool TryStripNtdsSettingsPrefix(string fsmoRoleOwnerDn, out string serverDn) { + serverDn = null; + if (string.IsNullOrEmpty(fsmoRoleOwnerDn)) return false; + const string prefix = "CN=NTDS Settings,"; + if (!fsmoRoleOwnerDn.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; + serverDn = fsmoRoleOwnerDn.Substring(prefix.Length); + return !string.IsNullOrEmpty(serverDn); + } + + /// + /// Uncontrolled fallback that populates a by calling + /// System.DirectoryServices.ActiveDirectory.Domain.GetDomain. + /// + /// + /// This path is intentionally opt-in: it returns false immediately unless + /// is set. When enabled, it mirrors + /// the exact construction used by the legacy + /// LdapUtils.GetDomain overloads so behavior is bit-identical to the pre-change code. + /// + /// The call into Domain.GetDomain does not honor any flag + /// beyond the username/password branches - server, port, SSL, signing, and cert-verification + /// settings are all bypassed because that API performs its own DC discovery via the native + /// DS RPC stack. + /// + /// + /// Every optional property access (Forest, PdcRoleOwner, DomainControllers, + /// GetDirectoryEntry) is wrapped in its own try/catch because each of these can + /// blocking-dial the network or attempt delegated auth and may fail independently even + /// when the top-level Domain was obtained successfully. + /// + /// + private static bool TryGetDomainInfoViaUncontrolledFallback(string domainName, LdapConfig config, + ILogger log, out DomainInfo info) { + info = null; + // Hard gate - when the flag is off this method is a no-op regardless of what the + // surrounding LDAP config looks like. + if (config == null || !config.AllowFallbackToUncontrolledLdap) { + return false; + } + + try { + // Matches the DirectoryContext construction in the legacy GetDomain overloads so + // enabling this fallback yields identical results to the pre-change code path. + DirectoryContext context; + if (config.Username != null) + context = domainName != null + ? new DirectoryContext(DirectoryContextType.Domain, domainName, config.Username, + config.Password) + : new DirectoryContext(DirectoryContextType.Domain, config.Username, config.Password); + else + context = domainName != null + ? new DirectoryContext(DirectoryContextType.Domain, domainName) + : new DirectoryContext(DirectoryContextType.Domain); + + // Blocking External Call + var domain = Domain.GetDomain(context); + if (domain == null) { + return false; + } + + var name = domain.Name?.ToUpper(); + var distinguishedName = !string.IsNullOrEmpty(domain.Name) + ? Helpers.DomainNameToDistinguishedName(domain.Name) + : null; + string forestName = null; + string primaryDomainController = null; + string domainSid = null; + IReadOnlyList domainControllers = null; + + // Forest lookup triggers a separate bind under the hood; swallow any failure and + // leave ForestName null rather than losing the rest of the DomainInfo. try { - var entry = domainObj.GetDirectoryEntry().ToDirectoryObject(); - if (entry.TryGetProperty(property, out var searchBase)) { - return (true, searchBase); + forestName = domain.Forest?.Name?.ToUpper(); + } + catch { + //pass + } + + // PdcRoleOwner.Name is the DNS hostname of the PDC. Separately guarded because + // it performs its own RPC lookup. + try { + primaryDomainController = domain.PdcRoleOwner?.Name; + } + catch { + //pass + } + + // DomainControllers enumeration discovers DCs via DsGetDcName; each property + // access on a returned controller can also fault independently. + try { + var dcs = new List(); + foreach (DomainController dc in domain.DomainControllers) { + try { + if (!string.IsNullOrEmpty(dc?.Name)) dcs.Add(dc.Name); + } + catch { + //pass + } } + + domainControllers = dcs; } catch { //pass } - var name = domainObj.Name; - if (!string.IsNullOrWhiteSpace(name)) { - var tempPath = Helpers.DomainNameToDistinguishedName(name); + // GetDirectoryEntry binds to the domain NC via SDS and reads objectSid. Cheapest + // way to get the domain SID from an already-resolved Domain object without another + // DirectoryContext round-trip. The raw DirectoryEntry inherits only the + // DirectoryContext credentials, so apply the LdapConfig transport/auth flags + // (matching Helpers.CreateDirectoryEntry) before the first property access forces + // the bind. + try { + var rawEntry = domain.GetDirectoryEntry(); + var authType = AuthenticationTypes.Secure; + if (config.ForceSSL) { + authType |= AuthenticationTypes.SecureSocketsLayer; + } + if (!config.DisableSigning && !config.ForceSSL) { + authType |= AuthenticationTypes.Signing | AuthenticationTypes.Sealing; + } + rawEntry.AuthenticationType = authType; + if (config.Username != null) { + rawEntry.Username = config.Username; + rawEntry.Password = config.Password; + } - var searchBase = context switch { - NamingContext.Configuration => $"CN=Configuration,{tempPath}", - NamingContext.Schema => $"CN=Schema,CN=Configuration,{tempPath}", - NamingContext.Default => tempPath, - _ => throw new ArgumentOutOfRangeException() + var entry = rawEntry.ToDirectoryObject(); + if (entry.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) { + domainSid = sid.ToUpper(); + } + } + catch { + //pass + } + + info = new DomainInfo( + name: name, + distinguishedName: distinguishedName, + forestName: forestName, + domainSid: domainSid, + primaryDomainController: primaryDomainController, + domainControllers: domainControllers); + return true; + } + catch (Exception e) { + log?.LogDebug(e, "TryGetDomainInfoViaUncontrolledFallback failed for domain {Name}", domainName); + return false; + } + } + + /// + /// Controlled resolver that uses ADSI () + /// via . Sits after the one-shot + /// path in and + /// , acting as a DC-locator-aware fallback for the + /// cases the raw-LDAP tier cannot resolve on its own. + /// + /// + /// ADSI's serverless binding transparently invokes DsGetDcName under the hood, so + /// NetBIOS short names and environments where has no direct + /// DNS A record still bind here after the preceding one-shot + /// tier could not locate a DC. ADSI honors , + /// , , + /// , and . It + /// cannot honor (no ADSI API) or + /// fine-grained selection (always Negotiate via + /// AuthenticationTypes.Secure), which is precisely why this tier is ordered *after* + /// the one-shot path: callers that configured those flags will have them respected by the + /// raw-LDAP tier and only reach ADSI if that tier failed outright. + /// + /// Populates , , + /// , , and + /// . Does not populate + /// or ; + /// those are only populated by the pool-based tier, which enumerates via subtree search. + /// + /// + /// ADSI property access is synchronous and blocking; the body is offloaded to a + /// thread-pool task so callers remain non-blocking. + /// + /// + private static async Task<(bool Success, DomainInfo DomainInfo)> TryResolveDomainInfoViaDirectoryEntryAsync( + string domainName, LdapConfig config, ILogger log) { + if (string.IsNullOrWhiteSpace(domainName) || config == null) { + return (false, null); + } + + return await Task.Run<(bool Success, DomainInfo DomainInfo)>(() => { + string name; + string distinguishedName; + string domainSid = null; + string forestName = null; + string primaryDomainController = null; + IDirectoryObject root; + try { + root = Helpers.CreateDirectoryEntry($"LDAP://{domainName}", config); + + // Force the bind by reading the DN. When the bind fails (unreachable DC, auth + // failure, etc.) TryGetDistinguishedName swallows the underlying COMException + // and returns false. + if (!root.TryGetDistinguishedName(out var defaultNc) || + string.IsNullOrWhiteSpace(defaultNc)) { + return (false, null); + } + + name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper(); + distinguishedName = defaultNc; + + if (root.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) { + domainSid = sid.ToUpper(); + } + } + catch (Exception e) { + log?.LogDebug(e, "DirectoryEntry tier: base bind failed for {Domain}", domainName); + return (false, null); + } + + // RootDSE on the same bound server yields the forest root NC without a separate GC bind. + try { + var rootDse = Helpers.CreateDirectoryEntry($"LDAP://{domainName}/RootDSE", config); + if (rootDse.TryGetProperty(LDAPProperties.RootDomainNamingContext, out var rootNc) && + !string.IsNullOrEmpty(rootNc)) { + forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper(); + } + } + catch (Exception ex) { + log?.LogDebug(ex, + "DirectoryEntry tier: RootDSE read failed for {Domain}", domainName); + } + + // fSMORoleOwner on the domain NC is the PDC NTDS Settings DN; its parent server + // object carries dNSHostName. Mirrors the extraction logic in the other tiers. + try { + if (root.TryGetProperty(LDAPProperties.FSMORoleOwner, out var fsmoOwner) && + TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) { + var server = Helpers.CreateDirectoryEntry($"LDAP://{serverDn}", config); + if (server.TryGetProperty(LDAPProperties.DNSHostName, out var pdc) && + !string.IsNullOrEmpty(pdc)) { + primaryDomainController = pdc; + } + } + } + catch (Exception ex) { + log?.LogDebug(ex, + "DirectoryEntry tier: PDC lookup failed for {Domain}", domainName); + } + + return (true, new DomainInfo( + name: name, + distinguishedName: distinguishedName, + forestName: forestName, + domainSid: domainSid, + primaryDomainController: primaryDomainController)); + }).ConfigureAwait(false); + } + + /// + /// Constructs and binds a one-shot + /// to honoring every option on (port, + /// SSL, signing, auth type, credentials, cert verification). This is the same configuration + /// shape used by LdapConnectionPool.CreateBaseConnection, duplicated here because + /// this code runs *outside* any pool and must not take a dependency on pool internals. + /// + /// + /// Tries SSL first then plain LDAP, respecting : when + /// ForceSSL is set, only the SSL attempt is made. Returns null if neither transport + /// binds. The returned connection is owned by the caller and must be disposed. + /// + private static LdapConnection TryBindOneShotLdapConnection( + string target, LdapConfig config, ILogger log) { + var transports = config.ForceSSL ? new[] { true } : new[] { true, false }; + foreach (var ssl in transports) { + LdapConnection connection = null; + try { + var port = config.GetPort(ssl); + var identifier = new LdapDirectoryIdentifier( + target, port, false, false); + connection = new LdapConnection(identifier) { + Timeout = TimeSpan.FromSeconds(30), }; + connection.SessionOptions.ProtocolVersion = 3; + connection.SessionOptions.ReferralChasing = + ReferralChasingOptions.None; + if (ssl) connection.SessionOptions.SecureSocketLayer = true; + + if (config.DisableSigning || ssl) { + connection.SessionOptions.Signing = false; + connection.SessionOptions.Sealing = false; + } else { + connection.SessionOptions.Signing = true; + connection.SessionOptions.Sealing = true; + } - return (true, searchBase); + if (config.DisableCertVerification) + connection.SessionOptions.VerifyServerCertificate = (_, __) => true; + + if (config.Username != null) { + connection.Credential = new NetworkCredential(config.Username, config.Password); + } + + connection.AuthType = config.AuthType; + connection.Bind(); + return connection; + } + catch (Exception e) { + log?.LogDebug(e, + "TryBindOneShotLdapConnection: bind failed for {Target} ssl={Ssl}", target, ssl); + connection?.Dispose(); } } + return null; + } - return (false, default); + /// + /// Controlled resolver used when no is available to + /// route queries through. Binds a one-shot + /// directly to (treating it as a DNS domain name that resolves + /// to a DC via standard round-robin A/SRV records) and populates a + /// by issuing the same rootDSE + Base + Subtree queries as + /// , just over a private connection. + /// + /// + /// Intended as the intermediate step in between the + /// pool-based controlled path and the uncontrolled Domain.GetDomain fallback, so that + /// callers inside (which must pass pool = null to + /// avoid reentering connection acquisition) still have a controlled resolution option when + /// is off. The connection is torn + /// down before returning; no state is added to any pool. + /// + private static async Task<(bool Success, DomainInfo DomainInfo)> TryResolveDomainInfoViaDirectLdapAsync( + string domainName, LdapConfig config, ILogger log) { + if (string.IsNullOrWhiteSpace(domainName) || config == null) { + return (false, null); + } + + var connection = TryBindOneShotLdapConnection(domainName, config, log); + if (connection == null) { + return (false, null); + } + + // Offload the synchronous SendRequest calls so we don't block the calling thread when + // called from an async code path (e.g. LdapConnectionPool.CreateLdapConnection). + return await Task.Run(() => { + try { + return ResolveDomainInfoFromConnection(connection, domainName, log); + } + finally { + connection.Dispose(); + } + }).ConfigureAwait(false); + } + + /// + /// Synchronous body of . Uses + /// SendRequest directly rather than the pool's Query because no pool is + /// available on this path. Follows the same attribute shape as + /// : rootDSE → domain NC base → + /// PDC server DN → DC subtree enumeration, each wrapped independently so a partial + /// failure still returns a usable . + /// + private static (bool Success, DomainInfo DomainInfo) ResolveDomainInfoFromConnection( + LdapConnection connection, string domainName, ILogger log) { + // 1. RootDSE - the authoritative source for the default/config/root NCs of the DC we bound to. + string defaultNc = null, configNc = null, rootNc = null; + try { + var rootReq = new SearchRequest( + "", "(objectClass=*)", SearchScope.Base, + new[] { + LDAPProperties.DefaultNamingContext, + LDAPProperties.ConfigurationNamingContext, + LDAPProperties.RootDomainNamingContext, + }); + var rootResp = (SearchResponse)connection.SendRequest(rootReq); + if (rootResp?.Entries != null && rootResp.Entries.Count > 0) { + var entry = new SearchResultEntryWrapper(rootResp.Entries[0]); + entry.TryGetProperty(LDAPProperties.DefaultNamingContext, out defaultNc); + entry.TryGetProperty(LDAPProperties.ConfigurationNamingContext, out configNc); + entry.TryGetProperty(LDAPProperties.RootDomainNamingContext, out rootNc); + } + } + catch (Exception e) { + log?.LogDebug(e, "Direct LDAP rootDSE read failed for {Domain}", domainName); + return (false, null); + } + + if (string.IsNullOrWhiteSpace(defaultNc)) { + return (false, null); + } + + var name = Helpers.DistinguishedNameToDomain(defaultNc).ToUpper(); + string domainSid = null; + string forestName = null; + string primaryDomainController = null; + IReadOnlyList domainControllers = null; + if (!string.IsNullOrWhiteSpace(rootNc)) { + forestName = Helpers.DistinguishedNameToDomain(rootNc).ToUpper(); + } + + // 2. Domain NC base search - objectSid + fsmoRoleOwner in one round-trip. + string fsmoOwner = null; + try { + var domReq = new SearchRequest( + defaultNc, "(objectClass=*)", SearchScope.Base, + new[] { LDAPProperties.ObjectSID, LDAPProperties.FSMORoleOwner }); + var domResp = (SearchResponse)connection.SendRequest(domReq); + if (domResp?.Entries != null && domResp.Entries.Count > 0) { + var entry = new SearchResultEntryWrapper(domResp.Entries[0]); + if (entry.TryGetSecurityIdentifier(out var sid) && !string.IsNullOrEmpty(sid)) { + domainSid = sid.ToUpper(); + } + entry.TryGetProperty(LDAPProperties.FSMORoleOwner, out fsmoOwner); + } + } + catch (Exception e) { + log?.LogDebug(e, "Direct LDAP domain NC read failed for {Domain}", domainName); + } + + // 3. PDC server dNSHostName via the fsmoRoleOwner DN parent. + if (TryStripNtdsSettingsPrefix(fsmoOwner, out var serverDn)) { + try { + var pdcReq = new SearchRequest( + serverDn, "(objectClass=*)", SearchScope.Base, + new[] { LDAPProperties.DNSHostName }); + var pdcResp = (SearchResponse)connection.SendRequest(pdcReq); + if (pdcResp?.Entries != null && pdcResp.Entries.Count > 0) { + var entry = new SearchResultEntryWrapper(pdcResp.Entries[0]); + if (entry.TryGetProperty(LDAPProperties.DNSHostName, out var pdcName)) { + primaryDomainController = pdcName; + } + } + } + catch (Exception e) { + log?.LogDebug(e, "Direct LDAP PDC lookup failed for {Domain}", domainName); + } + } + + // 4. Domain controller enumeration - same filter as CommonFilters.DomainControllers. + try { + var dcs = new List(); + var dcReq = new SearchRequest( + defaultNc, CommonFilters.DomainControllers, SearchScope.Subtree, + new[] { LDAPProperties.DNSHostName }); + var dcResp = (SearchResponse)connection.SendRequest(dcReq); + if (dcResp?.Entries != null) { + foreach (SearchResultEntry e in dcResp.Entries) { + var wrap = new SearchResultEntryWrapper(e); + if (wrap.TryGetProperty(LDAPProperties.DNSHostName, out var dcName) && + !string.IsNullOrEmpty(dcName)) { + dcs.Add(dcName); + } + } + } + domainControllers = dcs; + if (string.IsNullOrEmpty(primaryDomainController) && dcs.Count > 0) { + primaryDomainController = dcs[0]; + } + } + catch (Exception e) { + log?.LogDebug(e, "Direct LDAP DC enumeration failed for {Domain}", domainName); + } + + return (true, new DomainInfo( + name: name, + distinguishedName: defaultNc, + forestName: forestName, + domainSid: domainSid, + primaryDomainController: primaryDomainController, + domainControllers: domainControllers)); } public void ResetUtils() { _unresolvablePrincipals = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); _domainCache = new ConcurrentDictionary(); + _domainInfoCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); _domainControllers = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + lock (_uncontrolledGetDomainHintLock) { + _uncontrolledGetDomainHint = null; + } _connectionPool?.Dispose(); _connectionPool = new ConnectionPoolManager(_ldapConfig, scanner: _portScanner); - + // Metrics LdapMetrics.ResetInFlight(); } diff --git a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs index 28a6996f6..2f1212ca4 100644 --- a/src/CommonLib/Processors/GPOLocalGroupProcessor.cs +++ b/src/CommonLib/Processors/GPOLocalGroupProcessor.cs @@ -66,13 +66,14 @@ public async Task ReadGPOLocalGroups(string gpLink, string string domain; //If our dn is null, use our default domain if (string.IsNullOrEmpty(distinguishedName)) { - if (!_utils.GetDomain(out var domainResult)) { + var (ok, info) = await _utils.GetDomainInfoAsync(); + if (!ok || string.IsNullOrEmpty(info?.Name)) { return ret; } - domain = domainResult.Name; + domain = info.Name; } else { - domain = Helpers.DistinguishedNameToDomain(distinguishedName); + domain = Helpers.DistinguishedNameToDomain(distinguishedName); } // First lets check if this OU actually has computers that it contains. If not, then we'll ignore it. diff --git a/test/unit/Facades/MockLdapUtils.cs b/test/unit/Facades/MockLdapUtils.cs index 1e5f1194d..9946bd0f2 100644 --- a/test/unit/Facades/MockLdapUtils.cs +++ b/test/unit/Facades/MockLdapUtils.cs @@ -703,6 +703,14 @@ public bool GetDomain(out Domain domain) { return false; } + public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync(string domainName) { + return Task.FromResult<(bool, DomainInfo)>((false, null)); + } + + public Task<(bool Success, DomainInfo DomainInfo)> GetDomainInfoAsync() { + return Task.FromResult<(bool, DomainInfo)>((false, null)); + } + public async Task<(bool Success, TypedPrincipal Principal)> ResolveAccountName(string name, string domain) { var res = name.ToUpper() switch { "ADMINISTRATOR" => new TypedPrincipal( diff --git a/test/unit/GPOLocalGroupProcessorTest.cs b/test/unit/GPOLocalGroupProcessorTest.cs index 80f3a59a7..dfdb5ae5e 100644 --- a/test/unit/GPOLocalGroupProcessorTest.cs +++ b/test/unit/GPOLocalGroupProcessorTest.cs @@ -323,8 +323,8 @@ public async Task GPOLocalGroupProcessor_ReadGPOLocalGroups() { .Returns(mockComputerResults.ToAsyncEnumerable) .Returns(mockGCPFileSysPathResults.ToAsyncEnumerable) .Returns(Array.Empty>().ToAsyncEnumerable); - var domain = MockableDomain.Construct("TESTLAB.LOCAL"); - mockLDAPUtils.Setup(x => x.GetDomain(out domain)).Returns(true); + var domainInfo = new DomainInfo(name: "TESTLAB.LOCAL"); + mockLDAPUtils.Setup(x => x.GetDomainInfoAsync()).ReturnsAsync((true, domainInfo)); var processor = new GPOLocalGroupProcessor(mockLDAPUtils.Object); diff --git a/test/unit/LDAPUtilsTest.cs b/test/unit/LDAPUtilsTest.cs index 149f52449..6f9966f15 100644 --- a/test/unit/LDAPUtilsTest.cs +++ b/test/unit/LDAPUtilsTest.cs @@ -647,5 +647,153 @@ public async Task EnterpriseDomainControllersGroup_CorrectValues() { Assert.Equal("TESTLAB.LOCAL-S-1-5-9", entDCGroup.ObjectIdentifier); Assert.Equal(3, entDCGroup.Members.Length); } + + // --------------------------------------------------------------------------- + // AllowFallbackToUncontrolledLdap gate and DomainInfo resolution + // --------------------------------------------------------------------------- + + [Fact] + public void LdapConfig_AllowFallbackToUncontrolledLdap_DefaultsToFalse() { + var config = new LdapConfig(); + Assert.False(config.AllowFallbackToUncontrolledLdap); + } + + [Fact] + public void LdapConfig_ToString_IncludesAllowFallbackFlag() { + var offConfig = new LdapConfig(); + Assert.Contains("AllowFallbackToUncontrolledLdap: False", offConfig.ToString()); + + var onConfig = new LdapConfig { AllowFallbackToUncontrolledLdap = true }; + Assert.Contains("AllowFallbackToUncontrolledLdap: True", onConfig.ToString()); + } + + [Fact] + public void LdapConfig_CurrentUserDomain_DefaultsToNull() { + var config = new LdapConfig(); + Assert.Null(config.CurrentUserDomain); + } + + [Fact] + public void LdapConfig_ToString_IncludesCurrentUserDomain_WhenSet() { + var unset = new LdapConfig(); + Assert.DoesNotContain("CurrentUserDomain:", unset.ToString()); + + var set = new LdapConfig { CurrentUserDomain = "CONTOSO.LOCAL" }; + Assert.Contains("CurrentUserDomain: CONTOSO.LOCAL", set.ToString()); + } + + [Fact] + public async Task GetDomainInfoAsync_ControlledPathFails_FallbackDisabled_ReturnsFailure() { + var utils = new LdapUtils(); + utils.SetLdapConfig(new LdapConfig { + Server = "unreachable.invalid.test", + AllowFallbackToUncontrolledLdap = false + }); + + var (success, info) = await utils.GetDomainInfoAsync("unreachable.invalid.test"); + Assert.False(success); + Assert.Null(info); + } + + [Fact] + public void GetDomain_OutDomain_ReturnsFalse_WhenFallbackDisabled() { + var utils = new LdapUtils(); + utils.SetLdapConfig(new LdapConfig { AllowFallbackToUncontrolledLdap = false }); + + Assert.False(utils.GetDomain(out var currentDomain)); + Assert.Null(currentDomain); + + Assert.False(utils.GetDomain("unreachable.invalid.test", out var namedDomain)); + Assert.Null(namedDomain); + + Assert.False(LdapUtils.GetDomain("unreachable.invalid.test", + new LdapConfig { AllowFallbackToUncontrolledLdap = false }, out var staticDomain)); + Assert.Null(staticDomain); + } + + // --------------------------------------------------------------------------- + // TryStripNtdsSettingsPrefix + // --------------------------------------------------------------------------- + + [Fact] + public void TryStripNtdsSettingsPrefix_StandardDn_StripsPrefixAndReturnsServerDn() { + const string input = + "CN=NTDS Settings,CN=DC01,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=contoso,DC=local"; + var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn); + Assert.True(ok); + Assert.Equal( + "CN=DC01,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=contoso,DC=local", + serverDn); + } + + [Fact] + public void TryStripNtdsSettingsPrefix_LowercasePrefix_StripsCaseInsensitively() { + const string input = "cn=ntds settings,CN=DC01,CN=Servers,DC=contoso,DC=local"; + var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn); + Assert.True(ok); + Assert.Equal("CN=DC01,CN=Servers,DC=contoso,DC=local", serverDn); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void TryStripNtdsSettingsPrefix_NullOrEmptyInput_ReturnsFalse(string input) { + var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn); + Assert.False(ok); + Assert.Null(serverDn); + } + + [Fact] + public void TryStripNtdsSettingsPrefix_MissingPrefix_ReturnsFalse() { + const string input = "CN=DC01,CN=Servers,DC=contoso,DC=local"; + var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn); + Assert.False(ok); + Assert.Null(serverDn); + } + + [Fact] + public void TryStripNtdsSettingsPrefix_PrefixOnly_ReturnsFalse() { + const string input = "CN=NTDS Settings,"; + var ok = LdapUtils.TryStripNtdsSettingsPrefix(input, out var serverDn); + Assert.False(ok); + Assert.Equal(string.Empty, serverDn); + } + + // --------------------------------------------------------------------------- + // ResolveEffectiveDomainHint + // --------------------------------------------------------------------------- + + [Fact] + public void ResolveEffectiveDomainHint_ExplicitDomain_WinsOverCurrentUserDomain() { + var utils = new LdapUtils(); + utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = "FALLBACK.LOCAL" }); + + Assert.Equal("EXPLICIT.LOCAL", utils.ResolveEffectiveDomainHint("EXPLICIT.LOCAL")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveEffectiveDomainHint_NullOrWhitespaceInput_FallsBackToCurrentUserDomain(string input) { + var utils = new LdapUtils(); + utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = "CONTOSO.LOCAL" }); + + Assert.Equal("CONTOSO.LOCAL", utils.ResolveEffectiveDomainHint(input)); + } + + [Fact] + public void ResolveEffectiveDomainHint_WhitespaceCurrentUserDomain_FallsThroughToEnvironment() { + var utils = new LdapUtils(); + utils.SetLdapConfig(new LdapConfig { CurrentUserDomain = " " }); + + // Cannot assert a literal without pinning Environment.UserDomainName; asserting + // that the whitespace CurrentUserDomain was skipped and something else was chosen + // is sufficient to cover the branch. + var result = utils.ResolveEffectiveDomainHint(null); + Assert.False(string.IsNullOrWhiteSpace(result)); + Assert.NotEqual(" ", result); + } + } } \ No newline at end of file