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