Harden app security controls from audit

This commit is contained in:
2026-02-08 18:40:13 +01:00
parent a6364b0802
commit 42e60d2a5a
20 changed files with 689 additions and 109 deletions

View File

@@ -1,13 +1,18 @@
using GameList.Data;
using GameList.Endpoints;
using GameList.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Security.Claims;
using System.Globalization;
using System.Threading.RateLimiting;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
@@ -40,11 +45,57 @@ builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>();
builder.Services.AddSingleton<AuthAttemptMonitor>();
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(EndpointHelpers.CreateImageValidationHandler);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, token) =>
{
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordRateLimited(context.HttpContext);
if (context.HttpContext.Response.HasStarted)
return;
context.HttpContext.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too Many Requests",
Detail = "Too many requests. Please try again shortly.",
Extensions = { ["error"] = "Too many requests. Please try again shortly." }
};
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken: token);
};
options.AddPolicy("auth-sensitive", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: BuildAuthRateLimitKey(context),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 6,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("admin-sensitive", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: BuildAdminRateLimitKey(context),
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0,
AutoReplenishment = true
}));
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
@@ -53,9 +104,11 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.ExpireTimeSpan = TimeSpan.FromHours(12);
options.Events = new CookieAuthenticationEvents
{
OnSigningIn = EnsureSessionStartAsync,
OnValidatePrincipal = ValidateSessionLifetimeAsync,
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
};
@@ -66,6 +119,28 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
var app = builder.Build();
app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration));
app.UseRateLimiter();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
app.UseHttpsRedirection();
}
app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
var headers = ctx.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "no-referrer";
headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()";
headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: http:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
return Task.CompletedTask;
});
await next();
});
var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath))
@@ -99,6 +174,52 @@ app.MapAdminEndpoints();
app.Run();
static string BuildAuthRateLimitKey(HttpContext context)
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{ip}";
}
static string BuildAdminRateLimitKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon";
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{userId}|{ip}";
}
const string SessionStartedAtKey = "session_started_at_unix";
const long AbsoluteSessionLifetimeSeconds = 7L * 24 * 60 * 60;
static Task EnsureSessionStartAsync(CookieSigningInContext context)
{
if (!context.Properties.Items.ContainsKey(SessionStartedAtKey))
{
context.Properties.Items[SessionStartedAtKey] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
}
return Task.CompletedTask;
}
static async Task ValidateSessionLifetimeAsync(CookieValidatePrincipalContext context)
{
if (!context.Properties.Items.TryGetValue(SessionStartedAtKey, out var rawStart)
|| !long.TryParse(rawStart, out var unixStart))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return;
}
var startedAt = DateTimeOffset.FromUnixTimeSeconds(unixStart);
if ((DateTimeOffset.UtcNow - startedAt).TotalSeconds <= AbsoluteSessionLifetimeSeconds)
return;
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordSessionExpired(context.HttpContext, startedAt);
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config)
{
var options = new ForwardedHeadersOptions