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