fix(security): audit remediation batch A — validation, rate limiting, sessions
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s

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:
cesnimda
2026-07-02 03:16:45 +02:00
parent 2056548702
commit 9480033373
10 changed files with 500 additions and 2 deletions
@@ -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);
}
}