fix(security): audit remediation batch A — validation, rate limiting, sessions
Implements AUDIT_REPORT.md items H-1, H-2, M-1, M-4, M-6, L-3: - H-1: enable FluentValidation auto-validation — the registered validators (incl. Confirmed-required-for-destructive) now actually execute; invalid DTOs 400 at the boundary instead of reaching services. - H-2: ASP.NET Core rate limiting — global per-user/per-IP fixed window (300/min default) + stricter 'auth' (10/min) and 'expensive' (20/min: export, unsubscribe, AI) policies; config-driven; 429 with no queue. - M-1: absolute session lifetime (30d default) — an issued-at stamp set at sign-in and checked in OnValidatePrincipal, so a stolen cookie can no longer slide-renew forever. Pre-existing sessions re-login once. - M-4: remove the guessable default DB password from appsettings; startup fails fast with a clear message when the connection string has no password (compose/staging inject the real one). - M-6: matching tenant query filter on EmailLabel (via Email navigation) — clears the long-standing EF boot warning and closes the join-row leak window. - L-3: SMTP skip-notice logging downgraded to Debug (recipient address is PII-ish). Tests: 6 new (400-on-invalid x2, 429 auth rate limit via a test auth scheme, session-lifetime x3, EmailLabel cross-user invisibility). Full suite: 48/48 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
// ── Tests proving the Phase-2 audit fixes (see AUDIT_REPORT.md) ────────────────────────────
|
||||
|
||||
/// <summary>Pass-through auth scheme so integration tests can exercise authenticated
|
||||
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
|
||||
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string Scheme = "Test";
|
||||
// Stable across requests so per-user rate-limit partitions accumulate correctly.
|
||||
public static readonly string Uid = Guid.NewGuid().ToString();
|
||||
|
||||
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> o, ILoggerFactory l, UrlEncoder e)
|
||||
: base(o, l, e) { }
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "test-sub"),
|
||||
new Claim("inboxintel:uid", Uid),
|
||||
}, Scheme);
|
||||
return Task.FromResult(AuthenticateResult.Success(
|
||||
new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory with the test auth scheme + tiny rate-limit windows so limits trip fast.</summary>
|
||||
public class AuditTestAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Database:AutoMigrate"] = "false",
|
||||
["GoogleOAuth:ClientId"] = "test-client-id",
|
||||
["GoogleOAuth:ClientSecret"] = "test-client-secret",
|
||||
// H-2: make the auth policy trip on the 3rd request within the window.
|
||||
["RateLimiting:AuthPermitLimit"] = "2",
|
||||
["RateLimiting:WindowSeconds"] = "60",
|
||||
}));
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication(TestAuthHandler.Scheme)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.Scheme, _ => { });
|
||||
services.PostConfigure<AuthenticationOptions>(o =>
|
||||
{
|
||||
o.DefaultAuthenticateScheme = TestAuthHandler.Scheme;
|
||||
o.DefaultChallengeScheme = TestAuthHandler.Scheme;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class AuditFixesTests : IClassFixture<AuditTestAppFactory>
|
||||
{
|
||||
private readonly AuditTestAppFactory _factory;
|
||||
public AuditFixesTests(AuditTestAppFactory factory) => _factory = factory;
|
||||
|
||||
// H-1: FluentValidation auto-validation now rejects invalid DTOs at the boundary with 400
|
||||
// (previously the registered validators never executed).
|
||||
[Fact]
|
||||
public async Task Invalid_search_request_is_rejected_with_400_by_the_validator()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/api/v1/search", new { page = 1, pageSize = 0 });
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_with_from_after_to_is_rejected_with_400()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/api/v1/search",
|
||||
new { page = 1, pageSize = 10, from = "2026-02-01", to = "2026-01-01" });
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
// H-2: the "auth" rate-limit policy returns 429 once the per-window permit is exhausted.
|
||||
[Fact]
|
||||
public async Task Auth_endpoint_rate_limits_with_429_after_the_permit_is_exhausted()
|
||||
{
|
||||
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var s1 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
|
||||
var s2 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
|
||||
var s3 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
|
||||
|
||||
s1.Should().NotBe(HttpStatusCode.TooManyRequests);
|
||||
s2.Should().NotBe(HttpStatusCode.TooManyRequests);
|
||||
s3.Should().Be(HttpStatusCode.TooManyRequests);
|
||||
}
|
||||
}
|
||||
|
||||
// M-1: absolute session lifetime — a session older than the cap (or missing its issued
|
||||
// stamp) is expired regardless of sliding renewal.
|
||||
public class SessionLifetimeTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 07, 02, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly TimeSpan Max = TimeSpan.FromDays(30);
|
||||
|
||||
[Fact]
|
||||
public void Fresh_session_is_not_expired()
|
||||
=> SessionLifetime.IsExpired(Now.AddDays(-1).ToString("O"), Now, Max).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Session_older_than_the_cap_is_expired()
|
||||
=> SessionLifetime.IsExpired(Now.AddDays(-31).ToString("O"), Now, Max).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void Session_without_a_stamp_is_expired()
|
||||
=> SessionLifetime.IsExpired(null, Now, Max).Should().BeTrue();
|
||||
}
|
||||
|
||||
// M-6: EmailLabel now carries a matching tenant query filter (via its Email navigation), so
|
||||
// join rows can never leak across users even without a manual Where.
|
||||
public class EmailLabelFilterTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmailLabels_are_invisible_across_users()
|
||||
{
|
||||
var userA = Guid.NewGuid();
|
||||
var userB = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(nameof(EmailLabels_are_invisible_across_users)).Options;
|
||||
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
var emailA = new Email { UserId = userA, GmailMessageId = "a1" };
|
||||
var emailB = new Email { UserId = userB, GmailMessageId = "b1" };
|
||||
var labelA = new Label { UserId = userA, GmailLabelId = "LA", Name = "A" };
|
||||
var labelB = new Label { UserId = userB, GmailLabelId = "LB", Name = "B" };
|
||||
seed.AddRange(emailA, emailB, labelA, labelB,
|
||||
new EmailLabel { EmailId = emailA.Id, LabelId = labelA.Id },
|
||||
new EmailLabel { EmailId = emailB.Id, LabelId = labelB.Id });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = userA });
|
||||
var visible = await ctx.Set<EmailLabel>().ToListAsync(); // no manual Where — filter must enforce
|
||||
visible.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user