(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
This commit is contained in:
Arnaud Dartois
2026-04-18 20:56:52 +02:00
committed by GitHub
parent 9cc3e4f49a
commit 6b75ccd2d1
5 changed files with 156 additions and 3 deletions

View File

@@ -294,7 +294,9 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
private async Task<bool> ProcessEmailForRecipient(MimeMessage message, IMailbox? toAddress)
{
// Check if toAddress domain is allowed.
if (toAddress is null || !config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant()))
if (toAddress is null ||
string.IsNullOrWhiteSpace(toAddress.Host) ||
!config.AllowedToDomains.Contains(toAddress.Host.Trim().ToLowerInvariant()))
{
// ToAddress domain is not allowed.
logger.LogInformation(

View File

@@ -0,0 +1,68 @@
//-----------------------------------------------------------------------
// <copyright file="RecipientDomainMailboxFilter.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.SmtpService.Handlers;
using SmtpServer;
using SmtpServer.Mail;
using SmtpServer.Protocol;
using SmtpServer.Storage;
/// <summary>
/// Mailbox filter that rejects recipients outside configured managed domains during RCPT TO.
/// </summary>
/// <param name="config">SMTP service configuration.</param>
/// <param name="logger">ILogger instance.</param>
public class RecipientDomainMailboxFilter(Config config, ILogger<RecipientDomainMailboxFilter> logger) : MailboxFilter
{
/// <summary>
/// Validate sender mailbox.
/// </summary>
/// <param name="context">The SMTP session context for the current connection.</param>
/// <param name="from">The sender mailbox from the MAIL FROM command.</param>
/// <param name="size">The declared message size in bytes.</param>
/// <param name="cancellationToken">A token that can cancel the asynchronous operation.</param>
/// <returns>A task containing <see langword="true" /> when the sender is accepted.</returns>
public override Task<bool> CanAcceptFromAsync(ISessionContext context, IMailbox @from, int size, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
/// <summary>
/// Validate recipient mailbox during RCPT TO command.
/// </summary>
/// <param name="context">The SMTP session context for the current connection.</param>
/// <param name="to">The recipient mailbox from the RCPT TO command.</param>
/// <param name="from">The sender mailbox from the MAIL FROM command.</param>
/// <param name="cancellationToken">A token that can cancel the asynchronous operation.</param>
/// <returns>A task containing <see langword="true" /> when the recipient is accepted.</returns>
public override Task<bool> CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox @from, CancellationToken cancellationToken)
{
if (IsAllowedRecipientDomain(to.Host))
{
return Task.FromResult(true);
}
logger.LogInformation(
"Rejected RCPT TO for recipient domain {RecipientDomain}: domain is not managed by this instance.",
to.Host);
// Use 550 to mirror typical "relay not permitted" behavior and keep client behavior consistent.
throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.MailboxUnavailable, "Relay not permitted"));
}
private bool IsAllowedRecipientDomain(string? domain)
{
if (string.IsNullOrWhiteSpace(domain))
{
return false;
}
var normalizedDomain = domain.Trim().ToLowerInvariant();
return config.AllowedToDomains.Contains(normalizedDomain);
}
}

View File

@@ -34,7 +34,12 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs
// Create global config object, get values from environment variables.
Config config = new Config();
var emailDomains = Environment.GetEnvironmentVariable("PRIVATE_EMAIL_DOMAINS") ?? string.Empty;
config.AllowedToDomains = emailDomains.Split(',').ToList();
config.AllowedToDomains = emailDomains
.Split(',')
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToList();
var tlsEnabled = Environment.GetEnvironmentVariable("SMTP_TLS_ENABLED") ?? "false";
config.SmtpTlsEnabled = tlsEnabled;
@@ -72,6 +77,7 @@ builder.Services.AddSingleton(config);
builder.Services.AddAliasVaultDatabaseConfiguration(builder.Configuration);
builder.Services.AddTransient<IMessageStore, DatabaseMessageStore>();
builder.Services.AddTransient<IMailboxFilter, RecipientDomainMailboxFilter>();
builder.Services.AddSingleton(
provider =>
{

View File

@@ -157,6 +157,46 @@ public class SmtpServerTests
});
}
/// <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>
@@ -272,7 +312,6 @@ public class SmtpServerTests
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.Cc.Add(new MailboxAddress("Test Recipient 3 unknown domain", "recipient@unknowndomain.tld"));
message.Subject = "Test Email";
const string textBody = "This is a test email plain.";
@@ -402,4 +441,41 @@ public class SmtpServerTests
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();
}
}

View File

@@ -95,6 +95,7 @@ public class TestHostBuilder : AbstractTestHostBuilder
};
});
services.AddTransient<IMailboxFilter, RecipientDomainMailboxFilter>();
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>