Harden auth, headers, and container per OWASP review
- Add per-account lockout + IP rate limiter on local sign-in (A07) - Emit CSP and security headers on every response (A05) - Run container as non-root `app`, /data 0700 (A05/A02) - Stop reflecting raw token-endpoint body into redirect URL (A09) - Handle missing refresh_token in connect callback without a 500 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+51
-2
@@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Serilog;
|
||||
@@ -210,6 +212,25 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin)));
|
||||
});
|
||||
|
||||
// ── Rate limiting ───────────────────────────────────────────────────────────────
|
||||
// Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress
|
||||
// reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer;
|
||||
// the per-account lockout in UserService is what holds up when XFF is spoofed/rotated,
|
||||
// since forwarded headers are trusted from any source behind the proxy.
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("login", httpContext =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient("oauth");
|
||||
@@ -362,6 +383,33 @@ if (publicBaseUri is not null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Security response headers ───────────────────────────────────────────────────
|
||||
// Defence-in-depth on every response. CSP is tuned to this app: all scripts are external
|
||||
// (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*;
|
||||
// the login page and the Blazor components use inline <style>/style="" so style-src needs
|
||||
// 'unsafe-inline'. img-src allows data: for the base64 client logos and CSV download links.
|
||||
// connect-src 'self' covers the Blazor Server WebSocket. frame-ancestors blocks clickjacking
|
||||
// of the live circuit (X-Frame-Options for legacy agents).
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers["Content-Security-Policy"] =
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'";
|
||||
headers["X-Frame-Options"] = "DENY";
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
await next();
|
||||
});
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
@@ -374,6 +422,7 @@ app.UseStatusCodePagesWithReExecute("/not-found");
|
||||
app.MapStaticAssets();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
||||
@@ -425,7 +474,7 @@ else
|
||||
RedirectUri = returnUrl.ToLocalReturnUrl()
|
||||
};
|
||||
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
||||
});
|
||||
}).RequireRateLimiting("login");
|
||||
}
|
||||
|
||||
// Local password sign-in — available in every environment.
|
||||
@@ -456,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
|
||||
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
return Results.Redirect(safeReturn);
|
||||
});
|
||||
}).RequireRateLimiting("login");
|
||||
|
||||
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user