Files
Arnaud Dartois 6b75ccd2d1 (feat) implement reject rcp to for external domain (#1910)
* (feat) : Add RCPT-stage domain rejection to reduce SMTP open-relay false positives

* (docs) add comments in .env

* (tests) add unit tests for RCPT TO

* (docs) : update installation documentation with information about  RCPT TO process.

* (docs) : add missing header parameter

* error SA1508: A closing brace should not be preceded by a blank line

* (fix) SA1615 - fix stylecop rules

* Fix TU

* revert

* revert

* revert
2026-04-18 20:56:52 +02:00

482 lines
21 KiB
C#

//-----------------------------------------------------------------------
// <copyright file="SmtpServerTests.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.SmtpServer;
using System.Net.Sockets;
using System.Text;
using AliasServerDb;
using AliasVault.Cryptography.Server;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using MimeKit;
/// <summary>
/// SmtpServerTests class.
/// </summary>
[TestFixture]
public class SmtpServerTests
{
/// <summary>
/// Example public key for RSA encryption tests. This is a public key generated by the JSInterop on the client.
/// We use this here to also test the server-side decryption implementation, even though this isn't a real-world scenario.
/// </summary>
public const string PublicKey = "{\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"encrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\"}";
/// <summary>
/// Example private key for RSA encryption tests. This is a private key generated by the JSInterop on the client.
/// We use this here to also test the server-side decryption implementation, even though this isn't a real-world scenario.
/// </summary>
public const string PrivateKey = "{\"alg\":\"RSA-OAEP-256\",\"d\":\"KLByToUaseNym1oNkkrTRPQOHfREXywWWaTXhP8AwtXgEKomqv9G-c6aR-K-T6btY2P-oPj268I0rbnRhSEQdrsmUT5_cp8goYGJrx6MFwGlA32x6klXnus6GDsjkXJi7I5eJL17XV99CDOBtTagFxkNdaBpvClUcHTDvncQ5bGAIrNqS7KADoi-E19BxiW_GcSJiVT4H8kDHCkcgTjZx4rKJjTPqqJOLg_poDrvnTJbsjcXP80kQ1AAENRAvDGhSWzP0IYtP1DM_2FzM1s1b_SrUsS3KiO8drR2Kv-PSOvncpaNVnZGElGCraJ3B2Mm-dr3vFjkyWeWPceqyhtYoQ\",\"dp\":\"ttxRg6uB2YLWfkPKUkzAaBWniZDHM4silJX3IgexA5GJBd9GIhUiVEolc_MgmieQbZ10CC65wqcHVv82lgCeqxYHxHWLxxJCrOpvkFlYE8wr_WqOPQEzYKv3KsL6s6Fj7Pbv9WehWpXdlbJUm4Cy5cgUkdH6PXiwBSvfhCQGrYk\",\"dq\":\"YFqlDAVTfvTR2bMJulvWzd_at81CsEmR-lPo91h-3cLpxcLDOlrTP-d3Ass2I4r1PtBT1bKuuHeQ6fZmHH55a6m8XxPEs2BuIxlh9RiFfWbd66969UOnItuawf0rfGneKt1zl4st60T3KXd8-ECrLxdsvOYpOEuNzvIY_b3qitE\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"decrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\",\"p\":\"yUdbuDwmVwKhou5xXUxJfi1eOjN-5F88wtyR4LpgU2OvZ7m-er4hpXx5I2E-KTVX_iIp0Q9VDXhHH-WkN3qg20RXjRoxwgrggYbfdIYdrB-2kbMamq5cOf2XbXGEO8PoDXYoZprIB0EhrD4qVVykPUYg5El0hIKPdfs9LNoOEzs\",\"q\":\"vg93lGTurG0EY179tPr6Qe3ttKEN9zvQ97dZ9034DOWDoWLe-iMKG1-yKmkG4uwC8QqNnm1mPz7EqOuHPPGVTTib9NA4JdM27PUHSPKDUvp0cV4LhF6e-W7tMFk8WbJ2ACqkqhZHYgm-FDkZBCpnehNegTxipLluKa79G__ZHFE\",\"qi\":\"fnI3Wh5aYuxI0R18NTeFKjo1P7_Ck65Gc9O3CmeqiIe58EJaXQEcdwdSOG8aVmn03szXLHEnp7anNIH63f0ericbRYdCQVhcQpvsXzEM_sp4aYmwz45palrjlY4Jc6G6XQn3FwiqqRDvpnXdsunnQ62HHhxmslaEMYHQyLng2ss\"}";
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder;
/// <summary>
/// Setup logic for every test.
/// </summary>
/// <returns>Task.</returns>
[SetUp]
public async Task Setup()
{
_testHostBuilder = new TestHostBuilder();
_testHost = _testHostBuilder.Build();
await _testHost.StartAsync();
// Create an AliasVault user, public key and an email claim.
var dbContext = _testHostBuilder.GetDbContext();
var user = new AliasVaultUser
{
UserName = "testuser",
Email = "testuser@example.tld",
};
dbContext.AliasVaultUsers.Add(user);
await dbContext.SaveChangesAsync();
// Create email claims.
var emailClaim = new UserEmailClaim
{
UserId = user.Id,
Address = "claimed@example.tld",
AddressLocal = "claimed",
AddressDomain = "example.tld",
};
dbContext.UserEmailClaims.Add(emailClaim);
var emailClaim2 = new UserEmailClaim
{
UserId = user.Id,
Address = "claimed.cc@example.tld",
AddressLocal = "claimed.cc",
AddressDomain = "example.tld",
};
dbContext.UserEmailClaims.Add(emailClaim2);
// Create disabled email claim.
var emailClaimDisabled = new UserEmailClaim
{
UserId = user.Id,
Address = "disabled@example.tld",
AddressLocal = "disabled",
AddressDomain = "example.tld",
Disabled = true,
};
dbContext.UserEmailClaims.Add(emailClaimDisabled);
// Create public key.
var encryptionKey = new UserEncryptionKey
{
UserId = user.Id,
PublicKey = PublicKey,
IsPrimary = true,
};
dbContext.UserEncryptionKeys.Add(encryptionKey);
await dbContext.SaveChangesAsync();
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
/// <returns>Task.</returns>
[TearDown]
public async Task TearDown()
{
await _testHost.StopAsync();
_testHost.Dispose();
await _testHostBuilder.DisposeAsync();
}
/// <summary>
/// The SMTP greeting line must include the configured advertised hostname (same path as production resolver).
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task SmtpBanner_ContainsConfiguredHostname()
{
using var client = new TcpClient();
const int maxRetries = 10;
const int retryDelayMs = 100;
for (var attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await client.ConnectAsync("127.0.0.1", 2525);
break;
}
catch (SocketException) when (attempt < maxRetries)
{
await Task.Delay(retryDelayMs);
}
}
using var stream = client.GetStream();
var buffer = new byte[512];
var n = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
var line = Encoding.ASCII.GetString(buffer, 0, n);
Assert.Multiple(() =>
{
Assert.That(line, Does.StartWith("220 "));
Assert.That(line, Does.Contain(TestHostBuilder.IntegrationAdvertisedHostname));
});
}
/// <summary>
/// RCPT TO for external domains should be rejected immediately with a 550 relay denial.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task RcptToExternalDomain_IsRejectedAtRcptStage()
{
using var client = await ConnectRawSmtpClient();
using var stream = client.GetStream();
_ = await ReadSmtpLineAsync(stream); // Greeting
Assert.That(await SendSmtpCommandAsync(stream, "HELO localhost"), Does.StartWith("250"));
Assert.That(await SendSmtpCommandAsync(stream, "MAIL FROM:<sender@example.com>"), Does.StartWith("250"));
var rcptResponse = await SendSmtpCommandAsync(stream, "RCPT TO:<recipient@unknowndomain.tld>");
Assert.Multiple(() =>
{
Assert.That(rcptResponse, Does.StartWith("550"));
Assert.That(rcptResponse.ToLowerInvariant(), Does.Contain("relay not permitted"));
});
}
/// <summary>
/// RCPT TO for managed domains should be accepted regardless of alias existence (privacy protection).
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task RcptToManagedDomain_IsAcceptedAtRcptStage()
{
using var client = await ConnectRawSmtpClient();
using var stream = client.GetStream();
_ = await ReadSmtpLineAsync(stream); // Greeting
Assert.That(await SendSmtpCommandAsync(stream, "HELO localhost"), Does.StartWith("250"));
Assert.That(await SendSmtpCommandAsync(stream, "MAIL FROM:<sender@example.com>"), Does.StartWith("250"));
var rcptResponse = await SendSmtpCommandAsync(stream, "RCPT TO:<not-claimed@example.tld>");
Assert.That(rcptResponse, Does.StartWith("250"));
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server with valid claim to check if it is processed correctly.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task SingleEmailPlain()
{
// Email the SMTP server.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody();
await SendMessageToSmtpServer(message);
// Check if the email is in the database.
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);
Assert.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" <sender@example.com>"));
Assert.That(processedEmail.FromLocal, Is.EqualTo("sender"));
Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com"));
Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain."));
Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain."));
Assert.That(processedEmail.MessageHtml, Is.Null);
});
}
/// <summary>
/// Tests sending a single email in html format to the SMTP server to check if it is processed correctly.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task SingleEmailHtml()
{
// Arrange
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld"));
message.Subject = "Test Email with HTML body.";
const string htmlBody = "<html><body><h1>This is a test email html.</h1></body></html>";
message.Body = new BodyBuilder { HtmlBody = htmlBody }.ToMessageBody();
await SendMessageToSmtpServer(message);
// Check if the email is in the database.
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);
Assert.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html."));
Assert.That(processedEmail.MessagePlain, Is.Null);
Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody));
});
}
/// <summary>
/// Tests sending a single email in multipart format to the SMTP server to check if it is processed correctly.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task SingleEmailMultipart()
{
// Arrange
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld"));
message.Subject = "Test Email with multipart body.";
const string textBody = "This is a test email multipart.";
const string htmlBody = "<html><body><h1>This is a test email multipart.</h1></body></html>";
message.Body = new BodyBuilder { TextBody = textBody, HtmlBody = htmlBody }.ToMessageBody();
await SendMessageToSmtpServer(message);
// Check if the email is in the database.
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);
Assert.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart."));
Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart."));
Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody));
});
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task MultipleRecipientsEmail()
{
// Send email to the SMTP server.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld"));
message.Cc.Add(new MailboxAddress("Test Recipient 2", "claimed.cc@example.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody();
await SendMessageToSmtpServer(message);
// Check that two emails are in the database, one for each allowed recipient.
Assert.That(await _testHostBuilder.GetDbContext().Emails.CountAsync(), Is.EqualTo(2));
}
/// <summary>
/// Tests sending an email to an unknown recipient domain, we expect to get an error from the SMTP server.
/// </summary>
[Test]
public void SingleEmailUnknownRecipientDomain()
{
// Send email to the SMTP server.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "recipient@unknowndomain.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody();
// Expect error from SmtpClient when sending email to unknown domain.
Assert.ThrowsAsync<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// Tests sending an email to an existing but disabled email claim, we expect to get an error from the SMTP server.
/// </summary>
[Test]
public void SingleEmailDisabledUserClaim()
{
// Send email to the SMTP server.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "disabled@example.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody();
// Expect error from SmtpClient when sending email to unknown domain.
Assert.ThrowsAsync<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// Tests sending a single email to a known recipient domain but with no valid user claim. We expect
/// to get an error from the SMTP server.
/// </summary>
[Test]
public void SingleEmailNoUserClaim()
{
// Send email to the SMTP server.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "not-claimed@example.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody();
// Expect error from SmtpClient when sending email to unknown domain.
Assert.ThrowsAsync<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task AttachmentEmail()
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld"));
message.Subject = "Test Email with attachment";
var bodyBuilder = new BodyBuilder();
bodyBuilder.TextBody = "This is a test email with attachment.";
// Add attachment using BodyBuilder
byte[] attachmentData = Encoding.UTF8.GetBytes("This is an attachment.");
bodyBuilder.Attachments.Add("attachment.txt", attachmentData, ContentType.Parse("text/plain"));
message.Body = bodyBuilder.ToMessageBody();
await SendMessageToSmtpServer(message);
// Check that attachment is in the database and the bytes are encrypted.
Assert.That(await _testHostBuilder.GetDbContext().EmailAttachments.CountAsync(), Is.EqualTo(1));
var attachment = await _testHostBuilder.GetDbContext().EmailAttachments.FirstAsync();
Assert.That(attachment.Bytes, Is.Not.EqualTo(attachmentData), "Email attachment bytes are not encrypted. Check email encryption logic.");
}
/// <summary>
/// Sends a message to the SMTP server with retry logic for connection.
/// </summary>
/// <param name="message">MimeMessage to send.</param>
private static async Task SendMessageToSmtpServer(MimeMessage message)
{
using var client = new SmtpClient();
// Retry connection up to 10 times with 100ms delay to handle race condition
// where the SMTP server may not be fully started yet.
const int maxRetries = 10;
const int retryDelayMs = 100;
for (var attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await client.ConnectAsync("localhost", 2525, SecureSocketOptions.None);
break;
}
catch (System.Net.Sockets.SocketException) when (attempt < maxRetries)
{
await Task.Delay(retryDelayMs);
}
}
try
{
await client.SendAsync(message);
}
finally
{
await client.DisconnectAsync(true);
}
}
private static async Task<TcpClient> ConnectRawSmtpClient()
{
var client = new TcpClient();
const int maxRetries = 10;
const int retryDelayMs = 100;
for (var attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await client.ConnectAsync("127.0.0.1", 2525);
return client;
}
catch (SocketException) when (attempt < maxRetries)
{
await Task.Delay(retryDelayMs);
}
}
throw new InvalidOperationException("Unable to connect to SMTP server for raw command test.");
}
private static async Task<string> SendSmtpCommandAsync(NetworkStream stream, string command)
{
var bytes = Encoding.ASCII.GetBytes($"{command}\r\n");
await stream.WriteAsync(bytes.AsMemory(0, bytes.Length));
await stream.FlushAsync();
return await ReadSmtpLineAsync(stream);
}
private static async Task<string> ReadSmtpLineAsync(NetworkStream stream)
{
var buffer = new byte[512];
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
return Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim();
}
}