Skip to content

Commit 5e2364f

Browse files
authored
🐛 Do not return the SMTP password from the notifications API (#5980)
GET /api/notifications/email returned the stored SMTP password to every caller that can read the settings. This includes the reader role and, with authentication disabled (the default), anonymous callers. The password is now write-only: - GET never returns it. - A save with an empty password keeps the stored password. ServicePulse sends its form back without a password, because it no longer receives one. - A save without an account removes the stored password. Tests: - NotificationsControllerTests (unit) cover the GET response and how each kind of save changes the stored password. - One acceptance test checks the HTTP response end to end.
1 parent 4ed96e8 commit 5e2364f

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
namespace ServiceControl.AcceptanceTests.Monitoring.CustomChecks
2+
{
3+
using System.Threading.Tasks;
4+
using AcceptanceTesting;
5+
using NServiceBus.AcceptanceTesting;
6+
using NUnit.Framework;
7+
using ServiceControl.Notifications;
8+
9+
/// <summary>
10+
/// The SMTP password is write-only: the settings route never returns it. This checks the HTTP
11+
/// response end to end. NotificationsControllerTests covers how a save changes the stored password.
12+
/// </summary>
13+
class When_email_notification_settings_contain_a_password : AcceptanceTest
14+
{
15+
[Test]
16+
public async Task Should_not_return_the_password()
17+
{
18+
EmailNotifications returned = null;
19+
20+
await Define<Context>()
21+
.Do("Save settings with a password", async _ =>
22+
{
23+
await this.Post("/api/notifications/email", SettingsRequest(Account, Password));
24+
25+
return true;
26+
})
27+
.Do("Read the settings back", async _ =>
28+
{
29+
returned = await this.TryGet<EmailNotifications>("/api/notifications/email");
30+
31+
return returned != null;
32+
})
33+
.Done(_ => true)
34+
.Run();
35+
36+
using (Assert.EnterMultipleScope())
37+
{
38+
Assert.That(returned.AuthenticationAccount, Is.EqualTo(Account), "The account is not a secret and stays visible");
39+
Assert.That(returned.AuthenticationPassword, Is.Null, "The password must never leave the instance through the API");
40+
}
41+
}
42+
43+
static object SettingsRequest(string account, string password) => new
44+
{
45+
smtp_server = "localhost",
46+
smtp_port = 25,
47+
authorization_account = account,
48+
authorization_password = password,
49+
enable_tls = false,
50+
from = "servicecontrol@particular.net",
51+
to = "oncall@particular.net"
52+
};
53+
54+
const string Account = "svc-notifications";
55+
const string Password = "p@ssw0rd";
56+
57+
public class Context : ScenarioContext, ISequenceContext
58+
{
59+
public int Step { get; set; }
60+
}
61+
}
62+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#nullable enable
2+
namespace ServiceControl.UnitTests.Notifications;
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using NUnit.Framework;
7+
using ServiceControl.Notifications;
8+
using ServiceControl.Notifications.Api;
9+
using ServiceControl.Persistence;
10+
11+
/// <summary>
12+
/// The SMTP password is write-only: the settings route never returns it, and a save without a
13+
/// password keeps the stored one. ServicePulse reads the settings into its form and sends the form
14+
/// back, so a save without a password must not erase the password that was stored before.
15+
/// </summary>
16+
[TestFixture]
17+
class NotificationsControllerTests
18+
{
19+
[Test]
20+
public async Task Should_not_return_the_password()
21+
{
22+
var store = new InMemoryNotificationsDataStore(Account, Password);
23+
24+
var returned = await Controller(store).GetEmailNotificationsSettings();
25+
26+
using (Assert.EnterMultipleScope())
27+
{
28+
Assert.That(returned.AuthenticationAccount, Is.EqualTo(Account), "The account is not a secret and stays visible");
29+
Assert.That(returned.AuthenticationPassword, Is.Null, "The password must never leave the instance through the API");
30+
Assert.That(store.Stored.AuthenticationPassword, Is.EqualTo(Password), "Reading the settings must not change the stored password");
31+
}
32+
}
33+
34+
[Test]
35+
public async Task Should_keep_the_stored_password_when_saved_without_one()
36+
{
37+
var store = new InMemoryNotificationsDataStore(Account, Password);
38+
39+
await Controller(store).UpdateSettings(Request(Account, password: null));
40+
41+
Assert.That(store.Stored.AuthenticationPassword, Is.EqualTo(Password));
42+
}
43+
44+
[Test]
45+
public async Task Should_replace_the_stored_password_when_saved_with_a_new_one()
46+
{
47+
var store = new InMemoryNotificationsDataStore(Account, Password);
48+
49+
await Controller(store).UpdateSettings(Request(Account, NewPassword));
50+
51+
Assert.That(store.Stored.AuthenticationPassword, Is.EqualTo(NewPassword));
52+
}
53+
54+
[Test]
55+
public async Task Should_remove_the_stored_password_when_saved_without_an_account()
56+
{
57+
var store = new InMemoryNotificationsDataStore(Account, Password);
58+
59+
await Controller(store).UpdateSettings(Request(account: null, password: null));
60+
61+
Assert.That(store.Stored.AuthenticationPassword, Is.Null, "Without an account the password is never used, so it must not stay stored");
62+
}
63+
64+
static NotificationsController Controller(INotificationsDataStore store) => new(store, null!, null!);
65+
66+
static UpdateEmailNotificationsSettingsRequest Request(string? account, string? password) => new()
67+
{
68+
SmtpServer = "localhost",
69+
SmtpPort = 25,
70+
AuthorizationAccount = account,
71+
AuthorizationPassword = password,
72+
EnableTLS = false,
73+
From = "servicecontrol@particular.net",
74+
To = "oncall@particular.net"
75+
};
76+
77+
const string Account = "svc-notifications";
78+
const string Password = "p@ssw0rd";
79+
const string NewPassword = "n3w-p@ssw0rd";
80+
81+
// Loads and saves copies, like the real stores, so the controller cannot change the stored value in place.
82+
class InMemoryNotificationsDataStore(string account, string password) : INotificationsDataStore
83+
{
84+
public EmailNotifications Stored { get; private set; } = new() { AuthenticationAccount = account, AuthenticationPassword = password };
85+
86+
public Task<NotificationsSettings> LoadSettings(CancellationToken cancellationToken = default) =>
87+
Task.FromResult(new NotificationsSettings { Email = Copy(Stored) });
88+
89+
public Task SaveSettings(NotificationsSettings settings, CancellationToken cancellationToken = default)
90+
{
91+
Stored = Copy(settings.Email);
92+
return Task.CompletedTask;
93+
}
94+
95+
static EmailNotifications Copy(EmailNotifications source) => new()
96+
{
97+
Enabled = source.Enabled,
98+
SmtpServer = source.SmtpServer,
99+
SmtpPort = source.SmtpPort,
100+
AuthenticationAccount = source.AuthenticationAccount,
101+
AuthenticationPassword = source.AuthenticationPassword,
102+
EnableTLS = source.EnableTLS,
103+
To = source.To,
104+
From = source.From
105+
};
106+
}
107+
}

‎src/ServiceControl/Notifications/Api/NotificationsController.cs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ public async Task<EmailNotifications> GetEmailNotificationsSettings(Cancellation
2222
{
2323
var notificationsSettings = await store.LoadSettings(cancellationToken);
2424

25+
// The SMTP password is write-only. It is a credential for the customer's mail system, so it
26+
// never leaves the instance, whatever the caller's permissions. LoadSettings returns a detached
27+
// snapshot, so this does not change the stored password.
28+
notificationsSettings.Email.AuthenticationPassword = null;
29+
2530
return notificationsSettings.Email;
2631
}
2732

@@ -52,7 +57,19 @@ public async Task<IActionResult> UpdateSettings(UpdateEmailNotificationsSettings
5257
emailSettings.SmtpPort = request.SmtpPort;
5358

5459
emailSettings.AuthenticationAccount = request.AuthorizationAccount;
55-
emailSettings.AuthenticationPassword = request.AuthorizationPassword;
60+
61+
// The settings route does not return the password, so ServicePulse sends its form back without
62+
// one. An empty password keeps the stored one. Without an account the password is never used,
63+
// so it is removed.
64+
if (string.IsNullOrEmpty(request.AuthorizationAccount))
65+
{
66+
emailSettings.AuthenticationPassword = null;
67+
}
68+
else if (!string.IsNullOrEmpty(request.AuthorizationPassword))
69+
{
70+
emailSettings.AuthenticationPassword = request.AuthorizationPassword;
71+
}
72+
5673
emailSettings.EnableTLS = request.EnableTLS;
5774

5875
emailSettings.From = request.From;

0 commit comments

Comments
 (0)