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);