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:
+35
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user