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 62a389ffe4ba5b..0a9f1cca119c99 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs @@ -416,6 +416,71 @@ private static bool ValidateAddressFamily(ref AddressFamily addressFamily, strin return true; } + private const string Localhost = "localhost"; + private const string InvalidDomain = "invalid"; + + /// + /// Checks if the given host name matches a reserved name or is a subdomain of it. + /// For example, IsReservedName("foo.localhost", "localhost") returns true. + /// Also handles trailing dots: IsReservedName("foo.localhost.", "localhost") returns true. + /// Returns false for malformed hostnames (starting with dot or containing consecutive dots). + /// + private static bool IsReservedName(string hostName, string reservedName) + { + // Reject malformed hostnames - let OS resolver handle them (and reject them) + if (hostName.StartsWith('.') || hostName.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + // Strip trailing dot if present (DNS root notation) + ReadOnlySpan hostSpan = hostName.AsSpan(); + if (hostSpan.EndsWith('.')) + { + hostSpan = hostSpan.Slice(0, hostSpan.Length - 1); + } + + // Matches "reservedName" exactly, or "*.reservedName" (subdomain) + return hostSpan.EndsWith(reservedName, StringComparison.OrdinalIgnoreCase) && + (hostSpan.Length == reservedName.Length || + hostSpan[hostSpan.Length - reservedName.Length - 1] == '.'); + } + + /// + /// Checks if the given host name is a subdomain of localhost (e.g., "foo.localhost"). + /// Plain "localhost" or "localhost." returns false. + /// + private static bool IsLocalhostSubdomain(string hostName) + { + // Strip trailing dot for length comparison + int length = hostName.Length; + if (hostName.EndsWith('.')) + { + length--; + } + + // Must be longer than "localhost" (not just equal with trailing dot) + return length > Localhost.Length && IsReservedName(hostName, Localhost); + } + + /// + /// Tries to handle RFC 6761 "invalid" domain names. + /// Returns true if the host name is an invalid domain (exception will be set). + /// + private static bool TryHandleRfc6761InvalidDomain(string hostName, out SocketException? exception) + { + // RFC 6761 Section 6.4: "invalid" and "*.invalid" must always return NXDOMAIN. + if (IsReservedName(hostName, InvalidDomain)) + { + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Returning NXDOMAIN for 'invalid' domain"); + exception = new SocketException((int)SocketError.HostNotFound); + return true; + } + + exception = null; + return false; + } + private static object GetHostEntryOrAddressesCore(string hostName, bool justAddresses, AddressFamily addressFamily, NameResolutionActivity? activityOrDefault = default) { ValidateHostName(hostName); @@ -429,25 +494,56 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr // NameResolutionActivity may have already been set if we're being called from RunAsync. NameResolutionActivity activity = activityOrDefault ?? NameResolutionTelemetry.Log.BeforeResolution(hostName); - object result; + // RFC 6761 Section 6.4: "invalid" domains must return NXDOMAIN. + if (TryHandleRfc6761InvalidDomain(hostName, out SocketException? invalidDomainException)) + { + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: null, exception: invalidDomainException); + throw invalidDomainException!; + } + + // Track if this is a localhost subdomain for fallback handling. + bool isLocalhostSubdomain = IsLocalhostSubdomain(hostName); + + bool fallbackToLocalhost = false; + object? result = null; try { SocketError errorCode = NameResolutionPal.TryGetAddrInfo(hostName, justAddresses, addressFamily, out string? newHostName, out string[] aliases, out IPAddress[] addresses, out int nativeErrorCode); if (errorCode != SocketError.Success) { - if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(hostName, $"{hostName} DNS lookup failed with {errorCode}"); - throw CreateException(errorCode, nativeErrorCode); + // RFC 6761 Section 6.3: If localhost subdomain fails, fall back to resolving plain "localhost". + if (isLocalhostSubdomain) + { + 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: CreateException(errorCode, nativeErrorCode)); + fallbackToLocalhost = true; + } + else + { + if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(hostName, $"{hostName} DNS lookup failed with {errorCode}"); + throw CreateException(errorCode, nativeErrorCode); + } + } + else if (addresses.Length == 0 && isLocalhostSubdomain) + { + // 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'"); + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: justAddresses ? addresses : (object)new IPHostEntry { AddressList = addresses, HostName = newHostName!, Aliases = aliases }, exception: null); + fallbackToLocalhost = true; } - result = justAddresses ? (object) - addresses : - new IPHostEntry - { - AddressList = addresses, - HostName = newHostName!, - Aliases = aliases - }; + if (!fallbackToLocalhost) + { + result = justAddresses ? (object) + addresses : + new IPHostEntry + { + AddressList = addresses, + HostName = newHostName!, + Aliases = aliases + }; + } } catch (Exception ex) when (LogFailure(hostName, activity, ex)) { @@ -455,6 +551,12 @@ private static object GetHostEntryOrAddressesCore(string hostName, bool justAddr throw; } + if (fallbackToLocalhost) + { + return GetHostEntryOrAddressesCore(Localhost, justAddresses, addressFamily); + } + + Debug.Assert(result is not null); NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result); return result; @@ -588,6 +690,23 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR } else { + // Validate hostname before any processing + ValidateHostName(hostName); + + // RFC 6761 Section 6.4: "invalid" domains must return NXDOMAIN. + if (TryHandleRfc6761InvalidDomain(hostName, out SocketException? invalidDomainException)) + { + NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(hostName); + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: null, exception: invalidDomainException); + return justAddresses ? (Task) + Task.FromException(invalidDomainException!) : + Task.FromException(invalidDomainException!); + } + + // For localhost subdomains (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. + if (NameResolutionPal.SupportsGetAddrInfoAsync) { #pragma warning disable CS0162 // Unreachable code detected -- SupportsGetAddrInfoAsync is a constant on *nix. @@ -596,10 +715,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. - ValidateHostName(hostName); - + // Always use the telemetry-enabled path for localhost subdomains to ensure fallback handling. + // For other hostnames, use the non-telemetry path if diagnostics are disabled. + bool isLocalhostSubdomain = IsLocalhostSubdomain(hostName); Task? t; - if (NameResolutionTelemetry.AnyDiagnosticsEnabled()) + if (NameResolutionTelemetry.AnyDiagnosticsEnabled() || isLocalhostSubdomain) { t = justAddresses ? GetAddrInfoWithTelemetryAsync(hostName, justAddresses, family, cancellationToken) @@ -653,23 +773,60 @@ private static Task GetHostEntryOrAddressesCoreAsync(string hostName, bool justR if (task != null) { - return CompleteAsync(task, hostName, startingTimestamp); + bool isLocalhostSubdomain = IsLocalhostSubdomain(hostName); + return CompleteAsync(task, hostName, justAddresses, addressFamily, isLocalhostSubdomain, 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, long startingTimeStamp) + static async Task CompleteAsync(Task task, string hostName, bool justAddresses, AddressFamily addressFamily, bool isLocalhostSubdomain, long startingTimeStamp, CancellationToken cancellationToken) { NameResolutionActivity activity = NameResolutionTelemetry.Log.BeforeResolution(hostName, startingTimeStamp); Exception? exception = null; T? result = null; + bool fallbackOccurred = false; try { 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) + { + if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(hostName, "RFC 6761: Localhost subdomain returned empty, falling back to 'localhost'"); + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: null); + fallbackOccurred = true; + + // Resolve plain "localhost" instead + return await ((Task)(justAddresses + ? (Task)Dns.GetHostAddressesAsync(Localhost, addressFamily, cancellationToken) + : Dns.GetHostEntryAsync(Localhost, addressFamily, cancellationToken))).ConfigureAwait(false); + } + + if (isLocalhostSubdomain && 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'"); + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: null); + fallbackOccurred = true; + + // Resolve plain "localhost" instead + return await ((Task)(Task)Dns.GetHostEntryAsync(Localhost, addressFamily, cancellationToken)).ConfigureAwait(false); + } + return result; } + catch (SocketException ex) when (isLocalhostSubdomain && !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'"); + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: null, exception: ex); + fallbackOccurred = true; + + return await ((Task)(justAddresses + ? (Task)Dns.GetHostAddressesAsync(Localhost, addressFamily, cancellationToken) + : Dns.GetHostEntryAsync(Localhost, addressFamily, cancellationToken))).ConfigureAwait(false); + } catch (Exception ex) { exception = ex; @@ -677,7 +834,10 @@ static async Task CompleteAsync(Task task, string hostName, long startingTime } finally { - NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: exception); + if (!fallbackOccurred) + { + NameResolutionTelemetry.Log.AfterResolution(hostName, activity, answer: result, exception: exception); + } } } } diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs index 99421451491714..daea1a63a84d1e 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostAddressesTest.cs @@ -204,6 +204,151 @@ static async Task RunTest(string useAsync) Assert.Empty(addresses); } } + + // RFC 6761 Section 6.4: "invalid" and "*.invalid" must always return NXDOMAIN (HostNotFound). + [Theory] + [InlineData("invalid")] + [InlineData("invalid.")] + [InlineData("test.invalid")] + [InlineData("test.invalid.")] + [InlineData("foo.bar.invalid")] + [InlineData("INVALID")] + [InlineData("Test.INVALID")] + public async Task DnsGetHostAddresses_InvalidDomain_ThrowsHostNotFound(string hostName) + { + SocketException ex = Assert.ThrowsAny(() => Dns.GetHostAddresses(hostName)); + Assert.Equal(SocketError.HostNotFound, ex.SocketErrorCode); + + ex = await Assert.ThrowsAnyAsync(() => Dns.GetHostAddressesAsync(hostName)); + Assert.Equal(SocketError.HostNotFound, ex.SocketErrorCode); + } + + // RFC 6761 Section 6.3: "*.localhost" subdomains - OS resolver is tried first, + // falling back to plain "localhost" resolution if OS resolver fails or returns empty. + [Theory] + [InlineData("foo.localhost")] + [InlineData("bar.foo.localhost")] + [InlineData("test.localhost")] + [InlineData("FOO.LOCALHOST")] + [InlineData("Test.LocalHost")] + public async Task DnsGetHostAddresses_LocalhostSubdomain_ReturnsLoopback(string hostName) + { + // 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. + IPAddress[] addresses = Dns.GetHostAddresses(hostName); + Assert.True(addresses.Length >= 1, "Expected at least one loopback address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + addresses = await Dns.GetHostAddressesAsync(hostName); + Assert.True(addresses.Length >= 1, "Expected at least one loopback address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: "*.localhost" subdomains should respect AddressFamily parameter. + // OS resolver is tried first, falling back to plain "localhost" resolution. + [Theory] + [InlineData(AddressFamily.InterNetwork)] + [InlineData(AddressFamily.InterNetworkV6)] + public async Task DnsGetHostAddresses_LocalhostSubdomain_RespectsAddressFamily(AddressFamily addressFamily) + { + // Skip IPv6 test if OS doesn't support it. + if (addressFamily == AddressFamily.InterNetworkV6 && !Socket.OSSupportsIPv6) + { + return; + } + + string hostName = "test.localhost"; + + // The subdomain goes to OS resolver first. If it fails, it falls back to + // resolving plain "localhost" with the same address family filter. + IPAddress[] addresses = Dns.GetHostAddresses(hostName, addressFamily); + Assert.True(addresses.Length >= 1, "Expected at least one address"); + Assert.All(addresses, addr => Assert.Equal(addressFamily, addr.AddressFamily)); + + addresses = await Dns.GetHostAddressesAsync(hostName, addressFamily); + Assert.True(addresses.Length >= 1, "Expected at least one address"); + Assert.All(addresses, addr => Assert.Equal(addressFamily, addr.AddressFamily)); + } + + // RFC 6761: Verify that localhost subdomains return loopback addresses. + // Note: We don't require exact equality with plain "localhost" because: + // 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 + // The key requirement is that localhost subdomains return loopback addresses. + [Fact] + public async Task DnsGetHostAddresses_LocalhostAndSubdomain_BothReturnLoopback() + { + IPAddress[] localhostAddresses = Dns.GetHostAddresses("localhost"); + IPAddress[] subdomainAddresses = Dns.GetHostAddresses("foo.localhost"); + + // Both should return loopback addresses + Assert.True(localhostAddresses.Length >= 1); + Assert.True(subdomainAddresses.Length >= 1); + Assert.All(localhostAddresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + Assert.All(subdomainAddresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + // Async version + localhostAddresses = await Dns.GetHostAddressesAsync("localhost"); + subdomainAddresses = await Dns.GetHostAddressesAsync("bar.localhost"); + + Assert.True(localhostAddresses.Length >= 1); + Assert.True(subdomainAddresses.Length >= 1); + Assert.All(localhostAddresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + Assert.All(subdomainAddresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: Localhost subdomains with trailing dot should work (e.g., "foo.localhost.") + [Theory] + [InlineData("foo.localhost.")] + [InlineData("bar.test.localhost.")] + public async Task DnsGetHostAddresses_LocalhostSubdomainWithTrailingDot_ReturnsLoopback(string hostName) + { + IPAddress[] addresses = Dns.GetHostAddresses(hostName); + Assert.True(addresses.Length >= 1, "Expected at least one loopback address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + addresses = await Dns.GetHostAddressesAsync(hostName); + Assert.True(addresses.Length >= 1, "Expected at least one loopback address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: Ensure names that look similar but are not reserved are still resolved via OS. + [Theory] + [InlineData("notlocalhost")] + [InlineData("localhostfoo")] + [InlineData("invalidname")] + [InlineData("testinvalid")] + public async Task DnsGetHostAddresses_SimilarButNotReserved_ThrowsSocketException(string hostName) + { + Assert.ThrowsAny(() => Dns.GetHostAddresses(hostName)); + await Assert.ThrowsAnyAsync(() => Dns.GetHostAddressesAsync(hostName)); + } + + // Malformed hostnames should not be treated as RFC 6761 reserved names. + [Theory] + [InlineData(".localhost")] + [InlineData("foo..localhost")] + [InlineData(".invalid")] + [InlineData("test..invalid")] + public async Task DnsGetHostAddresses_MalformedReservedName_NotTreatedAsReserved(string hostName) + { + Assert.ThrowsAny(() => Dns.GetHostAddresses(hostName)); + await Assert.ThrowsAnyAsync(() => Dns.GetHostAddressesAsync(hostName)); + } + + // "localhost." (with trailing dot) should NOT be treated as a subdomain. + [Fact] + public async Task DnsGetHostAddresses_LocalhostWithTrailingDot_ReturnsLoopback() + { + IPAddress[] addresses = Dns.GetHostAddresses("localhost."); + Assert.True(addresses.Length >= 1, "Expected at least one address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + addresses = await Dns.GetHostAddressesAsync("localhost."); + Assert.True(addresses.Length >= 1, "Expected at least one address"); + Assert.All(addresses, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } } // Cancellation tests are sequential to reduce the chance of timing issues. diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs index e5d8ab8674a2a2..5306c600cdf51e 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/GetHostEntryTest.cs @@ -326,6 +326,158 @@ public async Task DnsGetHostEntry_LocalHost_AddressFamilySpecific(bool useAsync, { true, TestSettings.IPv6Host, AddressFamily.InterNetworkV6 } }; + // RFC 6761 Section 6.4: "invalid" and "*.invalid" must always return NXDOMAIN (HostNotFound). + [Theory] + [InlineData("invalid")] + [InlineData("invalid.")] + [InlineData("test.invalid")] + [InlineData("test.invalid.")] + [InlineData("foo.bar.invalid")] + [InlineData("INVALID")] + [InlineData("Test.INVALID")] + public async Task DnsGetHostEntry_InvalidDomain_ThrowsHostNotFound(string hostName) + { + SocketException ex = Assert.ThrowsAny(() => Dns.GetHostEntry(hostName)); + Assert.Equal(SocketError.HostNotFound, ex.SocketErrorCode); + + ex = await Assert.ThrowsAnyAsync(() => Dns.GetHostEntryAsync(hostName)); + Assert.Equal(SocketError.HostNotFound, ex.SocketErrorCode); + } + + // RFC 6761 Section 6.3: "*.localhost" subdomains - OS resolver is tried first, + // falling back to plain "localhost" resolution if OS resolver fails or returns empty. + // This preserves /etc/hosts customizations. + [Theory] + [InlineData("foo.localhost")] + [InlineData("bar.foo.localhost")] + [InlineData("test.localhost")] + [InlineData("FOO.LOCALHOST")] + [InlineData("Test.LocalHost")] + public async Task DnsGetHostEntry_LocalhostSubdomain_ReturnsLoopback(string hostName) + { + // 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. + IPHostEntry entry = Dns.GetHostEntry(hostName); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one loopback address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + entry = await Dns.GetHostEntryAsync(hostName); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one loopback address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: Ensure names that look similar but are not reserved are still resolved via OS. + [Theory] + [InlineData("notlocalhost")] + [InlineData("localhostfoo")] + [InlineData("invalidname")] + [InlineData("testinvalid")] + public async Task DnsGetHostEntry_SimilarButNotReserved_ThrowsSocketException(string hostName) + { + // These should go to the OS resolver and fail with HostNotFound (not special-cased). + Assert.ThrowsAny(() => Dns.GetHostEntry(hostName)); + await Assert.ThrowsAnyAsync(() => Dns.GetHostEntryAsync(hostName)); + } + + // Malformed hostnames should not be treated as RFC 6761 reserved names. + // They should fall through to the OS resolver which will reject them. + [Theory] + [InlineData(".localhost")] + [InlineData("foo..localhost")] + [InlineData(".invalid")] + [InlineData("test..invalid")] + public async Task DnsGetHostEntry_MalformedReservedName_NotTreatedAsReserved(string hostName) + { + // Malformed hostnames should go to OS resolver, not be special-cased. + // OS resolver will typically reject them with ArgumentException or SocketException. + Assert.ThrowsAny(() => Dns.GetHostEntry(hostName)); + 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. + [Fact] + public async Task DnsGetHostEntry_LocalhostWithTrailingDot_ReturnsLoopback() + { + IPHostEntry entry = Dns.GetHostEntry("localhost."); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + entry = await Dns.GetHostEntryAsync("localhost."); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: "*.localhost" subdomains should respect AddressFamily parameter. + // OS resolver is tried first, falling back to plain "localhost" resolution. + [Theory] + [InlineData(AddressFamily.InterNetwork)] + [InlineData(AddressFamily.InterNetworkV6)] + public async Task DnsGetHostEntry_LocalhostSubdomain_RespectsAddressFamily(AddressFamily addressFamily) + { + // Skip IPv6 test if OS doesn't support it. + if (addressFamily == AddressFamily.InterNetworkV6 && !Socket.OSSupportsIPv6) + { + return; + } + + string hostName = "test.localhost"; + + // The subdomain goes to OS resolver first. If it fails, it falls back to + // resolving plain "localhost" with the same address family filter. + IPHostEntry entry = Dns.GetHostEntry(hostName, addressFamily); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one address"); + Assert.All(entry.AddressList, addr => Assert.Equal(addressFamily, addr.AddressFamily)); + + entry = await Dns.GetHostEntryAsync(hostName, addressFamily); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one address"); + Assert.All(entry.AddressList, addr => Assert.Equal(addressFamily, addr.AddressFamily)); + } + + // RFC 6761: Verify that localhost subdomains return loopback addresses. + // Note: We don't require exact equality with plain "localhost" because: + // 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 + // The key requirement is that localhost subdomains return loopback addresses. + [Fact] + public async Task DnsGetHostEntry_LocalhostAndSubdomain_BothReturnLoopback() + { + IPHostEntry localhostEntry = Dns.GetHostEntry("localhost"); + IPHostEntry subdomainEntry = Dns.GetHostEntry("foo.localhost"); + + // Both should return loopback addresses + Assert.True(localhostEntry.AddressList.Length >= 1); + Assert.True(subdomainEntry.AddressList.Length >= 1); + Assert.All(localhostEntry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + Assert.All(subdomainEntry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + // Async version + localhostEntry = await Dns.GetHostEntryAsync("localhost"); + subdomainEntry = await Dns.GetHostEntryAsync("bar.localhost"); + + Assert.True(localhostEntry.AddressList.Length >= 1); + Assert.True(subdomainEntry.AddressList.Length >= 1); + Assert.All(localhostEntry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + Assert.All(subdomainEntry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + + // RFC 6761: Localhost subdomains with trailing dot should work (e.g., "foo.localhost.") + // Trailing dot is valid DNS notation indicating the root. + [Theory] + [InlineData("foo.localhost.")] + [InlineData("bar.test.localhost.")] + public async Task DnsGetHostEntry_LocalhostSubdomainWithTrailingDot_ReturnsLoopback(string hostName) + { + IPHostEntry entry = Dns.GetHostEntry(hostName); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one loopback address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + + entry = await Dns.GetHostEntryAsync(hostName); + Assert.True(entry.AddressList.Length >= 1, "Expected at least one loopback address"); + Assert.All(entry.AddressList, addr => Assert.True(IPAddress.IsLoopback(addr), $"Expected loopback address but got: {addr}")); + } + [Fact] public async Task DnsGetHostEntry_PreCancelledToken_Throws() {