diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs index fdac620bbf15df..6063e017074f6c 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs @@ -417,6 +417,7 @@ private static bool ValidateAddressFamily(ref AddressFamily addressFamily, strin } private const string Localhost = "localhost"; + private const string LocalhostWithTrailingDot = Localhost + "."; private const string InvalidDomain = "invalid"; // Some systems (e.g. Android, some Linux distros) map ::1 to "ip6-localhost" instead of @@ -451,11 +452,17 @@ private static bool IsReservedName(string hostName, string reservedName) } /// - /// Checks if the given host name is a subdomain of localhost (e.g., "foo.localhost"). - /// Plain "localhost" or "localhost." returns false. + /// Checks if the given host name should fall back to "localhost" when resolution fails or returns no addresses. + /// Fully qualified "localhost." and localhost subdomains return true; plain "localhost" returns false. /// - private static bool IsLocalhostSubdomain(string hostName) + private static bool ShouldFallbackToLocalhost(string hostName) { + // Plain "localhost" is the fallback target, so only its fully qualified form should enter this path. + if (hostName.Equals(LocalhostWithTrailingDot, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // Strip trailing dot for length comparison int length = hostName.Length; if (hostName.EndsWith('.')) @@ -463,7 +470,7 @@ private static bool IsLocalhostSubdomain(string hostName) length--; } - // Must be longer than "localhost" (not just equal with trailing dot) + // Subdomains must be longer than "localhost". return length > Localhost.Length && IsReservedName(hostName, Localhost); } @@ -513,10 +520,10 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr if (errorCode != SocketError.Success) { - // RFC 6761 Section 6.3: If localhost subdomain fails, fall back to resolving plain "localhost". - if (IsLocalhostSubdomain(hostName)) + // RFC 6761 Section 6.3: If a localhost name fails, fall back to resolving plain "localhost". + if (ShouldFallbackToLocalhost(hostName)) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain resolution failed, falling back to 'localhost'"); + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost name resolution failed, falling back to 'localhost'"); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: null, exception: CreateException(errorCode, nativeErrorCode)); fallbackToLocalhost = true; } @@ -526,10 +533,10 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr throw CreateException(errorCode, nativeErrorCode); } } - else if (addresses.Length == 0 && IsLocalhostSubdomain(hostName)) + else if (addresses.Length == 0 && ShouldFallbackToLocalhost(hostName)) { - // RFC 6761 Section 6.3: If localhost subdomain returns empty addresses, fall back to plain "localhost". - if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain returned empty, falling back to 'localhost'"); + // RFC 6761 Section 6.3: If a localhost name returns empty addresses, fall back to plain "localhost". + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost name returned empty, falling back to 'localhost'"); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: justAddresses ? addresses : (object)new IPHostEntry { AddressList = addresses, HostName = newHostName!, Aliases = aliases }, exception: null); fallbackToLocalhost = true; } @@ -713,7 +720,7 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR Task.FromException(invalidDomainException!); } - // For localhost subdomains (RFC 6761 Section 6.3), we try the OS resolver first. + // For localhost subdomains and fully qualified "localhost." (RFC 6761 Section 6.3), we try the OS resolver first. // If it fails or returns empty, we fall back to resolving plain "localhost". // This fallback logic is handled in GetHostEntryOrAddressesCore and GetAddrInfoWithTelemetryAsync. @@ -725,11 +732,11 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR // instead of calling the synchronous version in the ThreadPool. // If it fails, we will fall back to ThreadPool as well. - // Always use the telemetry-enabled path for localhost subdomains to ensure fallback handling. + // Always use the telemetry-enabled path for names requiring localhost fallback handling. // For other hostnames, use the non-telemetry path if diagnostics are disabled. - bool isLocalhostSubdomain = IsLocalhostSubdomain(hostName); + bool shouldFallbackToLocalhost = ShouldFallbackToLocalhost(hostName); Task? t; - if (NameResolutionTelemetry.AnyDiagnosticsEnabled() || isLocalhostSubdomain) + if (NameResolutionTelemetry.AnyDiagnosticsEnabled() || shouldFallbackToLocalhost) { t = justAddresses ? GetAddrInfoWithTelemetryAsync(hostName, justAddresses, family, cancellationToken) @@ -783,15 +790,15 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR if (task != null) { - bool isLocalhostSubdomain = IsLocalhostSubdomain(hostName); - return CompleteAsync(task, hostName, justAddresses, addressFamily, isLocalhostSubdomain, startingTimestamp, cancellationToken); + bool shouldFallbackToLocalhost = ShouldFallbackToLocalhost(hostName); + return CompleteAsync(task, hostName, justAddresses, addressFamily, shouldFallbackToLocalhost, startingTimestamp, cancellationToken); } // If resolution even did not start don't bother with telemetry. // We will retry on thread-pool. return null; - static async Task CompleteAsync(Task task, string hostName, bool justAddresses, AddressFamily addressFamily, bool isLocalhostSubdomain, long startingTimeStamp, CancellationToken cancellationToken) + static async Task CompleteAsync(Task task, string hostName, bool justAddresses, AddressFamily addressFamily, bool shouldFallbackToLocalhost, long startingTimeStamp, CancellationToken cancellationToken) { NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(hostName, startingTimeStamp); Exception? exception = null; @@ -801,19 +808,19 @@ static async Task CompleteAsync(Task task, string hostName, bool justAddresse { result = await ((Task)task).ConfigureAwait(false); - // RFC 6761 Section 6.3: If localhost subdomain returns empty addresses, fall back to plain "localhost". - if (isLocalhostSubdomain && result is IPAddress[] addresses && addresses.Length == 0) + // RFC 6761 Section 6.3: If a localhost name returns empty addresses, fall back to plain "localhost". + if (shouldFallbackToLocalhost && result is IPAddress[] addresses && addresses.Length == 0) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain returned empty, falling back to 'localhost'"); + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost name returned empty, falling back to 'localhost'"); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: null); fallbackOccurred = true; return await GetLocalhostAddressesAsync(addressFamily, cancellationToken).ConfigureAwait(false); } - if (isLocalhostSubdomain && result is IPHostEntry entry && entry.AddressList.Length == 0) + if (shouldFallbackToLocalhost && result is IPHostEntry entry && entry.AddressList.Length == 0) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain returned empty, falling back to 'localhost'"); + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost name returned empty, falling back to 'localhost'"); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: null); fallbackOccurred = true; @@ -822,10 +829,10 @@ static async Task CompleteAsync(Task task, string hostName, bool justAddresse return result; } - catch (SocketException ex) when (isLocalhostSubdomain && !fallbackOccurred) + catch (SocketException ex) when (shouldFallbackToLocalhost && !fallbackOccurred) { - // RFC 6761 Section 6.3: If localhost subdomain fails, fall back to resolving plain "localhost". - if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain resolution failed, falling back to 'localhost'"); + // RFC 6761 Section 6.3: If a localhost name fails, fall back to resolving plain "localhost". + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost name resolution failed, falling back to 'localhost'"); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: null, exception: ex); fallbackOccurred = true; diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs index 249aa22ef43c49..eeba7ecd3c8af9 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs @@ -370,7 +370,7 @@ public async Task DnsGetHostAddresses_MalformedReservedName_NotTreatedAsReserved await Assert.ThrowsAnyAsync(() => Dns.GetHostAddressesAsync(hostName)); } - // "localhost." (with trailing dot) should NOT be treated as a subdomain. + // "localhost." is resolved by the OS first, with fallback to plain "localhost" on failure or an empty result. [Fact] public async Task DnsGetHostAddresses_LocalhostWithTrailingDot_ReturnsLoopback() { diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs index d3d1676c9b110f..8bb4e291311a83 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs @@ -405,8 +405,8 @@ public async Task DnsGetHostEntry_MalformedReservedName_NotTreatedAsReserved(str await Assert.ThrowsAnyAsync(() => Dns.GetHostEntryAsync(hostName)); } - // "localhost." (with trailing dot) should NOT be treated as a subdomain. - // It's equivalent to plain "localhost" and should resolve via OS resolver. + // "localhost." is equivalent to plain "localhost". + // The OS resolver is tried first, falling back to plain "localhost" if resolution fails or returns no addresses. [Fact] public async Task DnsGetHostEntry_LocalhostWithTrailingDot_ReturnsLoopback() {