Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions src/libraries/System.Net.NameResolution/src/System/Net/Dns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -451,19 +452,43 @@ private static bool IsReservedName(string hostName, string reservedName)
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static string NormalizeLocalhostName(string hostName) =>
hostName.Equals(LocalhostWithTrailingDot, StringComparison.OrdinalIgnoreCase) ? Localhost : hostName;

/// <summary>
/// Checks if the given host name is a subdomain of localhost (e.g., "foo.localhost" or
/// "foo.localhost."). Plain "localhost" and "localhost." return false.
/// </summary>
/// <remarks>
/// 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 <see cref="NormalizeLocalhostName"/>), and
/// the fallback target itself is not a subdomain so the fallback cannot recurse on itself.
/// </remarks>
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);
}

Expand All @@ -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);
Expand All @@ -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'");
Expand All @@ -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;
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -801,7 +832,7 @@ static async Task<T> CompleteAsync(Task task, string hostName, bool justAddresse
{
result = await ((Task<T>)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'");
Expand All @@ -824,7 +855,7 @@ static async Task<T> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -370,7 +370,9 @@ public async Task DnsGetHostAddresses_MalformedReservedName_NotTreatedAsReserved
await Assert.ThrowsAnyAsync<Exception>(() => 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -405,8 +405,9 @@ public async Task DnsGetHostEntry_MalformedReservedName_NotTreatedAsReserved(str
await Assert.ThrowsAnyAsync<Exception>(() => 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()
{
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
28 changes: 27 additions & 1 deletion src/native/libs/System.Native/pal_networking.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
Loading