From 559101d7e21c0ef44a3f147a8935b49f0d62e948 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 3 Apr 2025 12:59:19 -0400 Subject: [PATCH] Add SMTP Mail Tests (#5597) * Add SMTP Mail Tests Co-authored-by: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> * Update test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs * Add Skipped Tests for upcoming feature * Safer TCS Completion --------- Co-authored-by: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> --- bitwarden-server.sln | 7 + .../MailKitSmtpMailDeliveryService.cs | 29 +- .../Core.IntegrationTest.csproj | 29 ++ .../MailKitSmtpMailDeliveryServiceTests.cs | 410 ++++++++++++++++++ 4 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 test/Core.IntegrationTest/Core.IntegrationTest.csproj create mode 100644 test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs diff --git a/bitwarden-server.sln b/bitwarden-server.sln index e9aff53f8e..892d2f4255 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -319,6 +321,10 @@ Global {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU + {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -370,6 +376,7 @@ Global {90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs index 4e7b7ee105..dc4e89aa23 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs @@ -34,6 +34,9 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService } public async Task SendEmailAsync(Models.Mail.MailMessage message) + => await SendEmailAsync(message, CancellationToken.None); + + public async Task SendEmailAsync(Models.Mail.MailMessage message, CancellationToken cancellationToken) { var mimeMessage = new MimeMessage(); mimeMessage.From.Add(new MailboxAddress(_globalSettings.SiteName, _replyEmail)); @@ -76,25 +79,37 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl && _globalSettings.Mail.Smtp.Port == 25) { - await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port, - MailKit.Security.SecureSocketOptions.None); + await client.ConnectAsync( + _globalSettings.Mail.Smtp.Host, + _globalSettings.Mail.Smtp.Port, + MailKit.Security.SecureSocketOptions.None, + cancellationToken + ); } else { var useSsl = _globalSettings.Mail.Smtp.Port == 587 && !_globalSettings.Mail.Smtp.SslOverride ? false : _globalSettings.Mail.Smtp.Ssl; - await client.ConnectAsync(_globalSettings.Mail.Smtp.Host, _globalSettings.Mail.Smtp.Port, useSsl); + await client.ConnectAsync( + _globalSettings.Mail.Smtp.Host, + _globalSettings.Mail.Smtp.Port, + useSsl, + cancellationToken + ); } if (CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Username) && CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Password)) { - await client.AuthenticateAsync(_globalSettings.Mail.Smtp.Username, - _globalSettings.Mail.Smtp.Password); + await client.AuthenticateAsync( + _globalSettings.Mail.Smtp.Username, + _globalSettings.Mail.Smtp.Password, + cancellationToken + ); } - await client.SendAsync(mimeMessage); - await client.DisconnectAsync(true); + await client.SendAsync(mimeMessage, cancellationToken); + await client.DisconnectAsync(true, cancellationToken); } } } diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj new file mode 100644 index 0000000000..6094209f23 --- /dev/null +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs new file mode 100644 index 0000000000..db2b945fda --- /dev/null +++ b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs @@ -0,0 +1,410 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Models.Mail; +using Bit.Core.Services; +using Bit.Core.Settings; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Rnwood.SmtpServer; +using Rnwood.SmtpServer.Extensions.Auth; +using Xunit.Abstractions; + +namespace Bit.Core.IntegrationTest; + +public class MailKitSmtpMailDeliveryServiceTests +{ + private readonly X509Certificate2 _selfSignedCert; + + public MailKitSmtpMailDeliveryServiceTests(ITestOutputHelper testOutputHelper) + { + ConfigureSmtpServerLogging(testOutputHelper); + + _selfSignedCert = CreateSelfSignedCert("localhost"); + } + + private static X509Certificate2 CreateSelfSignedCert(string commonName) + { + using var rsa = RSA.Create(2048); + var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + private static async Task SaveCertAsync(string filePath, X509Certificate2 certificate) + { + await File.WriteAllBytesAsync(filePath, certificate.Export(X509ContentType.Cert)); + } + + private static void ConfigureSmtpServerLogging(ITestOutputHelper testOutputHelper) + { + // Unfortunately this package doesn't public expose its logging infrastructure + // so we use private reflection to try and access it. + try + { + var loggingType = typeof(DefaultServerBehaviour).Assembly.GetType("Rnwood.SmtpServer.Logging") + ?? throw new Exception("No type found in RnWood.SmtpServer named 'Logging'"); + + var factoryProperty = loggingType.GetProperty("Factory") + ?? throw new Exception($"No property named 'Factory' found on class {loggingType.FullName}"); + + var factoryPropertyGet = factoryProperty.GetMethod + ?? throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} does not have a get method."); + + if (factoryPropertyGet.Invoke(null, null) is not ILoggerFactory loggerFactory) + { + throw new Exception($"{loggingType.FullName}.{factoryProperty.Name} is not of type 'ILoggerFactory'" + + $"instead it's type '{factoryProperty.PropertyType.FullName}'"); + } + + loggerFactory.AddXUnit(testOutputHelper); + } + catch (Exception ex) + { + testOutputHelper.WriteLine($"Failed to configure logging for RnWood.SmtpServer (logging will not be configured):\n{ex.Message}"); + } + } + private static int RandomPort() + { + return Random.Shared.Next(50000, 60000); + } + + private static GlobalSettings GetSettings(Action configure) + { + var globalSettings = new GlobalSettings(); + globalSettings.SiteName = "TestSiteName"; + globalSettings.Mail.ReplyToEmail = "test@example.com"; + globalSettings.Mail.Smtp.Host = "localhost"; + // Set common defaults + configure(globalSettings); + return globalSettings; + } + + [Fact] + public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertNotInTrustedRootStore_ThrowsException() + { + // If an SMTP server is using a self signed cert we currently require + // that the certificate for their SMTP server is installed in the root CA + // we are building the ability to do so without installing it, when we add that + // this test can be copied, and changed to utilize that new feature and instead of + // failing it should successfully send the email. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + }); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + await Assert.ThrowsAsync( + async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test@example.com"], + TextContent = "Hi", + }, new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token) + ); + } + + [Fact(Skip = "Upcoming feature")] + public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_Works() + { + // If an SMTP server is using a self signed cert we will in the future + // allow a custom location for certificates to be stored and the certitifactes + // stored there will also be trusted. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + }); + + // TODO: Setup custom location and save self signed cert there. + // await SaveCertAsync("./my-location", _selfSignedCert); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => _ = tcs.TrySetCanceled()); + + behavior.MessageReceivedEventHandler += (sender, args) => + { + if (args.Message.Recipients.Contains("test1@example.com")) + { + tcs.SetResult(); + } + return Task.CompletedTask; + }; + + await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token); + + // Wait for email + await tcs.Task; + } + + [Fact(Skip = "Upcoming feature")] + public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_WithUnrelatedCerts_Works() + { + // If an SMTP server is using a self signed cert we will in the future + // allow a custom location for certificates to be stored and the certitifactes + // stored there will also be trusted. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + }); + + // TODO: Setup custom location and save self signed cert there + // along with another self signed cert that is not related to + // the SMTP server. + // await SaveCertAsync("./my-location", _selfSignedCert); + // await SaveCertAsync("./my-location", CreateSelfSignedCert("example.com")); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => _ = tcs.TrySetCanceled()); + + behavior.MessageReceivedEventHandler += (sender, args) => + { + if (args.Message.Recipients.Contains("test1@example.com")) + { + tcs.SetResult(); + } + return Task.CompletedTask; + }; + + await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token); + + // Wait for email + await tcs.Task; + } + + [Fact] + public async Task SendEmailAsync_Succeeds_WhenCertIsSelfSigned_ServerIsTrusted() + { + // When the setting `TrustServer = true` is set even if the cert is + // self signed and the cert is not trusted in anyway the connection should + // still go through. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + gs.Mail.Smtp.TrustServer = true; + }); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => _ = tcs.TrySetCanceled()); + + behavior.MessageReceivedEventHandler += (sender, args) => + { + if (args.Message.Recipients.Contains("test1@example.com")) + { + tcs.SetResult(); + } + return Task.CompletedTask; + }; + + await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token); + + // Wait for email + await tcs.Task; + } + + [Fact] + public async Task SendEmailAsync_FailsConnectingWithTls_ServerDoesNotSupportTls() + { + // If the SMTP server is not setup to use TLS but our server is expecting it + // to, we should fail. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + gs.Mail.Smtp.TrustServer = true; + }); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await Assert.ThrowsAsync( + async () => await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token) + ); + } + + [Fact(Skip = "Requires permission to privileged port")] + public async Task SendEmailAsync_Works_NoSsl() + { + // If the SMTP server isn't set up with any SSL/TLS and we dont' expect + // any, then the email should go through just fine. Just without encryption. + // This test has to use port 25 + var port = 25; + var behavior = new DefaultServerBehaviour(false, port); + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = false; + gs.Mail.Smtp.StartTls = false; + }); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => _ = tcs.TrySetCanceled()); + + behavior.MessageReceivedEventHandler += (sender, args) => + { + if (args.Message.Recipients.Contains("test1@example.com")) + { + tcs.SetResult(); + } + return Task.CompletedTask; + }; + + await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token); + + // Wait for email + await tcs.Task; + } + + [Fact] + public async Task SendEmailAsync_Succeeds_WhenServerNeedsToAuthenticate() + { + // When the setting `TrustServer = true` is set even if the cert is + // self signed and the cert is not trusted in anyway the connection should + // still go through. + var port = RandomPort(); + var behavior = new DefaultServerBehaviour(false, port, _selfSignedCert); + behavior.AuthenticationCredentialsValidationRequiredEventHandler += (sender, args) => + { + args.AuthenticationResult = AuthenticationResult.Failure; + if (args.Credentials is not UsernameAndPasswordAuthenticationCredentials usernameAndPasswordCreds) + { + return Task.CompletedTask; + } + + if (usernameAndPasswordCreds.Username != "test" || usernameAndPasswordCreds.Password != "password") + { + return Task.CompletedTask; + } + + args.AuthenticationResult = AuthenticationResult.Success; + return Task.CompletedTask; + }; + using var smtpServer = new SmtpServer(behavior); + smtpServer.Start(); + + var globalSettings = GetSettings(gs => + { + gs.Mail.Smtp.Port = port; + gs.Mail.Smtp.Ssl = true; + gs.Mail.Smtp.TrustServer = true; + + gs.Mail.Smtp.Username = "test"; + gs.Mail.Smtp.Password = "password"; + }); + + var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( + globalSettings, + NullLogger.Instance + ); + + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => _ = tcs.TrySetCanceled()); + + behavior.MessageReceivedEventHandler += (sender, args) => + { + if (args.Message.Recipients.Contains("test1@example.com")) + { + tcs.SetResult(); + } + return Task.CompletedTask; + }; + + await mailKitDeliveryService.SendEmailAsync(new MailMessage + { + Subject = "Test", + ToEmails = ["test1@example.com"], + TextContent = "Hi", + }, cts.Token); + + // Wait for email + await tcs.Task; + } +}