From 9b6e761e5f65b01538f5ecbf6fa0cc02ccf016ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:59:25 +0000 Subject: [PATCH 1/6] fix: implement ASP.NET Core 10 error handling best practices - Add GrandExceptionHandler implementing IExceptionHandler (ASP.NET Core 8+ pattern): logs exceptions, returns RFC 7807 ProblemDetails JSON for API (Bearer) requests, returns false for web requests so the configured error page handles the response. Guards with HasStarted before writing. - Register AddExceptionHandler() and AddProblemDetails() in ErrorHandlerStartup.ConfigureServices. - Fix UseGrandExceptionHandler: remove dual UseExceptionHandler registration bug. Single UseExceptionHandler('/errorpage.htm') now invokes the IExceptionHandler chain first, then falls back to re-executing at /errorpage.htm for non-API requests. - Fix UsePageNotFound: replace 302 Redirect with UseStatusCodePagesWithReExecute ('/page-not-found') which preserves the original 404 status code. Add inline middleware to disable status code pages for API (Bearer) and static-resource requests so those callers receive their original response unchanged. - Fix ApiAuthenticationRegistrar: replace catch(Exception ex) { throw new Exception(ex.Message) } with bare throw to preserve the original stack trace; update OnAuthenticationFailed to return application/problem+json via IProblemDetailsService instead of plain text. - Fix BaseController.LogException: use a constant message template instead of exception.Message to avoid structured-logging format issues. Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/ecc051c4-72f1-4d90-8ec9-025f19ab46c3 Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../ApiAuthenticationRegistrar.cs | 39 +++++++--- .../Controllers/BaseController.cs | 2 +- .../ApplicationBuilderExtensions.cs | 73 +++++++------------ .../Infrastructure/GrandExceptionHandler.cs | 71 ++++++++++++++++++ .../Startup/ErrorHandlerStartup.cs | 5 ++ 5 files changed, 132 insertions(+), 58 deletions(-) create mode 100644 src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs diff --git a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs index c7b5215d1..dad83ef60 100644 --- a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs +++ b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -32,10 +33,17 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf OnAuthenticationFailed = async context => { context.NoResult(); - context.Response.StatusCode = 401; - context.Response.ContentType = "text/plain"; - await context.Response.WriteAsync(context.Exception.Message); - }, + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + var problemDetailsService = context.HttpContext.RequestServices.GetService(); + if (problemDetailsService != null) + await problemDetailsService.WriteAsync(new ProblemDetailsContext { + HttpContext = context.HttpContext, + ProblemDetails = new ProblemDetails { + Status = StatusCodes.Status401Unauthorized, + Title = "Authentication failed" + } + }); + }, OnTokenValidated = async context => { try @@ -52,9 +60,9 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf throw new Exception("API is disable"); } } - catch (Exception ex) + catch (Exception) { - throw new Exception(ex.Message); + throw; } } }; @@ -80,10 +88,17 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf OnAuthenticationFailed = async context => { context.NoResult(); - context.Response.StatusCode = 401; - context.Response.ContentType = "text/plain"; - await context.Response.WriteAsync(context.Exception.Message); - }, + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + var problemDetailsService = context.HttpContext.RequestServices.GetService(); + if (problemDetailsService != null) + await problemDetailsService.WriteAsync(new ProblemDetailsContext { + HttpContext = context.HttpContext, + ProblemDetails = new ProblemDetails { + Status = StatusCodes.Status401Unauthorized, + Title = "Authentication failed" + } + }); + }, OnTokenValidated = async context => { try @@ -101,9 +116,9 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf throw new Exception("API is disable"); } } - catch (Exception ex) + catch (Exception) { - throw new Exception(ex.Message); + throw; } } }; diff --git a/src/Web/Grand.Web.Common/Controllers/BaseController.cs b/src/Web/Grand.Web.Common/Controllers/BaseController.cs index 3a15c34f1..a69861e44 100644 --- a/src/Web/Grand.Web.Common/Controllers/BaseController.cs +++ b/src/Web/Grand.Web.Common/Controllers/BaseController.cs @@ -116,7 +116,7 @@ protected void Error(Exception exception, bool persistNextRequest = true, bool l private void LogException(Exception exception) { var logger = HttpContext.RequestServices.GetRequiredService().CreateLogger("BaseController"); - logger.LogError(exception, exception.Message); + logger.LogError(exception, "An error occurred"); } /// diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index f4ded74f3..cc4de30d5 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -1,5 +1,4 @@ -using Grand.Data; -using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Endpoints; using Grand.Infrastructure.Plugins; using Grand.Infrastructure.TypeSearch; @@ -39,64 +38,48 @@ public static void UseGrandExceptionHandler(this WebApplication application) //get detailed exceptions for developing and testing purposes application.UseDeveloperExceptionPage(); else - //or use special exception handler + //use registered IExceptionHandler services first; fall back to /errorpage.htm for non-API requests application.UseExceptionHandler("/errorpage.htm"); - - //log errors - application.UseExceptionHandler(handler => - { - handler.Run(async context => - { - var exception = context.Features.Get()?.Error; - if (exception == null) - return; - - string authHeader = context.Request.Headers["Authorization"]; - var apiRequest = authHeader != null && authHeader.Split(' ')[0] == "Bearer"; - if (apiRequest) - { - await context.Response.WriteAsync(exception.Message); - return; - } - - if (DataSettingsManager.DatabaseIsInstalled()) - { - var logger = context.RequestServices.GetRequiredService() - .CreateLogger("UseExceptionHandler"); - // Log the error - logger.LogError(exception, exception.Message); - } - - }); - }); } /// - /// Adds a special handler that checks for responses with the 404 status code that do not have a body + /// Adds a special handler that checks for responses with the 404 status code that do not have a body. + /// Re-executes the pipeline at /page-not-found (preserving the original 404 status code) while + /// skipping the re-execution for API and static-resource requests. /// /// Builder for configuring an application's request pipeline public static void UsePageNotFound(this WebApplication application) { - application.UseStatusCodePages(async context => + // UseStatusCodePagesWithReExecute sets IStatusCodePagesFeature.Enabled = true and re-executes + // the pipeline at the specified path when a 404 occurs, preserving the original 404 status code. + application.UseStatusCodePagesWithReExecute("/page-not-found"); + + // Disable status code pages for API (Bearer) requests and static resource requests so that + // those callers receive the original response rather than the HTML not-found page. + application.Use(async (context, next) => { - //handle 404 Not Found - if (context.HttpContext.Response.StatusCode == 404) - { - string authHeader = context.HttpContext.Request.Headers[HeaderNames.Authorization]; - var apiRequest = authHeader != null && - authHeader.Split(' ')[0] == JwtBearerDefaults.AuthenticationScheme; + string authHeader = context.Request.Headers[HeaderNames.Authorization]; + var apiRequest = authHeader != null && + authHeader.Split(' ')[0] == JwtBearerDefaults.AuthenticationScheme; + if (apiRequest) + { + var feature = context.Features.Get(); + if (feature != null) + feature.Enabled = false; + } + else + { var contentTypeProvider = new FileExtensionContentTypeProvider(); - var staticResource = contentTypeProvider.TryGetContentType(context.HttpContext.Request.Path, out _); - - if (!apiRequest && !staticResource) + if (contentTypeProvider.TryGetContentType(context.Request.Path, out _)) { - const string location = "/page-not-found"; - context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location); + var feature = context.Features.Get(); + if (feature != null) + feature.Enabled = false; } } - await Task.CompletedTask; + await next(context); }); } diff --git a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs new file mode 100644 index 000000000..ccf669592 --- /dev/null +++ b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs @@ -0,0 +1,71 @@ +using Grand.Data; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Common.Infrastructure; + +/// +/// Handles unhandled exceptions according to ASP.NET Core best practices. +/// For API requests (Bearer token) it writes an RFC 7807 ProblemDetails JSON response. +/// For regular web requests it only logs the error and returns false so the configured +/// error page (or developer exception page) can handle the response. +/// +public class GrandExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GrandExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (httpContext.Response.HasStarted) + return false; + + // Log the exception when the database is available + if (DataSettingsManager.DatabaseIsInstalled()) + _logger.LogError(exception, "An unhandled exception has occurred"); + + // Only write a JSON response for API (Bearer) requests; let the configured + // exception page handle HTML responses so the developer page / error page works. + string authHeader = httpContext.Request.Headers["Authorization"]; + var apiRequest = authHeader != null && authHeader.Split(' ')[0] == "Bearer"; + if (!apiRequest) + return false; + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + var problemDetailsService = httpContext.RequestServices.GetService(); + if (problemDetailsService != null) + { + await problemDetailsService.WriteAsync(new ProblemDetailsContext { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new ProblemDetails { + Status = StatusCodes.Status500InternalServerError, + Title = "An error occurred while processing your request", + Instance = httpContext.Request.Path + } + }); + } + else + { + httpContext.Response.ContentType = "application/problem+json"; + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails { + Status = StatusCodes.Status500InternalServerError, + Title = "An error occurred while processing your request", + Instance = httpContext.Request.Path + }, cancellationToken); + } + + return true; + } +} diff --git a/src/Web/Grand.Web.Common/Startup/ErrorHandlerStartup.cs b/src/Web/Grand.Web.Common/Startup/ErrorHandlerStartup.cs index 061d8b9c5..c2b9bd133 100644 --- a/src/Web/Grand.Web.Common/Startup/ErrorHandlerStartup.cs +++ b/src/Web/Grand.Web.Common/Startup/ErrorHandlerStartup.cs @@ -19,6 +19,11 @@ public class ErrorHandlerStartup : IStartupApplication /// Configuration root of the application public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { + // Register RFC 7807 ProblemDetails support (used by GrandExceptionHandler for API errors) + services.AddProblemDetails(); + + // Register the IExceptionHandler implementation used by UseExceptionHandler() + services.AddExceptionHandler(); } /// From 4684aef2f8d426d2ad005ff6db76168e7620093e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:01:22 +0000 Subject: [PATCH 2/6] fix: use StartsWith for Authorization header scheme check (safer than Split()[0])" Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/ecc051c4-72f1-4d90-8ec9-025f19ab46c3 Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../Infrastructure/ApplicationBuilderExtensions.cs | 4 ++-- .../Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index cc4de30d5..e1e2eb7e6 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -60,7 +60,7 @@ public static void UsePageNotFound(this WebApplication application) { string authHeader = context.Request.Headers[HeaderNames.Authorization]; var apiRequest = authHeader != null && - authHeader.Split(' ')[0] == JwtBearerDefaults.AuthenticationScheme; + authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); if (apiRequest) { @@ -96,7 +96,7 @@ public static void UseBadRequestResult(this WebApplication application) return Task.CompletedTask; string authHeader = context.HttpContext.Request.Headers[HeaderNames.Authorization]; - var apiRequest = authHeader != null && authHeader.Split(' ')[0] == JwtBearerDefaults.AuthenticationScheme; + var apiRequest = authHeader != null && authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); if (apiRequest) return Task.CompletedTask; var logger = context.HttpContext.RequestServices.GetRequiredService() diff --git a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs index ccf669592..cce6907a6 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs @@ -37,7 +37,7 @@ public async ValueTask TryHandleAsync( // Only write a JSON response for API (Bearer) requests; let the configured // exception page handle HTML responses so the developer page / error page works. string authHeader = httpContext.Request.Headers["Authorization"]; - var apiRequest = authHeader != null && authHeader.Split(' ')[0] == "Bearer"; + var apiRequest = authHeader != null && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase); if (!apiRequest) return false; From ad1c5bee7bccaf1258e77c94c5a923088c350520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:26:04 +0000 Subject: [PATCH 3/6] refactor: extract IsApiRequest helper to eliminate duplicate auth-header check Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/1ec7db4a-11fc-49b4-8aa2-85f12990ec16 Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../ApplicationBuilderExtensions.cs | 18 +++++++++--------- .../Infrastructure/GrandExceptionHandler.cs | 4 +--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index e1e2eb7e6..dbdc0caf1 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -25,6 +25,13 @@ namespace Grand.Web.Common.Infrastructure; /// public static class ApplicationBuilderExtensions { + internal static bool IsApiRequest(HttpRequest request) + { + string authHeader = request.Headers[HeaderNames.Authorization]; + return authHeader != null && + authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); + } + /// /// Add exception handling /// @@ -58,11 +65,7 @@ public static void UsePageNotFound(this WebApplication application) // those callers receive the original response rather than the HTML not-found page. application.Use(async (context, next) => { - string authHeader = context.Request.Headers[HeaderNames.Authorization]; - var apiRequest = authHeader != null && - authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); - - if (apiRequest) + if (IsApiRequest(context.Request)) { var feature = context.Features.Get(); if (feature != null) @@ -95,10 +98,7 @@ public static void UseBadRequestResult(this WebApplication application) if (context.HttpContext.Response.StatusCode != StatusCodes.Status400BadRequest) return Task.CompletedTask; - string authHeader = context.HttpContext.Request.Headers[HeaderNames.Authorization]; - var apiRequest = authHeader != null && authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); - - if (apiRequest) return Task.CompletedTask; + if (IsApiRequest(context.HttpContext.Request)) return Task.CompletedTask; var logger = context.HttpContext.RequestServices.GetRequiredService() .CreateLogger("UseBadRequestResult"); logger.LogError("Error 400. Bad request"); diff --git a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs index cce6907a6..c88002734 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs @@ -36,9 +36,7 @@ public async ValueTask TryHandleAsync( // Only write a JSON response for API (Bearer) requests; let the configured // exception page handle HTML responses so the developer page / error page works. - string authHeader = httpContext.Request.Headers["Authorization"]; - var apiRequest = authHeader != null && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase); - if (!apiRequest) + if (!ApplicationBuilderExtensions.IsApiRequest(httpContext.Request)) return false; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; From 2e3490f20aea6e503210e5ca0419b614280c9493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 06:55:03 +0000 Subject: [PATCH 4/6] fix: restrict UseStatusCodePagesWithReExecute to 404-only; static ContentTypeProvider; remove redundant try/catch; add OnAuthenticationFailed fallback Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/24b98e75-69d0-4ef3-a43e-2feb96bb1479 Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../ApiAuthenticationRegistrar.cs | 68 ++++++++++--------- .../ApplicationBuilderExtensions.cs | 32 ++++++--- 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs index dad83ef60..e3c8400a3 100644 --- a/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs +++ b/src/Modules/Grand.Module.Api/Infrastructure/ApiAuthenticationRegistrar.cs @@ -1,4 +1,4 @@ -using Grand.Module.Api.Infrastructure.Extensions; +using Grand.Module.Api.Infrastructure.Extensions; using Grand.Business.Core.Interfaces.Authentication; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication; @@ -36,6 +36,7 @@ public void AddAuthentication(AuthenticationBuilder builder, IConfiguration conf context.Response.StatusCode = StatusCodes.Status401Unauthorized; var problemDetailsService = context.HttpContext.RequestServices.GetService(); if (problemDetailsService != null) + { await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = context.HttpContext, ProblemDetails = new ProblemDetails { @@ -43,26 +44,28 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext { Title = "Authentication failed" } }); + } + else + { + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(new ProblemDetails { + Status = StatusCodes.Status401Unauthorized, + Title = "Authentication failed" + }); + } }, OnTokenValidated = async context => { - try + if (config.Enabled) { - if (config.Enabled) - { - var jwtAuthentication = context.HttpContext.RequestServices - .GetRequiredService(); - if (!await jwtAuthentication.Valid(context)) - throw new Exception(await jwtAuthentication.ErrorMessage()); - } - else - { - throw new Exception("API is disable"); - } + var jwtAuthentication = context.HttpContext.RequestServices + .GetRequiredService(); + if (!await jwtAuthentication.Valid(context)) + throw new Exception(await jwtAuthentication.ErrorMessage()); } - catch (Exception) + else { - throw; + throw new Exception("API is disabled"); } } }; @@ -91,6 +94,7 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext { context.Response.StatusCode = StatusCodes.Status401Unauthorized; var problemDetailsService = context.HttpContext.RequestServices.GetService(); if (problemDetailsService != null) + { await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = context.HttpContext, ProblemDetails = new ProblemDetails { @@ -98,27 +102,29 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext { Title = "Authentication failed" } }); + } + else + { + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(new ProblemDetails { + Status = StatusCodes.Status401Unauthorized, + Title = "Authentication failed" + }); + } }, OnTokenValidated = async context => { - try + if (config.Enabled) { - if (config.Enabled) - { - var jwtAuthentication = context.HttpContext.RequestServices - .GetRequiredService(); - var isValid = await jwtAuthentication.Valid(context); - if (!isValid) - throw new Exception(await jwtAuthentication.ErrorMessage()); - } - else - { - throw new Exception("API is disable"); - } + var jwtAuthentication = context.HttpContext.RequestServices + .GetRequiredService(); + var isValid = await jwtAuthentication.Valid(context); + if (!isValid) + throw new Exception(await jwtAuthentication.ErrorMessage()); } - catch (Exception) + else { - throw; + throw new Exception("API is disabled"); } } }; @@ -126,4 +132,4 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext { } public int Priority => 900; -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index dbdc0caf1..a1313985a 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -25,6 +25,9 @@ namespace Grand.Web.Common.Infrastructure; /// public static class ApplicationBuilderExtensions { + // Reused across requests — FileExtensionContentTypeProvider is stateless and thread-safe + private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); + internal static bool IsApiRequest(HttpRequest request) { string authHeader = request.Headers[HeaderNames.Authorization]; @@ -32,6 +35,11 @@ internal static bool IsApiRequest(HttpRequest request) authHeader.StartsWith(JwtBearerDefaults.AuthenticationScheme + " ", StringComparison.OrdinalIgnoreCase); } + private static bool IsStaticFileRequest(PathString path) + { + return ContentTypeProvider.TryGetContentType(path, out _); + } + /// /// Add exception handling /// @@ -63,26 +71,28 @@ public static void UsePageNotFound(this WebApplication application) // Disable status code pages for API (Bearer) requests and static resource requests so that // those callers receive the original response rather than the HTML not-found page. + // For all other requests, also restrict re-execution to actual 404 responses so that + // 400/401/403/405/500 etc. are not mistakenly routed to /page-not-found. application.Use(async (context, next) => { - if (IsApiRequest(context.Request)) + if (IsApiRequest(context.Request) || IsStaticFileRequest(context.Request.Path)) { var feature = context.Features.Get(); if (feature != null) feature.Enabled = false; - } - else - { - var contentTypeProvider = new FileExtensionContentTypeProvider(); - if (contentTypeProvider.TryGetContentType(context.Request.Path, out _)) - { - var feature = context.Features.Get(); - if (feature != null) - feature.Enabled = false; - } + await next(context); + return; } await next(context); + + // Only re-execute for 404 Not Found; all other error codes are handled elsewhere. + if (context.Response.StatusCode != StatusCodes.Status404NotFound) + { + var feature = context.Features.Get(); + if (feature != null) + feature.Enabled = false; + } }); } From 6b563463afee69f9f952b4bb636cea77fececbe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 17:22:56 +0000 Subject: [PATCH 5/6] fix: GrandExceptionHandler now handles web requests with redirect; remove path fallback from UseExceptionHandler Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/5190d235-2337-4c71-add0-49709d3379b1 Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com> --- .../Infrastructure/ApplicationBuilderExtensions.cs | 4 ++-- .../Infrastructure/GrandExceptionHandler.cs | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index a1313985a..e06e3bbf1 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -53,8 +53,8 @@ public static void UseGrandExceptionHandler(this WebApplication application) //get detailed exceptions for developing and testing purposes application.UseDeveloperExceptionPage(); else - //use registered IExceptionHandler services first; fall back to /errorpage.htm for non-API requests - application.UseExceptionHandler("/errorpage.htm"); + //use registered IExceptionHandler services (GrandExceptionHandler handles both API and web requests) + application.UseExceptionHandler(); } /// diff --git a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs index c88002734..82a043ad5 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/GrandExceptionHandler.cs @@ -10,8 +10,7 @@ namespace Grand.Web.Common.Infrastructure; /// /// Handles unhandled exceptions according to ASP.NET Core best practices. /// For API requests (Bearer token) it writes an RFC 7807 ProblemDetails JSON response. -/// For regular web requests it only logs the error and returns false so the configured -/// error page (or developer exception page) can handle the response. +/// For regular web (Razor/MVC) requests it redirects to the static error page (/errorpage.htm). /// public class GrandExceptionHandler : IExceptionHandler { @@ -34,10 +33,14 @@ public async ValueTask TryHandleAsync( if (DataSettingsManager.DatabaseIsInstalled()) _logger.LogError(exception, "An unhandled exception has occurred"); - // Only write a JSON response for API (Bearer) requests; let the configured - // exception page handle HTML responses so the developer page / error page works. if (!ApplicationBuilderExtensions.IsApiRequest(httpContext.Request)) - return false; + { + // For Razor/MVC web requests, redirect to the static error page so the + // browser always sees a user-friendly page (the path-based re-execution + // fallback in UseExceptionHandler is unreliable in an MVC pipeline). + httpContext.Response.Redirect("/errorpage.htm", permanent: false); + return true; + } httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; From 999033f8b8b99e71e0045e0a4e39a8461969febc Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sat, 2 May 2026 19:52:46 +0200 Subject: [PATCH 6/6] Disable full error stack display in production Changed "DisplayFullErrorStack" in appsettings.json from true to false to prevent full error stack traces from being shown in production environments. This enhances security and user experience by limiting error details exposed to end users. --- src/Web/Grand.Web/App_Data/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 7ca546b1b..4c2d343e3 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -1,7 +1,7 @@ { "Application": { //Enable if you want to see the full error in production environment. It's ignored (always enabled) in development environment - "DisplayFullErrorStack": true, + "DisplayFullErrorStack": false, //Value of "Cache-Control" header value for static content "StaticFilesCacheControl": "public,max-age=31536000", //Enable the session-based TempData provider