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:
2026-06-11 14:30:19 +02:00
parent 0adc2d4300
commit c4a1775d7d
6 changed files with 176 additions and 9 deletions
+51 -2
View File
@@ -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) =>
{