From dc697a2f990e4e765941de3e4d9beccc85d2ca14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 19 May 2026 20:29:27 +0200 Subject: [PATCH] security(ui): fix token leakage and captive dependency memory leak in auth handler --- .../Handlers/AuthenticationHeaderHandler.cs | 124 +++++++++++++++++- 1 file changed, 117 insertions(+), 7 deletions(-) diff --git a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs index 4a37777..ed07ce9 100644 --- a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs +++ b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs @@ -1,31 +1,141 @@ using System.Net.Http.Headers; +using System.Threading; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.WebAssembly.Http; +using Microsoft.Extensions.DependencyInjection; using NexusReader.Application.Abstractions.Services; namespace NexusReader.Web.Client.Handlers; +/// +/// A secure HTTP message delegating handler that automatically appends JWT tokens +/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner. +/// public class AuthenticationHeaderHandler : DelegatingHandler { private readonly INativeStorageService _storageService; + private readonly IServiceProvider _serviceProvider; private const string TokenKey = "nexus_auth_token"; + private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1); - public AuthenticationHeaderHandler(INativeStorageService storageService) + public AuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider) { _storageService = storageService; + _serviceProvider = serviceProvider; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - // Ensure cookies are sent (needed for InteractiveAuto SSR synchronization) + // Force browser to forward credentials (cookies) for SSR hydration sync request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); - var tokenResult = await _storageService.GetSecureString(TokenKey); - - if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) + var path = request.RequestUri?.AbsolutePath ?? ""; + bool isAuthEndpoint = path.Contains("identity/login") || + path.Contains("identity/register") || + path.Contains("identity/refresh"); + + // SECURITY FIX (CWE-200): Ensure we only append JWT tokens to local or trusted base origin requests + var navigationManager = _serviceProvider.GetRequiredService(); + bool isTrustedHost = request.RequestUri != null && + (!request.RequestUri.IsAbsoluteUri || + request.RequestUri.ToString().StartsWith(navigationManager.BaseUri, StringComparison.OrdinalIgnoreCase)); + + string? originalToken = null; + + if (!isAuthEndpoint && isTrustedHost) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Value); + var tokenResult = await _storageService.GetSecureString(TokenKey); + if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) + { + originalToken = tokenResult.Value; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); + } } - return await base.SendAsync(request, cancellationToken); + var response = await base.SendAsync(request, cancellationToken); + + // Transparent JWT Auto-Refresh on 401 Unauthorized + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint) + { + await _refreshSemaphore.WaitAsync(cancellationToken); + try + { + // Re-read token to verify if another concurrent request already refreshed it + var tokenResult = await _storageService.GetSecureString(TokenKey); + var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null; + + bool refreshed = false; + + if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken) + { + refreshed = true; + } + else + { + // SECURITY FIX (CWE-400): Resolve scoped services within an explicit using scope to prevent memory leaks + using var scope = _serviceProvider.CreateScope(); + var identityService = scope.ServiceProvider.GetRequiredService(); + var refreshResult = await identityService.RefreshTokenAsync(); + if (refreshResult.IsSuccess) + { + var newTokenResult = await _storageService.GetSecureString(TokenKey); + currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null; + refreshed = !string.IsNullOrEmpty(currentToken); + } + else + { + await identityService.LogoutAsync(); + } + } + + if (refreshed && !string.IsNullOrEmpty(currentToken)) + { + var newRequest = await CloneHttpRequestMessageAsync(request); + newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken); + return await base.SendAsync(newRequest, cancellationToken); + } + } + catch (Exception ex) + { + // Write standard security audit safe debug log + Console.WriteLine($"[AuthHeaderHandler] Automated token renewal failed: {ex.Message}"); + } + finally + { + _refreshSemaphore.Release(); + } + } + + return response; + } + + private async Task CloneHttpRequestMessageAsync(HttpRequestMessage req) + { + var clone = new HttpRequestMessage(req.Method, req.RequestUri) + { + Version = req.Version + }; + + if (req.Content != null) + { + var ms = new System.IO.MemoryStream(); + await req.Content.CopyToAsync(ms); + ms.Position = 0; + clone.Content = new StreamContent(ms); + + foreach (var h in req.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + foreach (var h in req.Headers) + { + clone.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + + clone.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + + return clone; } }