fix(security): enforce audited account, network and resource boundaries

Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
cesnimda
2026-09-09 18:21:47 +02:00
parent 7d641edc7b
commit 0954876295
116 changed files with 10101 additions and 122 deletions
+35 -18
View File
@@ -69,6 +69,8 @@ builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddOptions<PdfRenderOptions>().BindConfiguration("CvExports").ValidateDataAnnotations().ValidateOnStart();
builder.Services.AddSingleton<PdfRenderAdmission>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
@@ -128,20 +130,19 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
});
// Enable CORS (allowlist by default)
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
if (corsOrigins.Length == 0)
corsOrigins = new[] { builder.Environment.IsProduction() ? externalOrigin.BaseUrl : "http://localhost:3000" };
if (corsOrigins.Any(x => x.Trim() == "*"))
throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");
if (builder.Environment.IsProduction() && corsOrigins.Any(x => x.Trim().TrimEnd('/') != externalOrigin.BaseUrl))
throw new InvalidOperationException("Production Cors:Origins must match the canonical same-origin application URL.");
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowReact", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
if (origins.Length == 0)
{
origins = new[] { "http://localhost:3000" };
}
if (origins.Any(x => x.Trim() == "*"))
throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
policy.WithOrigins(corsOrigins.Select(x => x.Trim().TrimEnd('/')).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
@@ -156,6 +157,7 @@ builder.Services.AddProblemDetails(options =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
@@ -186,11 +188,8 @@ builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
builder.Services.AddHostedService<AccountDeletionHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = false
});
.ConfigurePrimaryHttpMessageHandler(services =>
JobImportHttpTransport.CreateHandler(services.GetRequiredService<IHostAddressResolver>()));
builder.Services.AddHttpClient("maintenance-jobs", client =>
{
@@ -430,6 +429,20 @@ var authLoginPermitLimit = builder.Configuration.GetValue<int?>("RateLimits:Auth
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
var response = context.HttpContext.Response;
response.Headers.CacheControl = "no-store";
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
await response.WriteAsJsonAsync(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too many requests",
Detail = "Please wait before trying again.",
Extensions = { ["code"] = "rate_limit_exceeded", ["traceId"] = context.HttpContext.TraceIdentifier }
}, cancellationToken);
};
options.AddPolicy("auth-login", context =>
RateLimitPartition.GetFixedWindowLimiter(
@@ -455,7 +468,7 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("email-send", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"email-send:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
partitionKey: RateLimitPartitionKeys.Account(context, "email-send"),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
@@ -477,7 +490,7 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("account-data", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"account-data:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
partitionKey: RateLimitPartitionKeys.Account(context, "account-data"),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 2,
@@ -599,7 +612,6 @@ await using (var attachmentScope = app.Services.CreateAsyncScope())
}
app.UseCors("AllowReact");
app.UseRateLimiter();
app.Use(async (ctx, next) =>
{
@@ -641,6 +653,11 @@ app.Use(async (ctx, next) =>
app.UseAuthentication();
app.UseAuthorization();
// Authorization can select an endpoint's explicit auth scheme (local instead of smart's
// Google principal). Partition only after the final, validated resource principal is set.
// AllowAnonymous security endpoints still reach their unchanged IP-scoped policies.
app.UseRateLimiter();
app.UseMiddleware<SessionContextMiddleware>();
app.MapControllers();
// Liveness probe for the container health check. Deliberately cheap and anonymous: it answers "is