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..db16a57e897d16 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,19 +452,43 @@ 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.
+ /// Normalizes the fully-qualified form of localhost ("localhost." with a single trailing dot)
+ /// to plain "localhost" so it resolves identically to "localhost". Other names are returned unchanged.
///
+ ///
+ /// Per RFC 6761 Section 6.3 (https://www.rfc-editor.org/rfc/rfc6761#section-6.3) the trailing dot
+ /// is DNS root notation that denotes the same name. This applies to all platforms (the RFC is
+ /// platform-agnostic), but it only changes behavior on resolvers that do not strip the trailing
+ /// dot themselves: e.g. Android's getaddrinfo("localhost.") fails while getaddrinfo("localhost")
+ /// succeeds via /etc/hosts, whereas glibc/Windows already resolve both to loopback.
+ ///
+ /// We normalize toward "localhost" (not "localhost.") deliberately: "localhost" is the form the
+ /// OS resolver can satisfy, so this fixes the failing platforms without regressing the others.
+ ///
+ private static string NormalizeLocalhostName(string hostName) =>
+ hostName.Equals(LocalhostWithTrailingDot, StringComparison.OrdinalIgnoreCase) ? Localhost : hostName;
+
+ ///
+ /// Checks if the given host name is a subdomain of localhost (e.g., "foo.localhost" or
+ /// "foo.localhost."). Plain "localhost" and "localhost." return false.
+ ///
+ ///
+ /// RFC 6761 Section 6.3 ("Domain Name Reservation Considerations for 'localhost.'",
+ /// https://www.rfc-editor.org/rfc/rfc6761#section-6.3) states:
+ /// "The domain 'localhost.' and any names falling within '.localhost.' are special" and
+ /// "Name resolution APIs and libraries SHOULD recognize localhost names as special and
+ /// SHOULD always return the IP loopback address for address queries [...]".
+ /// The OS resolver may have no entry for "*.localhost" subdomains, so when it fails or returns
+ /// no addresses for one we fall back to resolving plain "localhost". Plain "localhost"/"localhost."
+ /// are excluded: they resolve directly (the latter after ), and
+ /// the fallback target itself is not a subdomain so the fallback cannot recurse on itself.
+ ///
private static bool IsLocalhostSubdomain(string hostName)
{
- // Strip trailing dot for length comparison
- int length = hostName.Length;
- if (hostName.EndsWith('.'))
- {
- length--;
- }
+ // Strip a single trailing dot (DNS root notation) for the length comparison.
+ int length = hostName.EndsWith('.') ? hostName.Length - 1 : hostName.Length;
- // Must be longer than "localhost" (not just equal with trailing dot)
+ // Must be longer than "localhost" (i.e. a subdomain, not "localhost"/"localhost." themselves).
return length > Localhost.Length && IsReservedName(hostName, Localhost);
}
@@ -489,6 +514,9 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr
{
ValidateHostName(hostName);
+ // RFC 6761 Section 6.3: "localhost." is the fully-qualified form of "localhost"; resolve it identically.
+ hostName = NormalizeLocalhostName(hostName);
+
if (!ValidateAddressFamily(ref addressFamily, hostName, justAddresses, out object? resultOnFailure))
{
Debug.Assert(!activityOrDefault.HasValue);
@@ -513,7 +541,7 @@ 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".
+ // RFC 6761 Section 6.3: If a localhost subdomain fails, fall back to resolving plain "localhost".
if (IsLocalhostSubdomain(hostName))
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain resolution failed, falling back to 'localhost'");
@@ -528,7 +556,7 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr
}
else if (addresses.Length == 0 && IsLocalhostSubdomain(hostName))
{
- // RFC 6761 Section 6.3: If localhost subdomain returns empty addresses, fall back to plain "localhost".
+ // RFC 6761 Section 6.3: If a 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'");
NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: justAddresses ? addresses : (object)new IPHostEntry { AddressList = addresses, HostName = newHostName!, Aliases = aliases }, exception: null);
fallbackToLocalhost = true;
@@ -703,6 +731,9 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR
// Validate hostname before any processing
ValidateHostName(hostName);
+ // RFC 6761 Section 6.3: "localhost." is the fully-qualified form of "localhost"; resolve it identically.
+ hostName = NormalizeLocalhostName(hostName);
+
// RFC 6761 Section 6.4: "invalid" domains must return NXDOMAIN.
if (TryHandleRfc6761InvalidDomain(hostName, out SocketException? invalidDomainException))
{
@@ -801,7 +832,7 @@ 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".
+ // RFC 6761 Section 6.3: If a localhost subdomain returns empty addresses, fall back to plain "localhost".
if (isLocalhostSubdomain && result is IPAddress[] addresses && addresses.Length == 0)
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain returned empty, falling back to 'localhost'");
@@ -824,7 +855,7 @@ static async Task CompleteAsync(Task task, string hostName, bool justAddresse
}
catch (SocketException ex) when (isLocalhostSubdomain && !fallbackOccurred)
{
- // RFC 6761 Section 6.3: If localhost subdomain fails, fall back to resolving plain "localhost".
+ // RFC 6761 Section 6.3: If a 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'");
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..fd9d9e0e887cf9 100644
--- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs
+++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs
@@ -235,9 +235,9 @@ public async Task DnsGetHostAddresses_LocalhostSubdomain_ReturnsLoopback(string
{
// The subdomain goes to OS resolver first. If it fails (likely on most systems),
// it falls back to resolving plain "localhost", which should return loopback addresses.
- // On Android/Apple mobile platforms the OS resolver may return non-loopback addresses
+ // On Apple mobile platforms the OS resolver may return non-loopback addresses
// for *.localhost.
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPAddress[] addresses = Dns.GetHostAddresses(hostName);
Assert.True(addresses.Length >= 1, "Expected at least one address");
@@ -291,13 +291,13 @@ public async Task DnsGetHostAddresses_LocalhostSubdomain_RespectsAddressFamily(A
// 1. The OS resolver is tried first for subdomains
// 2. The OS may return different results (e.g., both IPv4+IPv6 vs IPv4 only)
// 3. Different systems configure localhost differently
- // On Android and Apple mobile the OS resolver may return non-loopback addresses
+ // On Apple mobile the OS resolver may return non-loopback addresses
// for both plain "localhost" and "*.localhost" (e.g. link-local IPv6 or
// multicast DNS results), so we only require any address to be returned there.
[Fact]
public async Task DnsGetHostAddresses_LocalhostAndSubdomain_BothReturnLoopback()
{
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPAddress[] localhostAddresses = Dns.GetHostAddresses("localhost");
IPAddress[] subdomainAddresses = Dns.GetHostAddresses("foo.localhost");
@@ -328,7 +328,7 @@ public async Task DnsGetHostAddresses_LocalhostAndSubdomain_BothReturnLoopback()
[InlineData("bar.test.localhost.")]
public async Task DnsGetHostAddresses_LocalhostSubdomainWithTrailingDot_ReturnsLoopback(string hostName)
{
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPAddress[] addresses = Dns.GetHostAddresses(hostName);
Assert.True(addresses.Length >= 1, "Expected at least one address");
@@ -370,7 +370,9 @@ public async Task DnsGetHostAddresses_MalformedReservedName_NotTreatedAsReserved
await Assert.ThrowsAnyAsync(() => Dns.GetHostAddressesAsync(hostName));
}
- // "localhost." (with trailing dot) should NOT be treated as a subdomain.
+ // "localhost." (fully-qualified form with trailing dot) is equivalent to plain "localhost"
+ // and must resolve to loopback, either directly via the OS resolver or via the RFC 6761
+ // fallback to plain "localhost" when the OS resolver doesn't handle the trailing dot.
[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..c80b45cb1fe257 100644
--- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs
+++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs
@@ -357,9 +357,9 @@ public async Task DnsGetHostEntry_LocalhostSubdomain_ReturnsLoopback(string host
{
// The subdomain goes to OS resolver first. If it fails (likely on most systems),
// it falls back to resolving plain "localhost", which should return loopback addresses.
- // On Android/Apple mobile platforms the OS resolver may return non-loopback addresses
+ // On Apple mobile platforms the OS resolver may return non-loopback addresses
// for *.localhost.
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPHostEntry entry = Dns.GetHostEntry(hostName);
Assert.True(entry.AddressList.Length >= 1, "Expected at least one address");
@@ -405,8 +405,9 @@ 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." (fully-qualified form with trailing dot) is equivalent to plain "localhost"
+ // and must resolve to loopback, either directly via the OS resolver or via the RFC 6761
+ // fallback to plain "localhost" when the OS resolver doesn't handle the trailing dot.
[Fact]
public async Task DnsGetHostEntry_LocalhostWithTrailingDot_ReturnsLoopback()
{
@@ -456,13 +457,13 @@ public async Task DnsGetHostEntry_LocalhostSubdomain_RespectsAddressFamily(Addre
// 1. The OS resolver is tried first for subdomains
// 2. The OS may return different results (e.g., both IPv4+IPv6 vs IPv4 only)
// 3. Different systems configure localhost differently
- // On Android and Apple mobile the OS resolver may return non-loopback addresses
+ // On Apple mobile the OS resolver may return non-loopback addresses
// for both plain "localhost" and "*.localhost" (e.g. link-local IPv6 or
// multicast DNS results), so we only require any address to be returned there.
[Fact]
public async Task DnsGetHostEntry_LocalhostAndSubdomain_BothReturnLoopback()
{
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPHostEntry localhostEntry = Dns.GetHostEntry("localhost");
IPHostEntry subdomainEntry = Dns.GetHostEntry("foo.localhost");
@@ -494,7 +495,7 @@ public async Task DnsGetHostEntry_LocalhostAndSubdomain_BothReturnLoopback()
[InlineData("bar.test.localhost.")]
public async Task DnsGetHostEntry_LocalhostSubdomainWithTrailingDot_ReturnsLoopback(string hostName)
{
- bool requireLoopback = !PlatformDetection.IsAppleMobile && !PlatformDetection.IsAndroid;
+ bool requireLoopback = !PlatformDetection.IsAppleMobile;
IPHostEntry entry = Dns.GetHostEntry(hostName);
Assert.True(entry.AddressList.Length >= 1, "Expected at least one address");
diff --git a/src/native/libs/System.Native/pal_networking.c b/src/native/libs/System.Native/pal_networking.c
index f0dc872b1878c3..1256be1a8c90a6 100644
--- a/src/native/libs/System.Native/pal_networking.c
+++ b/src/native/libs/System.Native/pal_networking.c
@@ -365,6 +365,30 @@ static int32_t CopySockAddrToIPAddress(sockaddr* addr, sa_family_t family, IPAdd
return -1;
}
+#if HAVE_GETIFADDRS
+// Determines whether the result of resolving 'address' should be augmented with the local
+// interface addresses, which happens when 'address' refers to the machine's own host name.
+static bool ShouldAugmentWithInterfaceAddresses(const char* address, const char* hostName)
+{
+ if (strcasecmp(address, hostName) != 0)
+ {
+ return false;
+ }
+
+#ifdef TARGET_ANDROID
+ // Per RFC 6761, "localhost" must resolve to loopback addresses only. On Android gethostname()
+ // can itself return "localhost", which would otherwise cause non-loopback interface addresses
+ // to leak into the result for "localhost" (and, via the *.localhost fallback, "*.localhost").
+ if (strcasecmp(address, "localhost") == 0)
+ {
+ return false;
+ }
+#endif
+
+ return true;
+}
+#endif
+
int32_t SystemNative_GetHostEntryForName(const uint8_t* address, int32_t addressFamily, HostEntry* entry)
{
if (address == NULL || entry == NULL)
@@ -430,7 +454,9 @@ int32_t SystemNative_GetHostEntryForName(const uint8_t* address, int32_t address
includeIPv4Loopback = true;
includeIPv6Loopback = true;
- if (result == 0 && strcasecmp((const char*)address, name) == 0)
+ // Augment the result with the local interface addresses when resolving the machine's own
+ // host name (see ShouldAugmentWithInterfaceAddresses for platform-specific exceptions).
+ if (result == 0 && ShouldAugmentWithInterfaceAddresses((const char*)address, name))
{
// Get all interface addresses if the host name corresponds to the local host.
result = getifaddrs(&addrs);