fix(notifications): validate Twilio AccountSid format at save — closes the un-escaped URI interpolation door

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:36:19 -04:00
parent 37cf7c1a44
commit 40088a2123
4 changed files with 90 additions and 19 deletions
@@ -1658,7 +1658,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", "MGnew",
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", "MGnew",
"https://new.example.com", "new-secret"),
"Administrator");
@@ -1666,7 +1666,7 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal("ACnew", existing.AccountSid);
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
Assert.Equal("MGnew", existing.MessagingServiceSid);
Assert.Equal("https://new.example.com", existing.ApiBaseUrl);
@@ -1690,7 +1690,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor();
// AuthToken + ApiBaseUrl omitted -> preserve-if-null.
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000"),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
"Administrator");
actor.Tell(envelope);
@@ -1701,7 +1701,7 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("old-secret", existing.AuthToken);
Assert.Equal("https://old.example.com", existing.ApiBaseUrl);
// Provided fields are still updated.
Assert.Equal("ACnew", existing.AccountSid);
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
Assert.Equal("+15551110000", existing.FromNumber);
}
@@ -1726,7 +1726,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", AuthToken: "super-secret-token"),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "super-secret-token"),
"Administrator");
actor.Tell(envelope);
@@ -1774,7 +1774,7 @@ public class ManagementActorTests : TestKit, IDisposable
// cannot reach it.
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000"),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
"Viewer");
actor.Tell(envelope);
@@ -1791,7 +1791,7 @@ public class ManagementActorTests : TestKit, IDisposable
// could rotate a production Twilio Auth Token via the CLI/Management API.
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", AuthToken: "rotate-me"),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "rotate-me"),
"Designer");
actor.Tell(envelope);
@@ -1818,7 +1818,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", "+15551110000", AuthToken: " "),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: " "),
"Administrator");
actor.Tell(envelope);
@@ -1828,7 +1828,7 @@ public class ManagementActorTests : TestKit, IDisposable
// The blank token was ignored; the stored secret survives.
Assert.Equal("old-secret", existing.AuthToken);
// Non-secret fields still updated.
Assert.Equal("ACnew", existing.AccountSid);
Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
}
[Fact]
@@ -1848,7 +1848,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "ACnew", FromNumber: null, MessagingServiceSid: "MGonly"),
new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", FromNumber: null, MessagingServiceSid: "MGonly"),
"Administrator");
actor.Tell(envelope);
@@ -1859,6 +1859,63 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("MGonly", existing.MessagingServiceSid);
}
[Fact]
public void UpdateSmsConfig_MalformedAccountSid_Rejected()
{
// Task 22: AccountSid is interpolated un-escaped into the Twilio request URI,
// so a malformed SID (e.g. a path-traversal / query-injection payload) must be
// rejected at save time, closing the URI-injection door before it can be persisted.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, "../evil?x=", "+15551110000"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Account SID", response.Error);
// The malformed value must never have been persisted.
Assert.Equal("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", existing.AccountSid);
}
[Fact]
public void UpdateSmsConfig_ValidSid_Accepted()
{
// Task 22: a well-formed Twilio SID ('AC' + 32 hex chars) passes the guard.
var notifRepo = Substitute.For<INotificationRepository>();
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
{
Id = 1,
AuthToken = "old-secret",
};
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
_services.AddScoped(_ => notifRepo);
var validSid = "AC" + new string('a', 32);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSmsConfigCommand(1, validSid, "+15551110000"),
"Administrator");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Equal(validSid, existing.AccountSid);
}
[Fact]
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
{