fix(security): audit batch A — validation, rate limiting, sessions (#20)
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 28s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s

This commit was merged in pull request #20.
This commit is contained in:
2026-07-02 03:22:17 +02:00
parent 2056548702
commit c5c33f7023
10 changed files with 500 additions and 2 deletions
@@ -7,6 +7,7 @@ namespace InboxIntel.Api.Controllers;
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: LLM calls are the most expensive path
public class AiController : ApiControllerBase
{
private readonly IAiService _ai;
@@ -5,10 +5,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace InboxIntel.Api.Controllers;
[ApiController]
[EnableRateLimiting("auth")] // AUDIT H-2: throttle login/challenge attempts per IP
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class AuthController : ControllerBase
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: PDF/CSV generation is costly
public class ExportController : ApiControllerBase
{
private readonly IExportService _export;
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: triggers server-side outbound HTTP
public class UnsubscribeController : ApiControllerBase
{
private readonly IUnsubscribeService _unsub;
+75
View File
@@ -1,5 +1,7 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Asp.Versioning;
using FluentValidation.AspNetCore;
using InboxIntel.Api.Auth;
using InboxIntel.Application;
using InboxIntel.Application.Abstractions;
@@ -8,8 +10,10 @@ using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
@@ -57,6 +61,24 @@ builder.Services.AddAuthentication(options =>
options.Cookie.Name = "inboxintel.session";
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
// AUDIT M-1: sliding expiration alone lets a stolen cookie renew forever. Stamp an
// absolute start at sign-in and reject principals older than the configured cap,
// forcing a full re-login. (Pre-existing sessions without the stamp are rejected
// once — a single re-login, then they carry the stamp.)
var absoluteDays = builder.Configuration.GetValue("Auth:AbsoluteSessionDays", 30);
options.Events.OnSigningIn = ctx =>
{
ctx.Properties.SetString("abs-start", DateTimeOffset.UtcNow.ToString("O"));
return Task.CompletedTask;
};
options.Events.OnValidatePrincipal = async ctx =>
{
if (SessionLifetime.IsExpired(ctx.Properties.GetString("abs-start"), DateTimeOffset.UtcNow, TimeSpan.FromDays(absoluteDays)))
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
};
// API-style behaviour: return status codes rather than redirecting to a login page.
options.Events.OnRedirectToLogin = ctx =>
{
@@ -98,6 +120,35 @@ builder.Services.AddApiVersioning(o =>
}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; });
builder.Services.AddControllers();
// AUDIT H-1: the validators in InboxIntel.Application/Validation were registered but never
// executed (FluentValidation 11.x needs explicit auto-validation). This wires them into
// model binding so invalid DTOs 400 at the boundary instead of reaching services.
builder.Services.AddFluentValidationAutoValidation();
// AUDIT H-2: rate limiting. Global per-user (or per-IP when anonymous) window, plus stricter
// named policies for auth and expensive endpoints (export/unsubscribe/AI). Limits are
// config-driven so tests and deployments can tune them.
var rl = builder.Configuration.GetSection("RateLimiting");
int Limit(string key, int def) => rl.GetValue(key, def);
var rlWindow = TimeSpan.FromSeconds(Limit("WindowSeconds", 60));
static string Partition(HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? ctx.User.FindFirstValue("inboxintel:uid") ?? "auth-unknown"
: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon";
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("GlobalPermitLimit", 300), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("auth", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("AuthPermitLimit", 10), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("expensive", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("ExpensivePermitLimit", 20), Window = rlWindow, QueueLimit = 0 }));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// V-06: RFC7807 ProblemDetails so the global exception handler returns a safe,
@@ -115,7 +166,18 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true))
{
// AUDIT M-4: the shipped appsettings no longer carries a guessable default DB
// password. Fail fast with a clear message rather than connecting with weak or
// missing credentials (compose/staging/prod inject the full connection string).
var connStr = app.Configuration.GetConnectionString("Postgres") ?? string.Empty;
var csb = new Npgsql.NpgsqlConnectionStringBuilder(connStr);
if (string.IsNullOrWhiteSpace(csb.Password))
throw new InvalidOperationException(
"ConnectionStrings:Postgres has no password. Set the full connection string via " +
"environment/user-secrets (see README) — a default password is deliberately not shipped.");
await db.Database.MigrateAsync();
}
}
// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and
@@ -169,9 +231,22 @@ app.Use(async (ctx, next) =>
app.UseSerilogRequestLogging();
app.UseCors("frontend");
app.UseAuthentication();
// AUDIT H-2: after authentication so authenticated traffic partitions per-user; anonymous
// traffic partitions per-IP. Endpoint policies ("auth", "expensive") apply via attributes.
app.UseRateLimiter();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
/// <summary>
/// AUDIT M-1: absolute session lifetime check, extracted for unit testing. A session with no
/// issued stamp (pre-dating this feature) is treated as expired — one forced re-login.
/// </summary>
public static class SessionLifetime
{
public static bool IsExpired(string? issuedAtIso, DateTimeOffset now, TimeSpan maxAge)
=> !DateTimeOffset.TryParse(issuedAtIso, out var issued) || now - issued > maxAge;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel"
"Postgres": ""
},
"Database": {
"AutoMigrate": true
@@ -25,7 +25,7 @@ public class SmtpEmailSender : IEmailSender
{
if (!IsEnabled)
{
_logger.LogInformation("SMTP not configured; skipping email \"{Subject}\" to {To}", subject, toAddress);
_logger.LogDebug("SMTP not configured; skipping email \"{Subject}\" to {To}", subject, toAddress);
return;
}
@@ -53,6 +53,10 @@ public class AppDbContext : DbContext, IAppDbContext
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
// entity; without a matching filter EF warns on boot and joins could surface rows whose
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);