mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-09 15:56:11 -04:00
(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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ public class TestHostBuilder : AbstractTestHostBuilder
|
||||
};
|
||||
});
|
||||
|
||||
services.AddTransient<IMailboxFilter, RecipientDomainMailboxFilter>();
|
||||
services.AddTransient<IMessageStore, DatabaseMessageStore>();
|
||||
services.AddSingleton<SmtpServer>(
|
||||
provider =>
|
||||
|
||||
Reference in New Issue
Block a user