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
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:
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user