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()
{