feat(email): add ImapProvider (generic IMAP for unsupported providers)
b3 of the multi-provider email roadmap. Adds ImapConnection model + table (reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client), ImapProvider implementing the existing IEmailProvider contract unchanged, and ImapController for credential-based connect (no OAuth — user supplies host/username/password directly, verified by a live connect before storage). Scope, documented inline with ponytail: comments: - INBOX only, no multi-folder support. - Thread grouping approximates the References/In-Reply-To chain root rather than the IMAP THREAD extension, which not every server implements. - External message ids are IMAP UIDs, scoped to the connection's current UIDVALIDITY. Security: ran the security-audit skill against this diff (credential handling + arbitrary-host connect is exactly the class of change the standing security gate exists for). Found and fixed a real SSRF: the connect endpoint let an authenticated user point the server at an arbitrary host:port with no internal-range check, and connect-vs-auth failure was distinguishable to the caller -- together a working oracle to fingerprint internal services (loopback/RFC1918/link-local/cloud metadata) from the server's network position. Fixed with EnsureHostIsExternalAsync (DNS-resolve + reject internal ranges, re-checked on every reconnect to close the DNS-rebinding gap) and a single generic failure message that no longer distinguishes connect vs auth failure. 7 regression tests added. Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj -- stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing) would be a large, security-sensitive protocol implementation nobody asked for, so this is the correct dependency, not a stdlib substitute. 168/168 green (161 existing + 7 new SSRF regression tests; the earlier 14 IMAP feature tests are included in the 161). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_returns_connection_fields_for_connected_account()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnection
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Host = "imap.example.test",
|
||||
Port = 993,
|
||||
UseSsl = true,
|
||||
Username = "user@example.test",
|
||||
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
LastSyncStatus = "ok"
|
||||
});
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("imap.example.test", payload.Host);
|
||||
Assert.Equal(993, payload.Port);
|
||||
Assert.Equal("user@example.test", payload.Username);
|
||||
Assert.Equal("ok", payload.LastSyncStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_reports_not_connected_when_no_connection_exists()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ImapConnection?)null);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.False(payload.Connected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", 993, "user", "pass", "Host is required.")]
|
||||
[InlineData("imap.example.test", 0, "user", "pass", "Valid port is required.")]
|
||||
[InlineData("imap.example.test", 993, "", "pass", "Username is required.")]
|
||||
[InlineData("imap.example.test", 993, "user", "", "Password is required.")]
|
||||
public async Task Connect_rejects_missing_fields(string host, int port, string username, string password, string expectedError)
|
||||
{
|
||||
var imap = new Mock<IImapService>(MockBehavior.Strict);
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest(host, port, true, username, password), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal(expectedError, badRequest.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_returns_bad_request_when_service_rejects_credentials()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("IMAP authentication failed: bad credentials"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user", "wrong"), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Contains("authentication failed", (string)badRequest.Value!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_succeeds_and_returns_username()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnectResult("user@example.test"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user@example.test", "correct"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var username = ok.Value!.GetType().GetProperty("username")!.GetValue(ok.Value) as string;
|
||||
Assert.Equal("user@example.test", username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_calls_service_for_authenticated_user()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Disconnect(CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ImapController CreateController(IImapService imap, string userId)
|
||||
{
|
||||
return new ImapController(imap)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderKey_is_imap()
|
||||
{
|
||||
var provider = new ImapProvider(Mock.Of<IImapService>());
|
||||
Assert.Equal("imap", provider.ProviderKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" });
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(connection);
|
||||
Assert.Equal("imap", connection!.ProviderKey);
|
||||
Assert.Equal("user@example.test", connection.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_returns_null_when_not_connected()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null);
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.Null(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ImapMessageSummary>
|
||||
{
|
||||
new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet")
|
||||
});
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
|
||||
|
||||
var summary = Assert.Single(results);
|
||||
Assert.Equal("42", summary.Id);
|
||||
Assert.Equal("root-msg-id@example.test", summary.ThreadId);
|
||||
Assert.Equal("Interview", summary.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapMessageDetail(
|
||||
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
|
||||
"body text", "<p>body</p>", new List<string>(),
|
||||
new List<ImapMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "cid-1", false) }));
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None);
|
||||
|
||||
Assert.Equal("root-msg-id@example.test", detail.ThreadId);
|
||||
var attachment = Assert.Single(detail.Attachments);
|
||||
Assert.Equal("resume.pdf", attachment.FileName);
|
||||
Assert.Equal("cid-1", attachment.ExternalAttachmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Regression coverage for the SSRF guard in ImapService: an authenticated user's IMAP "connect"
|
||||
// target must not be usable to probe loopback/RFC1918/link-local/cloud-metadata addresses.
|
||||
public sealed class ImapServiceSsrfGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("localhost")]
|
||||
[InlineData("10.0.0.5")]
|
||||
[InlineData("172.16.0.5")]
|
||||
[InlineData("192.168.1.5")]
|
||||
[InlineData("169.254.169.254")] // cloud metadata endpoint
|
||||
public async Task ConnectAsync_rejects_internal_and_metadata_hosts(string host)
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", host, 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
// Message must not leak connect-vs-auth distinction (that's the oracle this guard closes).
|
||||
Assert.DoesNotContain("resolve", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("reachable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", "this-host-does-not-exist.invalid", 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
Assert.Equal("Could not connect to that IMAP server with the given credentials. Check host, port, and password.", ex.Message);
|
||||
}
|
||||
|
||||
private static ImapService CreateService()
|
||||
{
|
||||
var db = TestHostFactory.CreateInMemoryDb();
|
||||
var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
||||
return new ImapService(db, protectionProvider);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user