Skip to content

Commit ab0d80c

Browse files
authored
feat(config): Add CommandValidationExpression (#1735)
1 parent 0fc0fde commit ab0d80c

File tree

14 files changed

+173
-28
lines changed

14 files changed

+173
-28
lines changed

Rnwood.Smtp4dev/ApiModel/Server.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public class Server
6767

6868
public string CurrentUserDefaultMailboxName { get; set; }
6969
public string HtmlValidateConfig { get; set; }
70+
public string CommandValidationExpression { get; set; }
7071
}
7172

7273
}

Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default class Server {
88

99
constructor(isRunning: boolean, exception: string, portNumber: number, hostName: string, allowRemoteConnections: boolean, numberOfMessagesToKeep: number, numberOfSessionsToKeep: number, imapPortNumber: number, settingsAreEditable: boolean, disableMessageSanitisation: boolean, automaticRelayExpression: string, tlsMode: string, credentialsValidationExpression: string,
1010
authenticationRequired: boolean,
11-
secureConnectionRequired: boolean, recipientValidationExpression: string, messageValidationExpression: string, disableIPv6: string, users: User[],
11+
secureConnectionRequired: boolean, recipientValidationExpression: string, messageValidationExpression: string, commandValidationExpression: string, disableIPv6: string, users: User[],
1212
relayTlsMode: string | undefined,
1313
relaySmtpServer: string,
1414
relaySmtpPort: number,
@@ -47,6 +47,7 @@ export default class Server {
4747
this.secureConnectionRequired = secureConnectionRequired;
4848
this.recipientValidationExpression = recipientValidationExpression;
4949
this.messageValidationExpression = messageValidationExpression;
50+
this.commandValidationExpression = commandValidationExpression;
5051
this.disableIPv6 = disableIPv6;
5152
this.users = users;
5253
this.relayTlsMode = relayTlsMode;
@@ -88,6 +89,7 @@ export default class Server {
8889
secureConnectionRequired: boolean;
8990
recipientValidationExpression: string;
9091
messageValidationExpression: string;
92+
commandValidationExpression: string;
9193
disableIPv6: string;
9294
users: User[];
9395
relayTlsMode: string | undefined;

Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@
152152
</template>
153153
</el-input>
154154
</el-form-item>
155+
156+
<el-form-item label="Command validation expression (see comments in appsettings.json)" prop="server.commandValidationExpression">
157+
<el-input v-model="server.commandValidationExpression" :disabled="server.lockedSettings.commandValidationExpression">
158+
<template #prefix>
159+
<el-icon v-if="server.lockedSettings.commandValidationExpression" :title="`Locked: ${server.lockedSettings.commandValidationExpression}`"><Lock /></el-icon>
160+
</template>
161+
</el-input>
162+
</el-form-item>
155163
</el-tab-pane>
156164

157165

Rnwood.Smtp4dev/Controllers/ServerController.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public ApiModel.Server GetServer()
102102
CredentialsValidationExpression = serverOptionsCurrentValue.CredentialsValidationExpression,
103103
RecipientValidationExpression = serverOptionsCurrentValue.RecipientValidationExpression,
104104
MessageValidationExpression = serverOptionsCurrentValue.MessageValidationExpression,
105+
CommandValidationExpression = serverOptionsCurrentValue.CommandValidationExpression,
105106
DisableIPv6 = serverOptionsCurrentValue.DisableIPv6,
106107
WebAuthenticationRequired = serverOptionsCurrentValue.WebAuthenticationRequired,
107108
DeliverMessagesToUsersDefaultMailbox = serverOptionsCurrentValue.DeliverMessagesToUsersDefaultMailbox,
@@ -263,6 +264,7 @@ public ActionResult UpdateServer(ApiModel.Server serverUpdate)
263264
newSettings.SecureConnectionRequired = serverUpdate.SecureConnectionRequired != defaultSettingsFile.ServerOptions.SecureConnectionRequired ? serverUpdate.SecureConnectionRequired : null;
264265
newSettings.CredentialsValidationExpression = serverUpdate.CredentialsValidationExpression != defaultSettingsFile.ServerOptions.CredentialsValidationExpression ? serverUpdate.CredentialsValidationExpression : null;
265266
newSettings.RecipientValidationExpression = serverUpdate.RecipientValidationExpression != defaultSettingsFile.ServerOptions.RecipientValidationExpression ? serverUpdate.RecipientValidationExpression : null;
267+
newSettings.CommandValidationExpression = serverUpdate.CommandValidationExpression != defaultSettingsFile.ServerOptions.CommandValidationExpression ? serverUpdate.CommandValidationExpression : null;
266268
newSettings.MessageValidationExpression = serverUpdate.MessageValidationExpression != defaultSettingsFile.ServerOptions.MessageValidationExpression ? serverUpdate.MessageValidationExpression : null;
267269
newSettings.DisableIPv6 = serverUpdate.DisableIPv6 != defaultSettingsFile.ServerOptions.DisableIPv6 ? serverUpdate.DisableIPv6 : null;
268270
newSettings.Users = serverUpdate.Users != defaultSettingsFile.ServerOptions.Users ? serverUpdate.Users : null;

Rnwood.Smtp4dev/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"environmentVariables": {
88
"ASPNETCORE_ENVIRONMENT": "Development",
99
"SERVEROPTIONS__URLS": "http://*:5005"
10-
}
10+
},
1111
"applicationUrl": "http://localhost:5005/"
1212
}
1313
},

Rnwood.Smtp4dev/Server/ScriptingHost.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,27 @@ private void ParseScripts(RelayOptions relayOptionsCurrentValue, Settings.Server
8181
ref recipValidationSource);
8282
ParseScript("MessageValidationExpression", serverOptionsCurrentValue.MessageValidationExpression, ref messageValidationScript,
8383
ref messageValidationSource);
84+
ParseScript("CommandValidationExpression", serverOptionsCurrentValue.CommandValidationExpression, ref commandValidationScript,
85+
ref commandValidationSource);
8486
}
8587

8688
private string shouldRelaySource;
8789
private Script shouldRelayScript;
90+
8891
private string credValidationSource;
8992
private Script credValidationScript;
93+
9094
private string recipValidationSource;
9195
private Script recipValidationScript;
9296

9397
private string messageValidationSource;
9498
private Script messageValidationScript;
9599

100+
private string commandValidationSource;
101+
private Script commandValidationScript;
102+
96103
public bool HasValidateMessageExpression { get => this.messageValidationScript != null; }
104+
public bool HasValidateCommandExpression { get => this.commandValidationScript != null; }
97105

98106
private void AddStandardApi(Engine jsEngine, IConnection connection)
99107
{
@@ -273,6 +281,67 @@ public bool ValidateRecipient(ApiModel.Session session, string recipient, IConne
273281
}
274282
}
275283

284+
internal SmtpResponse ValidateCommand(SmtpCommand command, ApiModel.Session session, IConnection connection)
285+
{
286+
if (commandValidationScript == null)
287+
{
288+
return null;
289+
}
290+
291+
Engine jsEngine = CreateEngineWithStandardApi(connection);
292+
293+
jsEngine.SetValue("command", command);
294+
jsEngine.SetValue("session", session);
295+
296+
try
297+
{
298+
JsValue result = jsEngine.Evaluate(commandValidationScript);
299+
300+
SmtpResponse response;
301+
302+
if (result.IsNull() || result.IsUndefined())
303+
{
304+
response = null;
305+
}
306+
else if (result.IsNumber())
307+
{
308+
response = new SmtpResponse((int)result.AsNumber(), "Command rejected by CommandValidationExpression");
309+
}
310+
else if (result.IsString())
311+
{
312+
response = new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, result.AsString());
313+
}
314+
else
315+
{
316+
response = result.AsBoolean() ? null : new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "Message rejected by CommandValidationExpression");
317+
}
318+
319+
log.Information("CommandValidationExpression: (command: {command}, session: {session.Id}) => {result} => {success}", command,
320+
session.Id, result, response?.Code.ToString() ?? "Success");
321+
322+
return response;
323+
324+
}
325+
catch (ConnectionUnexpectedlyClosedException)
326+
{
327+
throw;
328+
}
329+
catch (SmtpServerException ex)
330+
{
331+
return ex.SmtpResponse;
332+
}
333+
catch (JavaScriptException ex)
334+
{
335+
log.Error("Error executing CommandValidationExpression : {error}", ex.Error);
336+
return new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "CommandValidationExpression failed");
337+
}
338+
catch (Exception ex)
339+
{
340+
log.Error("Error executing CommandValidationExpression : {error}", ex.ToString());
341+
return new SmtpResponse(StandardSmtpResponseCode.TransactionFailed, "CommandValidationExpression failed");
342+
}
343+
}
344+
276345
internal SmtpResponse ValidateMessage(ApiModel.Message message, ApiModel.Session session, IConnection connection)
277346
{
278347
if (messageValidationScript == null)

Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ public record ServerOptions
5555
public string RecipientValidationExpression { get; set; }
5656

5757
public string MessageValidationExpression { get; set; }
58+
59+
public string CommandValidationExpression { get; set; }
5860
public bool DisableIPv6 { get; set; } = false;
5961

6062
public UserOptions[] Users { get; set; } = [];

Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ public record ServerOptionsSource
4747
public bool? SmtpAllowAnyCredentials { get; set; }
4848
public bool? SecureConnectionRequired { get; set; }
4949
public string RecipientValidationExpression { get; set; }
50-
5150
public string MessageValidationExpression { get; set; }
51+
public string CommandValidationExpression { get; set; }
5252
public bool? DisableIPv6 { get; set; }
5353

5454
public UserOptions[] Users { get; set; }

Rnwood.Smtp4dev/Server/Smtp4devServer.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ private void CreateSmtpServer()
109109
((SmtpServer.ServerOptions)this.smtpServer.Options).MessageStartEventHandler += OnMessageStart;
110110

111111
((SmtpServer.ServerOptions)this.smtpServer.Options).MessageRecipientAddingEventHandler += OnMessageRecipientAddingEventHandler;
112+
((SmtpServer.ServerOptions)this.smtpServer.Options).CommandReceivedEventHandler += OnCommandReceived;
113+
}
114+
115+
private Task OnCommandReceived(object sender, CommandEventArgs e)
116+
{
117+
if (!scriptingHost.HasValidateCommandExpression)
118+
{
119+
return Task.CompletedTask;
120+
}
121+
122+
using var scope = serviceScopeFactory.CreateScope();
123+
Smtp4devDbContext dbContext = scope.ServiceProvider.GetService<Smtp4devDbContext>();
124+
Session dbSession = dbContext.Sessions.Find(activeSessionsToDbId[e.Connection.Session]);
125+
126+
var apiSession = new ApiModel.Session(dbSession);
127+
128+
var errorResponse = scriptingHost.ValidateCommand(e.Command, apiSession, e.Connection);
129+
130+
if (errorResponse != null)
131+
{
132+
throw new SmtpServerException(errorResponse);
133+
}
134+
135+
return Task.CompletedTask;
112136
}
113137

114138
private Task OnMessageRecipientAddingEventHandler(object sender, RecipientAddingEventArgs e)

Rnwood.Smtp4dev/appsettings.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
// The return value is a boolean value where true will accept the credentials.
141141
// The variable `credentials` refers to the credentials that were presented. See:
142142
// https://github.com/rnwood/smtpserver/tree/master/Rnwood.SmtpServer/Extensions/Auth
143+
// A standard API is available. See the top of this file.
143144
//
144145
// Examples:
145146
// credentials.Type == 'USERNAME_PASSWORD' && credentials.username == 'rob' && credentials.password == 'pass'
@@ -150,6 +151,7 @@
150151
// The variable 'recipient' refers to the current recipient (one call per recipient)
151152
// The variable `session` refers to the the current session:
152153
// For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Session.cs
154+
// A standard API is available. See the top of this file.
153155
//
154156
// Examples
155157
// recipient == "foo@bar.com"
@@ -166,6 +168,7 @@
166168
// For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Message.cs
167169
// The variable `session` refers to the the current session:
168170
// For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Session.cs
171+
// A standard API is available. See the top of this file.
169172
//
170173
// Examples
171174
// !message.subject.includes("19")
@@ -174,6 +177,24 @@
174177
// - Rejects messages that include 19 with a 441, otherwise accepts
175178
"MessageValidationExpression": "",
176179

180+
//A JavaScript expression used to validate commands and allowing you to change the response.
181+
//The return value is a boolean value where true will accept the command.
182+
//If the return value is a number it will be used as response code
183+
//If the return value is a string, it will be used as an error response message
184+
// The variable 'command' refers to the current command:
185+
// For available properties, see https://github.com/rnwood/smtp4dev/blob/master/smtpserver/Rnwood.SmtpServer/SmtpCommand.cs
186+
// The variable `session` refers to the the current session:
187+
// For available properties see https://github.com/rnwood/smtp4dev/blob/master/Rnwood.Smtp4dev/ApiModel/Session.cs
188+
// A standard API is available. See the top of this file.
189+
//
190+
// Examples
191+
// command.Verb == "HELO" && command.ArgumentsText == "rob"
192+
// - Rejects "HELO rob"
193+
// command.Verb == "HELO" && command.ArgumentsText == "rob" ? error(123, "Rob is not welcome here") : null
194+
// - Rejects "HELO rob" with a custom error
195+
//
196+
"CommandValidationExpression": "",
197+
177198
//True if web interface and API will need BASIC auth.
178199
//See 'Users'
179200
"WebAuthenticationRequired": false,

smtpserver/Rnwood.SmtpServer.Tests/CommandEventArgsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public class CommandEventArgsTests
1818
public void Command()
1919
{
2020
SmtpCommand command = new SmtpCommand("BLAH");
21-
CommandEventArgs args = new CommandEventArgs(command);
21+
CommandEventArgs args = new CommandEventArgs(command, null,null);
2222

2323
Assert.Same(command, args.Command);
2424
}

smtpserver/Rnwood.SmtpServer/CommandEventArgs.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,19 @@ public class CommandEventArgs : EventArgs
1616
/// Initializes a new instance of the <see cref="CommandEventArgs" /> class.
1717
/// </summary>
1818
/// <param name="command">The command<see cref="SmtpCommand" />.</param>
19-
public CommandEventArgs(SmtpCommand command) => Command = command;
19+
public CommandEventArgs(SmtpCommand command, ISession session, IConnection connection)
20+
{
21+
this.Command = command;
22+
this.Session = session;
23+
this.Connection = connection;
24+
}
2025

2126
/// <summary>
2227
/// Gets the Command.
2328
/// </summary>
2429
public SmtpCommand Command { get; private set; }
30+
31+
public ISession Session { get; private set; }
32+
33+
public IConnection Connection { get; private set; }
2534
}

smtpserver/Rnwood.SmtpServer/Connection.cs

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -242,38 +242,45 @@ private async Task ReadAndProcessNextCommand()
242242
{
243243
bool badCommand = false;
244244
SmtpCommand command = new SmtpCommand(await ReadLine().ConfigureAwait(false));
245-
await Server.Options.OnCommandReceived(this, command).ConfigureAwait(false);
246245

247-
if (command.IsValid)
248-
{
249-
badCommand = !await TryProcessCommand(command).ConfigureAwait(false);
250-
}
251-
else if (!command.IsEmpty)
246+
try
252247
{
253-
badCommand = true;
254-
}
248+
await Server.Options.OnCommandReceived(this, command).ConfigureAwait(false);
255249

256-
if (badCommand)
257-
{
258-
await Session.IncrementBadCommandCounter().ConfigureAwait(false);
250+
if (command.IsValid)
251+
{
252+
badCommand = !await TryProcessCommand(command).ConfigureAwait(false);
253+
}
254+
else if (!command.IsEmpty)
255+
{
256+
badCommand = true;
257+
}
259258

260-
if (Server.Options.MaximumNumberOfSequentialBadCommands > 0 &&
261-
Session.NumberOfBadCommandsInARow >= Server.Options.MaximumNumberOfSequentialBadCommands)
259+
if (badCommand)
262260
{
263-
await WriteResponse(new SmtpResponse(StandardSmtpResponseCode.ClosingTransmissionChannel,
264-
"Too many bad commands. Bye!")).ConfigureAwait(false);
265-
await CloseConnection().ConfigureAwait(false);
261+
await Session.IncrementBadCommandCounter().ConfigureAwait(false);
262+
263+
if (Server.Options.MaximumNumberOfSequentialBadCommands > 0 &&
264+
Session.NumberOfBadCommandsInARow >= Server.Options.MaximumNumberOfSequentialBadCommands)
265+
{
266+
await WriteResponse(new SmtpResponse(StandardSmtpResponseCode.ClosingTransmissionChannel,
267+
"Too many bad commands. Bye!")).ConfigureAwait(false);
268+
await CloseConnection().ConfigureAwait(false);
269+
}
270+
else
271+
{
272+
await WriteResponse(new SmtpResponse(
273+
StandardSmtpResponseCode.SyntaxErrorCommandUnrecognised,
274+
"Command unrecognised")).ConfigureAwait(false);
275+
}
266276
}
267277
else
268278
{
269-
await WriteResponse(new SmtpResponse(
270-
StandardSmtpResponseCode.SyntaxErrorCommandUnrecognised,
271-
"Command unrecognised")).ConfigureAwait(false);
279+
await Session.ResetBadCommandCounter().ConfigureAwait(false);
272280
}
273-
}
274-
else
281+
} catch (SmtpServerException e)
275282
{
276-
await Session.ResetBadCommandCounter().ConfigureAwait(false);
283+
await WriteResponse(e.SmtpResponse);
277284
}
278285
}
279286

smtpserver/Rnwood.SmtpServer/ServerOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ public virtual Task<bool> IsAuthMechanismEnabled(IConnection connection, IAuthMe
170170

171171
/// <inheritdoc />
172172
public virtual Task OnCommandReceived(IConnection connection, SmtpCommand command) =>
173-
CommandReceivedEventHandler?.Invoke(this, new CommandEventArgs(command)) ?? Task.CompletedTask;
173+
CommandReceivedEventHandler?.Invoke(this, new CommandEventArgs(command, connection.Session, connection)) ?? Task.CompletedTask;
174174

175175
/// <inheritdoc />
176176
public virtual Task<IMessageBuilder> OnCreateNewMessage(IConnection connection) =>

0 commit comments

Comments
 (0)