jstedfast / MailKit

A cross-platform .NET library for IMAP, POP3, and SMTP.
http://www.mimekit.net
MIT License
6.17k stars 820 forks source link

EHLO/HELO not RFC5321 compatible #1436

Closed Taldrit78 closed 2 years ago

Taldrit78 commented 2 years ago

By reference of RFC5321 the FQDN should be transferred, or when not available, the IP address. So we still use MailKit 2.15.0, because the version 3.4.1 only trasfers the device name. So I have to set the LocalDomain by hand.

RFC5321 text: The domain name, as described in this document and in RFC 1035 [2], is the entire, fully-qualified name (often referred to as an "FQDN"). A domain name that is not in FQDN form is no more than a local alias. Local aliases MUST NOT appear in any SMTP transaction.

Only resolvable, fully-qualified domain names (FQDNs) are permitted when domain names are used in SMTP. In other words, names that can be resolved to MX RRs or address (i.e., A or AAAA) RRs (as discussed in Section 5) are permitted, as are CNAME RRs whose targets can be resolved, in turn, to MX or address RRs. Local nicknames or unqualified names MUST NOT be used. There are two exceptions to the rule requiring FQDNs:

o The domain name given in the EHLO command MUST be either a primary host name (a domain name that resolves to an address RR) or, if the host has no name, an address literal, as described in Section 4.1.3 and discussed further in the EHLO discussion of Section 4.1.4.

jstedfast commented 2 years ago

It looks like 2.15.0 always used the IP address (gotten from Socket.LocalEndPoint) unless a LocalDomain string was specified: SmtpClient.cs#L646

3.x moved to using IPGlobalProperties.GetIPGlobalProperties ().HostName because some SMTP servers were rejecting IP addresses that were gotten from Socket.LocalEndPoint.

Do you know of a reliable way of getting the FQDN?

Taldrit78 commented 2 years ago

It seems by this discussion: https://stackoverflow.com/questions/804700/how-to-find-fqdn-of-local-machine-in-c-net it would be the best choice to get the DomainName instead of the HostName and the HostName of the DNS.GetHostName();. And then to check if the dns host name does have the domain name of the IPGlobalProperties at the end and if not to build this name together by both properties. I tested it in different locations (our domain, a clients domain, my personal computer without domain, a VM without domain, etc.) and all returned the correct FQDN.

But please also read the part of the RFC where it says: The domain name given in the EHLO command MUST be either a primary host name (a domain name that resolves to an address RR) or, if the host has no name, an address literal, as described in [Section 4.1.3] and discussed further in the EHLO discussion of [Section 4.1.4].

So you should not use localhost, but the IP address. You make this nicely done "address literal" inside the SendEhloAsync method (even when I would use $"[{ip}]" instead of "[" + ip + "]"; ). But for the DefaultLocalDomain you only use "localhost". And that would be false in respect of the RFC. At least as far as I understand and our customer is fairly sure that this is not correct and cannot work with it.

jstedfast commented 2 years ago

Let me ask this:

What is the motivation behind this bug report?

I ask because, in my experience, in practice, SMTP servers don't care about the string that is used... at all. The ones that do, seem to be more strict with IP addresses than hostnames (i.e. if the IP address provided doesn't match their Socket.RemoteEndPoint value, then they reject it).

This is why SmtpClient does what it does rather than the old 2.15.0 way of doing things.

In practical terms since I cannot find a reliable way to get the FQDN (and that StackOverflow answer doesn't work - I tried it this weekend as well as a number of other variations), I'm going to likely have to go back to using IP addresses, but I already know that doesn't work with all servers.

I feel that I am between a rock and a hard place. I can't make this work for everyone, hence the LocalDomain string property.

jstedfast commented 2 years ago

Here are all of the solutions I came up with:

using System;
using System.Net;
using System.Net.Sockets;
using System.Net.NetworkInformation;

namespace GetFQDN
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World! Join me on an adventure to try and find the FQDN of your current machine.");

            Console.WriteLine("Dns.GetHostName: {0}", Dns.GetHostName());
            Console.WriteLine("Dns.GetHostEntry: {0}", GetFQDNViaDnsGetHostEntry());
            Console.WriteLine("IPGlobalProperties: {0}", GetFQDNViaIPGlobalProperties());

            using (var socket = new Socket(/*AddressFamily.InterNetwork,*/ SocketType.Stream, ProtocolType.Tcp)) {
                socket.Connect("www.google.com", 443);

                Console.WriteLine("Socket.LocalEndPoint: {0}", GetFQDNViaSocketLocalEndPoint(socket));
                Console.WriteLine("NetworkInterface: {0}", GetFQDNViaNetworkInterface(socket));

                socket.Disconnect(false);
            }
        }

        static string GetFQDNViaIPGlobalProperties()
        {
            var properties = IPGlobalProperties.GetIPGlobalProperties();

            if (!string.IsNullOrEmpty(properties.DomainName))
                return properties.HostName + "." + properties.DomainName;

            return properties.HostName;
        }

        static string GetFQDNViaDnsGetHostEntry()
        {
            var entry = Dns.GetHostEntry(Dns.GetHostName());
            var aliases = string.Join(", ", entry.Aliases);

            return $"HostName: {entry.HostName}; Aliases={aliases}";
        }

        static string GetFQDNViaSocketLocalEndPoint(Socket socket)
        {
            if (socket.LocalEndPoint is IPEndPoint ipEndPoint) {
                var ipAddress = ipEndPoint.Address;
                if (ipAddress.IsIPv4MappedToIPv6)
                    ipAddress = ipAddress.MapToIPv4();

                var entry = Dns.GetHostEntry(ipAddress);
                var aliases = string.Join(", ", entry.Aliases);

                return $"IP: {ipAddress}; HostName: {entry.HostName}; Aliases={aliases}";
            } else if (socket.LocalEndPoint is DnsEndPoint dnsEndPoint) {
                return dnsEndPoint.Host;
            } else {
                return "Nope.";
            }
        }

        static bool IPAddressesEqual(IPAddress ipAddress1, IPAddress ipAddress2)
        {
            if (ipAddress1.AddressFamily != ipAddress2.AddressFamily)
                return false;

            var addr1 = ipAddress1.GetAddressBytes();
            var addr2 = ipAddress2.GetAddressBytes();

            for (int i = 0; i < addr1.Length; i++) {
                if (addr1[i] != addr2[i])
                    return false;
            }

            return true;
        }

        static string GetFQDNViaNetworkInterface(Socket socket)
        {
            var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();

            if (socket.LocalEndPoint is not IPEndPoint ipEndPoint)
                return "Nope.";

            var ipAddress = ipEndPoint.Address;
            if (ipAddress.IsIPv4MappedToIPv6)
                ipAddress = ipAddress.MapToIPv4();

            foreach (var networkInterface in networkInterfaces) {
                var properties = networkInterface.GetIPProperties();
                var unicast = properties.UnicastAddresses;

                var found = unicast.FirstOrDefault(x => IPAddressesEqual(x.Address, ipAddress));
                if (found == null)
                    continue;

                return Dns.GetHostName() + "." + properties.DnsSuffix;
            }

            return "Nope.";
        }
    }
}

Which ones of these work for you (or your customer) and which ones get the wrong value?

jstedfast commented 2 years ago

For me, the GetFQDNViaNetworkInterface method is the most "accurate".

If I go over my VPN, it gets the correct domain for my machine. If I go over the net, it's "wrong", but that's because I'm behind a NAT and ends up providing my local area network domain.

jstedfast commented 2 years ago

FWIW, take a look at the System.Net.Mail.SmtpClient implementation:

https://referencesource.microsoft.com/#System/net/System/Net/mail/SmtpClient.cs

If a domain string is specified in the SmtpClient config, then it uses that.

If not, it does exactly what MailKit does, which is to use the IPGlobalProperties HostName value (it does not use the DomainName at all).

If that fails, it uses "LocalHost".

jstedfast commented 2 years ago

Also here: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs#L119-L152

Taldrit78 commented 2 years ago

That version doesn't work for me. In this very moment I have no connection to our internal DNS and so there is no suffix, but only "{Hostname}.{string.Empty}". The 2nd to 4th are working fine. Our customer has an actual problem. He uses a Notes Server and configured the server to only accept specific domains. And therefore they refuse e-mails which only send ip adresses or only the hostname.

Taldrit78 commented 2 years ago

Now I asked myself how do the actual email clients like Thunderbird do this. But it is very confusing to work through such a huge project like the Mozilla Thunderbird (not only because it is JavaScript and Python). I couldn't find it to be honest.

Taldrit78 commented 2 years ago

The 5th version seems to be broken in different environments. https://answers.microsoft.com/en-us/windows/forum/all/win-10-dns-resolution-of-remote-network-via-vpn/513bdeea-0d18-462e-9ec3-a41129eec736

jstedfast commented 2 years ago

Ironically, the 5th one is the only one that doesn't give me just the host name.

Finding a reliable way of getting the FQDN is not so easy, is it?

This is why MailKit has the LocalDomain property.

Your customer just needs to use that. Even if the used System.Net.Mail, they would have to do that.

jstedfast commented 2 years ago

FWIW, I've tested both Windows 11 and MacOS (dotnet) and in both cases, the NetworkInterface version is the only one that has any accuracy:

Dns.GetHostName: Jeffreys-MacBook-Pro.local
Dns.GetHostEntry: HostName: jeffreys-macbook-pro.local; Aliases=
IPGlobalProperties: Jeffreys-MacBook-Pro.local
Socket.LocalEndPoint: Nope. <-- this actually throws an exception because Dns.GetHostEntry() does not support doing lookups on IP addresses on MacOS, apparently.
NetworkInterface: Jeffreys-MacBook-Pro.local.corp.microsoft.com
Taldrit78 commented 2 years ago

Ok, I spoke with my team leader and what we should do. He will speak to our product manager (this strange bureaucracy) and it seems we will take the approach, that we build an extra TextBox with the LocalDomain setting as Binding. Maybe we try an approach with a mix of the above methods of yours and at least try to read the domain name and build it together by hand, but you are right that it is nearly fruitless to try and use an automation that always runs in an error depending on the environment.

Oh... btw... you are doing a really good job with this project.

jstedfast commented 2 years ago

FWIW, here is what Thunderbird does: https://github.com/mozilla/releases-comm-central/blob/master/mailnews/compose/src/SmtpClient.jsm#L768-L778

They use the equivalent of the Socket.LocalEndPoint IP address formatted as "[IPv6:{ip}]" or "[{ip}]" depending on the address family of the IP address.

Evolution tries to rDNS the Socket.LocalEndPoint (equivalent) and if it has a '.', then it assumes it has what is probably an FQDN, else it falls back to using the IP address with the proper formatting: https://gitlab.gnome.org/GNOME/evolution-data-server/-/blob/master/src/camel/providers/smtp/camel-smtp-transport.c#L1476-1510

jstedfast commented 2 years ago

I think your customer is being too strict in the wrong way. If they want to restrict who can send mail to just local machines, it should restrict based on the connecting IP address, not the EHLO argument.

The problem is that with most setups is that machines are rarely directly connected to the internet. They are almost always behind a router and using a local area IP address (e.g. for me, my machine is 192.168.1.104) which is not at all an IP address that can be resolved by the SMTP server, so even using the IP address is a bogus string in most cases.

What the SMTP server can do is check the IP address in the Socket.RemoteEndPoint and see that the IP of my "machine" is actually 100.72.xxx.yyy and then do restrictions based on that.

Taldrit78 commented 2 years ago

Oh, erm... I don't know if you want to change anything based on our discussion, but in our opinion this bug can be closed. And thank you once again for the effort.