Skip to content

Commit 516f75a

Browse files
committed
Add middleware to clear stale cookies after key rotation
Introduced StaleCookieMiddleware to detect and remove invalid encrypted cookies when Data Protection keys change, preventing cryptographic errors and login issues. Added configurable StaleCookieOptions and extension methods for easy integration. Improved GlobalExceptionHandler for better error mapping and logging. Enhances application resilience and user experience.
1 parent ddaa9dd commit 516f75a

File tree

4 files changed

+251
-25
lines changed

4 files changed

+251
-25
lines changed

src/Server.UI/DependencyInjection.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Toolbelt.Blazor.Extensions.DependencyInjection;
1818
using CleanArchitecture.Blazor.Server.UI.Middlewares;
1919
using CleanArchitecture.Blazor.Application;
20+
using CleanArchitecture.Blazor.Server.UI.Extensions;
2021

2122

2223
namespace CleanArchitecture.Blazor.Server.UI;
@@ -133,13 +134,14 @@ public static WebApplication ConfigureServer(this WebApplication app, IConfigura
133134
}
134135
else
135136
{
136-
app.UseExceptionHandler("/Error", true);
137137
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
138138
app.UseHsts();
139139
}
140140
app.InitializeCacheFactory();
141+
app.UseExceptionHandler();
141142
app.UseStatusCodePagesWithReExecute("/not-found",createScopeForStatusCodePages: true);
142143
app.MapHealthChecks("/health");
144+
app.UseDataProtectionKeyCheck();
143145
app.UseAuthentication();
144146
app.UseAuthorization();
145147
app.UseAntiforgery();
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using CleanArchitecture.Blazor.Server.UI.Middlewares;
2+
3+
namespace CleanArchitecture.Blazor.Server.UI.Extensions;
4+
5+
public static class StaleCookieMiddlewareExtensions
6+
{
7+
public static IApplicationBuilder UseDataProtectionKeyCheck(
8+
this IApplicationBuilder builder)
9+
{
10+
return builder.UseMiddleware<StaleCookieMiddleware>();
11+
}
12+
13+
public static IServiceCollection AddDataProtectionKeyCheck(
14+
this IServiceCollection services, Action<StaleCookieOptions>? configureOptions = null)
15+
{
16+
if (configureOptions != null)
17+
{
18+
services.Configure(configureOptions);
19+
}
20+
else
21+
{
22+
services.Configure<StaleCookieOptions>(_ => { });
23+
}
24+
return services;
25+
}
26+
}
Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
using System.Diagnostics;
2-
using CleanArchitecture.Blazor.Application.Common.ExceptionHandlers;
2+
using System.Security;
33
using Microsoft.AspNetCore.Diagnostics;
44

55
namespace CleanArchitecture.Blazor.Server.UI.Middlewares;
@@ -12,40 +12,49 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
1212
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
1313

1414
logger.LogError(exception,
15-
$"Could not process a request on machine {Environment.MachineName}. TraceId: {traceId}");
15+
"Could not process a request on machine {MachineName}. TraceId: {TraceId}",
16+
Environment.MachineName, traceId);
1617

17-
await GenerateProblemDetails(httpContext, traceId, exception).ConfigureAwait(false);
18+
var rootException = GetRootException(exception);
19+
var (statusCode, title) = MapExceptionToStatusCode(rootException);
1820

19-
return true;
20-
}
21+
httpContext.Response.StatusCode = statusCode;
2122

22-
private static async Task GenerateProblemDetails(HttpContext httpContext,
23-
string traceId,
24-
Exception exception)
25-
{
26-
var (statusCode, title) = MapExceptionWithStatusCode(exception);
27-
28-
await Results.Problem(title: title,
23+
await Results.Problem(
24+
title: title,
2925
statusCode: statusCode,
3026
extensions: new Dictionary<string, object?>
3127
{
32-
{
33-
"traceId", traceId
34-
}
28+
{ "traceId", traceId }
3529
}).ExecuteAsync(httpContext).ConfigureAwait(false);
30+
31+
return true;
32+
}
33+
34+
private static Exception GetRootException(Exception exception)
35+
{
36+
while (exception.InnerException is not null)
37+
{
38+
exception = exception.InnerException;
39+
}
40+
return exception;
3641
}
3742

38-
private static (int statusCode, string title) MapExceptionWithStatusCode(Exception exception)
43+
private static (int statusCode, string title) MapExceptionToStatusCode(Exception exception)
3944
{
40-
if (exception is not ServerException && exception.InnerException != null)
41-
while (exception.InnerException != null)
42-
exception = exception.InnerException;
4345
return exception switch
4446
{
45-
ArgumentOutOfRangeException => (StatusCodes.Status400BadRequest, exception.Message),
46-
ServerException => (StatusCodes.Status500InternalServerError, exception.Message),
47-
KeyNotFoundException => (StatusCodes.Status404NotFound, exception.Message),
48-
_ => (StatusCodes.Status500InternalServerError, "We are sorry for the inconvenience but we are on it.")
47+
ArgumentOutOfRangeException or ArgumentNullException or ArgumentException
48+
=> (StatusCodes.Status400BadRequest, "Bad request."),
49+
KeyNotFoundException or FileNotFoundException
50+
=> (StatusCodes.Status404NotFound, "Resource not found."),
51+
UnauthorizedAccessException or SecurityException
52+
=> (StatusCodes.Status403Forbidden, "Access denied."),
53+
TimeoutException or TaskCanceledException
54+
=> (StatusCodes.Status504GatewayTimeout, "The request timed out."),
55+
NotImplementedException
56+
=> (StatusCodes.Status501NotImplemented, "This feature is not implemented."),
57+
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred. Please try again later.")
4958
};
5059
}
51-
}
60+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
using System.Security.Cryptography;
2+
using Microsoft.AspNetCore.DataProtection;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace CleanArchitecture.Blazor.Server.UI.Middlewares;
6+
7+
public class StaleCookieMiddleware
8+
{
9+
private readonly RequestDelegate _next;
10+
private readonly IDataProtector _protector;
11+
private readonly ILogger<StaleCookieMiddleware> _logger;
12+
private readonly StaleCookieOptions _options;
13+
14+
private const string CanaryPayload = "ok";
15+
16+
public StaleCookieMiddleware(
17+
RequestDelegate next,
18+
IDataProtectionProvider dataProtection,
19+
ILogger<StaleCookieMiddleware> logger,
20+
IOptions<StaleCookieOptions> options) // Inject configuration
21+
{
22+
_next = next;
23+
_protector = dataProtection.CreateProtector("StaleCookieMiddleware.Canary");
24+
_logger = logger;
25+
_options = options.Value;
26+
}
27+
28+
public async Task InvokeAsync(HttpContext context)
29+
{
30+
// Optimization: Skip decryption overhead for static files or other requests that don't need to be checked.
31+
// Note: This depends on your routing configuration. Sometimes static files don't go through Endpoint routing, this is just an example.
32+
if (_options.IgnoreStaticFiles && IsStaticFile(context))
33+
{
34+
await _next(context);
35+
return;
36+
}
37+
38+
var cookieName = _options.CanaryCookieName;
39+
var hasCanary = context.Request.Cookies.TryGetValue(cookieName, out var canaryValue);
40+
41+
bool keysAreStale = false;
42+
bool shouldSetCanary = true; // By default, needs to be set/refreshed
43+
44+
if (hasCanary && !string.IsNullOrEmpty(canaryValue))
45+
{
46+
try
47+
{
48+
var result = _protector.Unprotect(canaryValue);
49+
if (result == CanaryPayload)
50+
{
51+
// Verification successful: keys are not expired
52+
keysAreStale = false;
53+
// Optimization: If the cookie is valid, no need to resend Set-Cookie on every response, reduce header size.
54+
// Unless you want to implement Sliding Expiration, set this to false.
55+
shouldSetCanary = false;
56+
}
57+
else
58+
{
59+
keysAreStale = true;
60+
}
61+
}
62+
catch (CryptographicException)
63+
{
64+
// Decryption failed, indicating the key has changed
65+
keysAreStale = true;
66+
}
67+
}
68+
69+
if (keysAreStale)
70+
{
71+
HandleStaleCookies(context);
72+
// Since the old one is invalid, we need to set a new one in the response
73+
shouldSetCanary = true;
74+
}
75+
76+
// Continue executing the pipeline
77+
await _next(context);
78+
79+
// Response phase: Write new canary cookie
80+
if (shouldSetCanary && !context.Response.HasStarted && context.Response.StatusCode < 400)
81+
{
82+
SetCanaryCookie(context);
83+
}
84+
}
85+
86+
private void HandleStaleCookies(HttpContext context)
87+
{
88+
var canaryName = _options.CanaryCookieName;
89+
var staleCookies = context.Request.Cookies.Keys
90+
.Where(k => IsEncryptedCookie(k) && !string.Equals(k, canaryName, StringComparison.OrdinalIgnoreCase))
91+
.ToList();
92+
93+
if (staleCookies.Count > 0)
94+
{
95+
_logger.LogWarning(
96+
"Detected Key Rotation. Clearing {Count} stale cookie(s): {Cookies}",
97+
staleCookies.Count,
98+
string.Join(", ", staleCookies));
99+
100+
var staleSet = new HashSet<string>(staleCookies, StringComparer.OrdinalIgnoreCase);
101+
102+
// 1. Notify the browser to delete
103+
foreach (var cookie in staleCookies)
104+
{
105+
context.Response.Cookies.Delete(cookie);
106+
}
107+
108+
// 2. Most importantly: Remove from current request to prevent errors in subsequent middleware
109+
// Rebuild the Cookie Header
110+
var freshCookies = context.Request.Cookies
111+
.Where(c => !staleSet.Contains(c.Key))
112+
.Select(c => $"{Uri.EscapeDataString(c.Key)}={Uri.EscapeDataString(c.Value)}");
113+
114+
context.Request.Headers.Cookie = string.Join("; ", freshCookies);
115+
}
116+
}
117+
118+
private void SetCanaryCookie(HttpContext context)
119+
{
120+
try
121+
{
122+
var options = new CookieOptions
123+
{
124+
HttpOnly = true,
125+
Secure = true, // Recommendation: Force Secure in production environment
126+
SameSite = SameSiteMode.Lax,
127+
IsEssential = true,
128+
MaxAge = TimeSpan.FromDays(365) // Long-term validity
129+
};
130+
131+
// Protect the payload
132+
var protectedPayload = _protector.Protect(CanaryPayload);
133+
context.Response.Cookies.Append(_options.CanaryCookieName, protectedPayload, options);
134+
}
135+
catch (Exception ex)
136+
{
137+
_logger.LogWarning(ex, "Failed to set canary cookie.");
138+
}
139+
}
140+
141+
private bool IsEncryptedCookie(string name)
142+
{
143+
// Use prefixes from configuration, no longer hardcoded
144+
return _options.EncryptedCookiePrefixes.Any(prefix =>
145+
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
146+
}
147+
148+
// Simple static file judgment logic (optional)
149+
private bool IsStaticFile(HttpContext context)
150+
{
151+
// Better approach: Check Endpoint Metadata or simple extension check
152+
var path = context.Request.Path.Value;
153+
if (string.IsNullOrEmpty(path)) return false;
154+
155+
// Simple example
156+
return path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ||
157+
path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ||
158+
path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
159+
path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
160+
path.EndsWith(".ico", StringComparison.OrdinalIgnoreCase) ||
161+
path.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) ||
162+
path.EndsWith(".woff", StringComparison.OrdinalIgnoreCase) ||
163+
path.EndsWith(".woff2", StringComparison.OrdinalIgnoreCase) ||
164+
path.EndsWith(".wasm", StringComparison.OrdinalIgnoreCase);
165+
}
166+
}
167+
public class StaleCookieOptions
168+
{
169+
/// <summary>
170+
/// List of encrypted cookie prefixes to monitor and remove.
171+
/// </summary>
172+
public List<string> EncryptedCookiePrefixes { get; set; } = new()
173+
{
174+
".AspNetCore.Antiforgery",
175+
".AspNetCore.Identity",
176+
".AspNetCore.Cookies",
177+
".AspNetCore.Session"
178+
};
179+
180+
/// <summary>
181+
/// Name of the canary cookie
182+
/// </summary>
183+
public string CanaryCookieName { get; set; } = ".AspNetCore.DPCheck";
184+
185+
/// <summary>
186+
/// Whether to ignore checks on static files (determined by Endpoint)
187+
/// </summary>
188+
public bool IgnoreStaticFiles { get; set; } = true;
189+
}

0 commit comments

Comments
 (0)