fix(security): keep imported mail subjects out of the admin audit

This commit is contained in:
cesnimda
2026-11-09 22:52:07 +01:00
parent de3d5031c8
commit d4c486681a
5 changed files with 112 additions and 2 deletions
@@ -159,6 +159,82 @@ public sealed class AdminProcessingControllerTests
Assert.Equal(expected, (await client.GetAsync(route)).StatusCode);
}
[Fact]
public async Task Audit_log_keeps_mail_subjects_in_the_owner_record()
{
await using var host = await Host.Start();
const string privateSubject = "Synthetic private correspondence subject";
const string operationalNote = "Synthetic workflow status change";
await using (var scope = host.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
db.JobEvents.AddRange(
new JobEvent { JobApplicationId = 2, Type = "ReplyReceived", NewValue = "True:2026-11-03", Note = privateSubject, At = DateTime.UtcNow },
new JobEvent { JobApplicationId = 2, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", Note = operationalNote, At = DateTime.UtcNow });
await db.SaveChangesAsync();
}
using var anonymous = host.Client(null);
using var ordinary = host.Client("owner");
foreach (var (client, status) in new[] { (anonymous, HttpStatusCode.Unauthorized), (ordinary, HttpStatusCode.Forbidden) })
{
using var response = await client.GetAsync("/api/admin/audit");
Assert.Equal(status, response.StatusCode);
Assert.DoesNotContain(privateSubject, await response.Content.ReadAsStringAsync());
}
using var admin = host.Client("admin");
var audit = await admin.GetFromJsonAsync<JsonElement>("/api/admin/audit");
var events = audit.EnumerateArray().ToDictionary(item => item.GetProperty("type").GetString()!);
Assert.DoesNotContain(privateSubject, audit.GetRawText());
var reply = events["ReplyReceived"];
Assert.Equal(JsonValueKind.Null, reply.GetProperty("note").ValueKind);
Assert.Equal("True:2026-11-03", reply.GetProperty("newValue").GetString());
Assert.Equal(2, reply.GetProperty("jobApplicationId").GetInt32());
Assert.Equal(operationalNote, events["StatusChanged"].GetProperty("note").GetString());
await using var verifyScope = host.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
Assert.Equal(privateSubject, await verifyDb.JobEvents.IgnoreQueryFilters().Where(item => item.Type == "ReplyReceived").Select(item => item.Note).SingleAsync());
}
[Fact]
public async Task User_polling_does_not_expose_another_owners_notifications_or_processing_records()
{
await using var host = await Host.Start();
var foreignNoticeId = Guid.NewGuid();
const string foreignNotice = "Synthetic notice for the other owner";
await using (var scope = host.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
db.UserNotifications.AddRange(
new UserNotification { Id = Guid.NewGuid(), OwnerUserId = "owner", Kind = "operation_failed", Title = "Operation failed", Message = "Synthetic owner notice", CreatedAtUtc = DateTime.UtcNow },
new UserNotification { Id = foreignNoticeId, OwnerUserId = "other", Kind = "operation_failed", Title = "Operation failed", Message = foreignNotice, CreatedAtUtc = DateTime.UtcNow });
await db.SaveChangesAsync();
}
using var owner = host.Client("owner");
var notices = await owner.GetFromJsonAsync<JsonElement>("/api/notifications?owner=other");
Assert.Equal("Synthetic owner notice", Assert.Single(notices.EnumerateArray()).GetProperty("message").GetString());
var unread = await owner.GetFromJsonAsync<JsonElement>("/api/notifications/unread-count?owner=other");
Assert.Equal(1, unread.GetProperty("count").GetInt32());
Assert.Equal(HttpStatusCode.NotFound, (await owner.PostAsync($"/api/notifications/{foreignNoticeId}/read", null)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await owner.DeleteAsync($"/api/notifications/{foreignNoticeId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/operations/{host.ForeignSubjectOperationId}")).StatusCode);
var ownOperation = await owner.GetStringAsync($"/api/operations/{host.OperationId}");
foreach (var internalValue in new[] { "SECRET_TOKEN", "SECRET_EXCEPTION", "SECRET_RESULT", "SECRET_CV_CONTENT" })
Assert.DoesNotContain(internalValue, ownOperation);
using var other = host.Client("other");
notices = await other.GetFromJsonAsync<JsonElement>("/api/notifications");
Assert.Equal(foreignNotice, Assert.Single(notices.EnumerateArray()).GetProperty("message").GetString());
await using var verifyScope = host.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var notice = await verifyDb.UserNotifications.IgnoreQueryFilters().SingleAsync(item => item.Id == foreignNoticeId);
Assert.Null(notice.ReadAtUtc);
Assert.Null(notice.DismissedAtUtc);
}
[Fact]
public async Task Admin_projection_filters_owners_and_never_returns_raw_processing_contents_or_lease_data()
{
@@ -216,7 +292,9 @@ public sealed class AdminProcessingControllerTests
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
builder.Services.AddAuthentication("test")
.AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { })
.AddScheme<AuthenticationSchemeOptions, TestAuthentication>("local", _ => { });
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
builder.Services.AddRateLimiter(options =>
{
@@ -226,6 +304,8 @@ public sealed class AdminProcessingControllerTests
_ => new FixedWindowRateLimiterOptions { PermitLimit = permits, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<UserOperationStore>();
builder.Services.AddScoped<UserNotificationStore>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
var app = builder.Build();