From bdb5bd414f27cb69c6f07420ac8545e9a6746198 Mon Sep 17 00:00:00 2001 From: Rob Wood Date: Sun, 14 Apr 2024 14:32:29 +0100 Subject: [PATCH] Feat(Rules): Allow message validation expression #165 (#1377) --- Rnwood.Smtp4dev/ApiModel/Server.cs | 1 + Rnwood.Smtp4dev/ApiModel/Session.cs | 4 +- .../ClientApp/src/ApiClient/Server.ts | 4 +- .../src/components/settingsdialog.vue | 4 + .../Controllers/ServerController.cs | 4 +- Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj | 33 +++---- Rnwood.Smtp4dev/Server/ScriptingHost.cs | 93 ++++++++++++++++--- Rnwood.Smtp4dev/Server/ServerOptions.cs | 2 + Rnwood.Smtp4dev/Server/Smtp4devServer.cs | 43 +++++++-- Rnwood.Smtp4dev/appsettings.json | 18 +++- 10 files changed, 156 insertions(+), 50 deletions(-) diff --git a/Rnwood.Smtp4dev/ApiModel/Server.cs b/Rnwood.Smtp4dev/ApiModel/Server.cs index 206cf5e06..434a80c0e 100644 --- a/Rnwood.Smtp4dev/ApiModel/Server.cs +++ b/Rnwood.Smtp4dev/ApiModel/Server.cs @@ -32,6 +32,7 @@ public class Server public string CredentialsValidationExpression { get; set; } public bool SecureConnectionRequired { get; set; } public string RecipientValidationExpression { get; set; } + public string MessageValidationExpression { get; set; } } } diff --git a/Rnwood.Smtp4dev/ApiModel/Session.cs b/Rnwood.Smtp4dev/ApiModel/Session.cs index c551d7635..cde444b09 100644 --- a/Rnwood.Smtp4dev/ApiModel/Session.cs +++ b/Rnwood.Smtp4dev/ApiModel/Session.cs @@ -12,13 +12,15 @@ public Session(DbModel.Session dbSession) { this.Id = dbSession.Id; this.Error = dbSession.SessionError; - this.ErrorType = dbSession.SessionErrorType.ToString(); + this.ErrorType = dbSession.SessionErrorType?.ToString(); + this.StartDate = dbSession.StartDate; } public Guid Id { get; private set; } public string ErrorType { get; private set; } + public DateTime StartDate { get; } public string Error { get; private set; } diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts index c253316cd..447442ea0 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts @@ -5,7 +5,7 @@ export default class Server { constructor(isRunning: boolean, exception: string, portNumber: number, hostName: string, allowRemoteConnections: boolean, numberOfMessagesToKeep: number, numberOfSessionsToKeep: number, relayOptions: ServerRelayOptions, imapPortNumber: number, settingsAreEditable: boolean, disableMessageSanitisation: boolean, automaticRelayExpression: string, tlsMode: string, credentialsValidationExpression: string, authenticationRequired: boolean, - secureConnectionRequired: boolean, recipientValidationExpression: string) { + secureConnectionRequired: boolean, recipientValidationExpression: string, messageValidationExpression : string) { this.isRunning = isRunning; this.exception = exception; @@ -24,6 +24,7 @@ export default class Server { this.authenticationRequired = authenticationRequired; this.secureConnectionRequired = secureConnectionRequired; this.recipientValidationExpression = recipientValidationExpression; + this.messageValidationExpression = messageValidationExpression; } @@ -44,4 +45,5 @@ export default class Server { authenticationRequired: boolean; secureConnectionRequired: boolean; recipientValidationExpression: string; + messageValidationExpression: string; } diff --git a/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue b/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue index 88a0c4a2a..21a1039fc 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue +++ b/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue @@ -43,6 +43,10 @@ + + + + diff --git a/Rnwood.Smtp4dev/Controllers/ServerController.cs b/Rnwood.Smtp4dev/Controllers/ServerController.cs index ee79a0daa..9051eb23f 100644 --- a/Rnwood.Smtp4dev/Controllers/ServerController.cs +++ b/Rnwood.Smtp4dev/Controllers/ServerController.cs @@ -68,7 +68,8 @@ public ApiModel.Server GetServer() AuthenticationRequired = serverOptions.CurrentValue.AuthenticationRequired, SecureConnectionRequired = serverOptions.CurrentValue.SecureConnectionRequired, CredentialsValidationExpression = serverOptions.CurrentValue.CredentialsValidationExpression, - RecipientValidationExpression = serverOptions.CurrentValue.RecipientValidationExpression + RecipientValidationExpression = serverOptions.CurrentValue.RecipientValidationExpression, + MessageValidationExpression = serverOptions.CurrentValue.MessageValidationExpression }; } @@ -101,6 +102,7 @@ public ActionResult UpdateServer(ApiModel.Server serverUpdate) newSettings.SecureConnectionRequired = serverUpdate.SecureConnectionRequired; newSettings.CredentialsValidationExpression = serverUpdate.CredentialsValidationExpression; newSettings.RecipientValidationExpression = serverUpdate.RecipientValidationExpression; + newSettings.MessageValidationExpression = serverUpdate.MessageValidationExpression; newRelaySettings.SmtpServer = serverUpdate.RelayOptions.SmtpServer; newRelaySettings.SmtpPort = serverUpdate.RelayOptions.SmtpPort; diff --git a/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj b/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj index 93482380f..2bd30680c 100644 --- a/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj +++ b/Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj @@ -32,6 +32,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -44,6 +46,7 @@ + @@ -54,15 +57,10 @@ - - - - - + - + @@ -143,11 +141,9 @@ - + - + @@ -184,22 +180,18 @@ - + - + - - + + @@ -208,8 +200,7 @@ - + %(DistFiles.Identity) PreserveNewest diff --git a/Rnwood.Smtp4dev/Server/ScriptingHost.cs b/Rnwood.Smtp4dev/Server/ScriptingHost.cs index 291306f61..21989bdcc 100644 --- a/Rnwood.Smtp4dev/Server/ScriptingHost.cs +++ b/Rnwood.Smtp4dev/Server/ScriptingHost.cs @@ -7,6 +7,7 @@ using Jint; using Jint.Native; using Jint.Runtime; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Options; using Rnwood.Smtp4dev.DbModel; using Rnwood.SmtpServer; @@ -19,7 +20,7 @@ internal class ScriptingHost { private readonly ILogger log = Log.ForContext(); - + private IOptionsMonitor relayOptions; private IOptionsMonitor serverOptions; @@ -61,6 +62,8 @@ private void ParseScripts(RelayOptions relayOptionsCurrentValue, ServerOptions s ref credValidationSource); ParseScript("RecipientValidationExpression", serverOptionsCurrentValue.RecipientValidationExpression, ref recipValidationScript, ref recipValidationSource); + ParseScript("MessageValidationExpression", serverOptionsCurrentValue.MessageValidationExpression, ref messageValidationScript, + ref messageValidationSource); } private string shouldRelaySource; @@ -69,17 +72,21 @@ private void ParseScripts(RelayOptions relayOptionsCurrentValue, ServerOptions s private Script credValidationScript; private string recipValidationSource; private Script recipValidationScript; - - + + private string messageValidationSource; + private Script messageValidationScript; + + public bool HasValidateMessageExpression { get => this.messageValidationScript != null; } + public IReadOnlyCollection GetAutoRelayRecipients(ApiModel.Message message, string recipient, ApiModel.Session session) { if (shouldRelayScript == null) { return Array.Empty(); } - + Engine jsEngine = new Engine(); - + jsEngine.SetValue("recipient", recipient); jsEngine.SetValue("message", message); jsEngine.SetValue("session", session); @@ -91,7 +98,7 @@ public IReadOnlyCollection GetAutoRelayRecipients(ApiModel.Message messa List recpients = new List(); if (result.IsNull()) { - + } else if (result.IsString()) { @@ -128,15 +135,15 @@ public IReadOnlyCollection GetAutoRelayRecipients(ApiModel.Message messa } - public AuthenticationResult ValidateCredentials(Session session, IAuthenticationCredentials credentials) + public AuthenticationResult ValidateCredentials(ApiModel.Session session, IAuthenticationCredentials credentials) { if (credValidationScript == null) { return AuthenticationResult.Success; } - + Engine jsEngine = new Engine(); - + jsEngine.SetValue("credentials", credentials); jsEngine.SetValue("session", session); @@ -145,7 +152,7 @@ public AuthenticationResult ValidateCredentials(Session session, IAuthentication JsValue result = jsEngine.Evaluate(credValidationScript); bool success = result.AsBoolean(); - + log.Information("CredentialValidationExpression: (credentials: {credentials}, session: {session.Id}) => {result} => {success}", credentials, session.Id, result, success); @@ -163,16 +170,16 @@ public AuthenticationResult ValidateCredentials(Session session, IAuthentication return AuthenticationResult.TemporaryFailure; } } - - public bool ValidateRecipient(Session session, string recipient) + + public bool ValidateRecipient(ApiModel.Session session, string recipient) { if (recipValidationScript == null) { return true; } - + Engine jsEngine = new Engine(); - + jsEngine.SetValue("recipient", recipient); jsEngine.SetValue("session", session); @@ -181,7 +188,7 @@ public bool ValidateRecipient(Session session, string recipient) JsValue result = jsEngine.Evaluate(recipValidationScript); bool success = result.AsBoolean(); - + log.Information("RecipientValidationExpression: (recipient: {recipient}, session: {session.Id}) => {result} => {success}", recipient, session.Id, result, success); @@ -199,4 +206,60 @@ public bool ValidateRecipient(Session session, string recipient) return false; } } + + internal SmtpResponse ValidateMessage(ApiModel.Message message, ApiModel.Session session) + { + if (messageValidationScript == null) + { + return null; + } + + Engine jsEngine = new Engine(); + + jsEngine.SetValue("message", message); + jsEngine.SetValue("session", session); + jsEngine.SetValue("error", (Action)((code, message) => throw new SmtpServerException(new SmtpResponse(code ?? (int)StandardSmtpResponseCode.TransactionFailed, message ?? "")))); + + try + { + JsValue result = jsEngine.Evaluate(messageValidationScript); + + SmtpResponse response; + + if (result.IsNull() || result.IsUndefined()) + { + response = null; + } + else if (result.IsNumber()) + { + response = new SmtpResponse((int)result.AsNumber(), "Message rejected by MessageValidationExpression"); + } else if (result.IsString()) { + response = new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, result.AsString()); + } + else + { + response = result.AsBoolean() ? null : new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "Message rejected by MessageValidationExpression"); + } + + log.Information("MessageValidationExpression: (message: {message}, session: {session.Id}) => {result} => {success}", message, + session.Id, result, response?.Code.ToString() ?? "Success"); + + return response; + + } + catch (SmtpServerException ex) + { + return ex.SmtpResponse; + } + catch (JavaScriptException ex) + { + log.Error("Error executing MessageValidationExpression : {error}", ex.Error); + return new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "MessageValidationExpression failed"); + } + catch (Exception ex) + { + log.Error("Error executing MessageValidationExpression : {error}", ex.ToString()); + return new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "MessageValidationExpression failed"); + } + } } \ No newline at end of file diff --git a/Rnwood.Smtp4dev/Server/ServerOptions.cs b/Rnwood.Smtp4dev/Server/ServerOptions.cs index b7079b769..4e2e7f5c5 100644 --- a/Rnwood.Smtp4dev/Server/ServerOptions.cs +++ b/Rnwood.Smtp4dev/Server/ServerOptions.cs @@ -35,5 +35,7 @@ public class ServerOptions public bool AuthenticationRequired { get; set; } = false; public bool SecureConnectionRequired { get; set; } = false; public string RecipientValidationExpression { get; set; } + + public string MessageValidationExpression { get; set; } } } diff --git a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs index ccf2b7df5..21e4dffec 100644 --- a/Rnwood.Smtp4dev/Server/Smtp4devServer.cs +++ b/Rnwood.Smtp4dev/Server/Smtp4devServer.cs @@ -84,7 +84,7 @@ private void CreateSmtpServer() this.smtpServer.AuthenticationCredentialsValidationRequiredEventHandler += OnAuthenticationCredentialsValidationRequired; this.smtpServer.IsRunningChanged += OnIsRunningChanged; ((DefaultServerBehaviour)this.smtpServer.Behaviour).MessageStartEventHandler += OnMessageStart; - + ((DefaultServerBehaviour)this.smtpServer.Behaviour).MessageRecipientAddingEventHandler += OnMessageRecipientAddingEventHandler; } @@ -94,12 +94,13 @@ private Task OnMessageRecipientAddingEventHandler(object sender, RecipientAdding using var scope = serviceScopeFactory.CreateScope(); Smtp4devDbContext dbContext = scope.ServiceProvider.GetService(); var session = dbContext.Sessions.AsNoTracking().Single(s => s.Id == sessionId); + var apiSession = new ApiModel.Session(session); - if (!this.scriptingHost.ValidateRecipient(session, e.Recipient)) + if (!this.scriptingHost.ValidateRecipient(apiSession, e.Recipient)) { throw new SmtpServerException(new SmtpResponse(StandardSmtpResponseCode.RecipientRejected, "Recipient rejected")); } - + return Task.CompletedTask; } @@ -109,12 +110,12 @@ private Task OnMessageStart(object sender, MessageStartEventArgs e) { throw new SmtpServerException(new SmtpResponse(451, "Secure connection required")); } - + if (this.serverOptions.CurrentValue.AuthenticationRequired && !e.Session.Authenticated) { throw new SmtpServerException(new SmtpResponse(StandardSmtpResponseCode.AuthenticationRequired, "Authentication is required")); } - + return Task.CompletedTask; } @@ -125,9 +126,29 @@ private void OnIsRunningChanged(object sender, EventArgs e) this.notificationsHub.OnServerChanged().Wait(); } - private Task OnMessageCompleted(object sender, ConnectionEventArgs e) + private async Task OnMessageCompleted(object sender, ConnectionEventArgs e) { - return Task.CompletedTask; + if (!scriptingHost.HasValidateMessageExpression) + { + return; + } + + Message message = new MessageConverter().ConvertAsync(await e.Connection.CurrentMessage.ToMessage()).Result; + + var apiMessage = new ApiModel.Message(message); + + using var scope = serviceScopeFactory.CreateScope(); + Smtp4devDbContext dbContext = scope.ServiceProvider.GetService(); + Session dbSession = dbContext.Sessions.Find(activeSessionsToDbId[e.Connection.Session]); + + var apiSession = new ApiModel.Session(dbSession); + + var errorResponse = scriptingHost.ValidateMessage(apiMessage, apiSession); + + if (errorResponse != null) + { + throw new SmtpServerException(errorResponse); + } } public void Stop() @@ -170,7 +191,9 @@ private Task OnAuthenticationCredentialsValidationRequired(object sender, Authen Smtp4devDbContext dbContext = scope.ServiceProvider.GetService(); var session = dbContext.Sessions.Single(s => s.Id == sessionId); - AuthenticationResult result = scriptingHost.ValidateCredentials(session, e.Credentials); + var apiSession = new ApiModel.Session(session); + + AuthenticationResult result = scriptingHost.ValidateCredentials(apiSession, e.Credentials); e.AuthenticationResult = result; return Task.CompletedTask; @@ -278,7 +301,7 @@ void ProcessMessage() log.Information("Processing received message"); using var scope = serviceScopeFactory.CreateScope(); Smtp4devDbContext dbContext = scope.ServiceProvider.GetService(); - + message.Session = dbContext.Sessions.Find(activeSessionsToDbId[e.Message.Session]); var relayResult = TryRelayMessage(message, null); message.RelayError = string.Join("\n", relayResult.Exceptions.Select(e => e.Key + ": " + e.Value.Message)); @@ -340,7 +363,7 @@ public RelayResult TryRelayMessage(Message message, MailboxAddress[] overrideRec recipients.AddRange(overrideRecipients); } - foreach (MailboxAddress recipient in recipients.DistinctBy(r =>r.Address)) + foreach (MailboxAddress recipient in recipients.DistinctBy(r => r.Address)) { try { diff --git a/Rnwood.Smtp4dev/appsettings.json b/Rnwood.Smtp4dev/appsettings.json index 75bb982ca..5d92f26da 100644 --- a/Rnwood.Smtp4dev/appsettings.json +++ b/Rnwood.Smtp4dev/appsettings.json @@ -91,7 +91,23 @@ // - Accepts this recipient only // recipient != "foo@bar.com" // - Rejects this recipient only - "RecipientValidationExpression": "" + "RecipientValidationExpression": "", + + // A JavaScript expression used to validate message to determine if the server will accept it. + // The return value is a boolean value where true will accept the message. + //If the return value is a number it will be used as response code + //If the return value is a string, it will be used as an error response message + // The variable 'message' refers to the current message + // For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Message.cs + // The variable `session` refers to the the current session: + // For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Session.cs + // The function error(code: number, message: string) returns a specific SMTP error and message immediately. + // Examples + // !message.subject.includes("19") + // - Rejects messages that include in the subject + // message.subject.includes("19") ? 441 : null + // - Rejects messages that include 19 with a 441, otherwise accepts + "MessageValidationExpression": "" }, "RelayOptions": {