fix(notifications): EWS sender review follow-ups — https guard, transient-path logging, test hardening

This commit is contained in:
Joseph Doherty
2026-08-10 06:29:37 -04:00
parent 08957cc907
commit 8657fae14f
2 changed files with 63 additions and 8 deletions
@@ -68,6 +68,19 @@ public sealed class EwsSoapMailSender : IEwsMailSender
{
ArgumentNullException.ThrowIfNull(request);
// Defense in depth: a Basic header is a replayable cleartext credential, so it never
// leaves this process over anything but https — not even if a misconfigured
// SmtpConfiguration row supplies an http:// URL. Config defects do not improve on
// retry, so this is permanent. Only the scheme and host appear in the message.
if (!string.Equals(request.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
var schemeError =
$"EWS endpoint '{request.Endpoint.Scheme}://{request.Endpoint.Host}' is not https; " +
"Basic credentials are only ever sent over https.";
_logger.LogError("Permanent EWS failure: {Detail}", schemeError);
throw new EwsPermanentException(schemeError);
}
var envelope = EwsSoapEnvelope.BuildCreateItem(
request.FromAddress, request.BccRecipients, request.Subject, request.Body);
@@ -114,7 +127,14 @@ public sealed class EwsSoapMailSender : IEwsMailSender
}
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{
// Transport failure or our own timeout — availability-shaped, so retry.
// Transport failure or our own timeout — availability-shaped, so retry. Logged with
// the host and exception type only: the exception message is untrusted text and the
// recipient addresses are notification content.
_logger.LogWarning(
"Transient EWS failure contacting {EwsHost} ({ExceptionType}).",
request.Endpoint.Host,
ex.GetType().Name);
throw new EwsTransientException(
Scrub(
$"EWS request to {request.Endpoint.Host} failed: {ex.Message}",
@@ -76,16 +76,34 @@ public class EwsSoapMailSenderTests
public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
{
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
var sender = CreateSender(handler);
var factory = new FakeHttpClientFactory(handler);
var sender = CreateSender(factory);
await sender.SendAsync(CreateRequest());
Assert.Equal(EwsSoapMailSender.HttpClientName, factory.RequestedClientName);
Assert.Equal("Basic", handler.CapturedScheme);
Assert.Equal(ExpectedBase64, handler.CapturedParameter);
Assert.Equal("text/xml", handler.CapturedMediaType);
Assert.Contains(Recipient, handler.CapturedContent);
}
[Fact]
public async Task SendAsync_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials()
{
// A Basic header is a replayable cleartext credential: the guard must fire before the
// request is ever built, so a misconfigured http:// URL cannot put it on the wire.
var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
var sender = CreateSender(handler);
var request = CreateRequest() with { Endpoint = new Uri("http://ews.example.test/EWS/Exchange.asmx") };
var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(request));
Assert.Equal(0, handler.InvocationCount);
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
AssertNoCredentialLeak(ex);
}
[Fact]
public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
{
@@ -189,13 +207,16 @@ public class EwsSoapMailSenderTests
}
/// <summary>
/// Neither the password nor the base64 Basic-auth value may reach an exception message —
/// those messages land in the operational log and in the notification's stored error.
/// Neither the password nor the base64 Basic-auth value may reach an exception — those land
/// in the operational log and in the notification's stored error. The whole
/// <see cref="Exception.ToString"/> is scanned, so an inner transport exception carrying the
/// credential would fail this too.
/// </summary>
private static void AssertNoCredentialLeak(Exception ex)
{
Assert.DoesNotContain(Password, ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(ExpectedBase64, ex.Message, StringComparison.Ordinal);
var rendered = ex.ToString();
Assert.DoesNotContain(Password, rendered, StringComparison.Ordinal);
Assert.DoesNotContain(ExpectedBase64, rendered, StringComparison.Ordinal);
}
private static EwsSendRequest CreateRequest(int timeoutSeconds = 0)
@@ -210,12 +231,22 @@ public class EwsSoapMailSenderTests
timeoutSeconds);
private static EwsSoapMailSender CreateSender(HttpMessageHandler handler)
=> new(new FakeHttpClientFactory(handler), NullLogger<EwsSoapMailSender>.Instance);
=> CreateSender(new FakeHttpClientFactory(handler));
private static EwsSoapMailSender CreateSender(FakeHttpClientFactory factory)
=> new(factory, NullLogger<EwsSoapMailSender>.Instance);
/// <summary>Hands every named client the one stub handler under test.</summary>
private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
/// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
public string? RequestedClientName { get; private set; }
public HttpClient CreateClient(string name)
{
RequestedClientName = name;
return new HttpClient(handler, disposeHandler: false);
}
}
/// <summary>
@@ -237,6 +268,9 @@ public class EwsSoapMailSenderTests
public string CapturedContent { get; private set; } = string.Empty;
/// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary>
public int InvocationCount { get; private set; }
public static StubHandler Responding(HttpStatusCode status, string body)
=> new(_ => Task.FromResult(new HttpResponseMessage(status)
{
@@ -256,6 +290,7 @@ public class EwsSoapMailSenderTests
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
InvocationCount++;
CapturedScheme = request.Headers.Authorization?.Scheme;
CapturedParameter = request.Headers.Authorization?.Parameter;
CapturedMediaType = request.Content?.Headers.ContentType?.MediaType;