Show missing permission in 403 page (#7387)
What changed, and why it matters
This change is purely a user-experience improvement: when a BTCPay Server user is denied access to a page, the 403 error page now tells them which specific permission they are missing. It does not alter who can access what, nor does it fix or introduce any security vulnerability. It simply makes the denial message more informative.
No security action required; this is a normal UX enhancement. Reviewers may optionally verify that the displayed permission string is not attacker-controlled and is sourced only from the PolicyRequirement or a query parameter added by the framework event handler.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit adds a missing-permission display to the 403 error page. It wires the existing PolicyRequirement (carried in HttpContext.Items) into the access-denied redirect as a query parameter, then renders that value in the 403 view. A test was updated to assert the new message text. No authorization policy, permission check, or access-control decision is changed.
Changed components
BTCPayServer/Controllers/UIErrorController.csBTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer/Views/UIError/403.cshtmlBTCPayServer.Tests/RolesTests.csInspect captured patch +47 / −0
diff --git a/BTCPayServer.Tests/RolesTests.cs b/BTCPayServer.Tests/RolesTests.cs
index 2e63eb7..31012ea 100644
--- a/BTCPayServer.Tests/RolesTests.cs
+++ b/BTCPayServer.Tests/RolesTests.cs
@@ -613,6 +613,7 @@ public class RolesTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testO
await s.AssertPageAccess(false, StorePath(storeId, "payment-requests"));
await s.AssertPageAccess(false, StorePath(storeId, "pull-payments"));
await s.AssertPageAccess(false, StorePath(storeId, "payouts"));
+ await Expect(s.Page.Locator("#details")).ToHaveTextAsync("You are missing the btcpay.store.canviewpayouts permission.");
await s.AssertPageAccess(true, WalletImport(storeId, cryptoCode));
await s.AssertPageAccess(false, WalletImportSeed(storeId, cryptoCode));
await AssertWalletSettingsCannotTargetAnotherStore();
diff --git a/BTCPayServer/Controllers/UIErrorController.cs b/BTCPayServer/Controllers/UIErrorController.cs
index 26e44a0..d7730dc 100644
--- a/BTCPayServer/Controllers/UIErrorController.cs
+++ b/BTCPayServer/Controllers/UIErrorController.cs
@@ -1,5 +1,7 @@
using System;
using System.Linq;
+using BTCPayServer.Models;
+using BTCPayServer.Security;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
@@ -8,6 +10,8 @@ namespace BTCPayServer.Controllers
public class UIErrorController : Controller
{
public const string ErrorDetailsKey = "ERROR_DETAILS";
+ public const string MissingPermissionQueryKey = "permission";
+
[Route("/errors/{statusCode:int}")]
public IActionResult Handle(int? statusCode = null)
{
@@ -19,6 +23,8 @@ namespace BTCPayServer.Controllers
if (specialPages.Any(a => a == statusCode.Value))
{
var viewName = statusCode.ToString();
+ if (statusCode.Value == 403)
+ return View(viewName, GetMissingPermission());
return View(viewName);
}
}
@@ -26,5 +32,20 @@ namespace BTCPayServer.Controllers
}
return this.StatusCode(statusCode ?? 500);
}
+
+ private string GetMissingPermission()
+ {
+ string missingPermission = null;
+ if (HttpContext.Items.TryGetValue(PermissionAuthorizationHandler.PolicyRequirementKey, out var requirement) &&
+ requirement is PolicyRequirement policyRequirement)
+ {
+ missingPermission = policyRequirement.Policy;
+ }
+ else if (Request.Query.TryGetValue(MissingPermissionQueryKey, out var permission))
+ {
+ missingPermission = permission.FirstOrDefault();
+ }
+ return missingPermission;
+ }
}
}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index c0796dd..cc4507d 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -914,6 +914,7 @@ namespace BTCPayServer.Hosting
opt.LoginPath = "/login";
opt.AccessDeniedPath = "/errors/403";
opt.LogoutPath = "/logout";
+ ConfigureAccessDeniedRedirect(opt);
});
services.AddAuthentication()
.AddCookie(AuthenticationSchemes.LimitedLogin, options =>
@@ -926,10 +927,28 @@ namespace BTCPayServer.Hosting
options.LoginPath = "/login";
options.AccessDeniedPath = "/errors/403";
options.LogoutPath = "/logout";
+ ConfigureAccessDeniedRedirect(options);
})
.AddAPIKeyAuthentication();
}
+ private static void ConfigureAccessDeniedRedirect(CookieAuthenticationOptions options)
+ {
+ var onRedirectToAccessDenied = options.Events.OnRedirectToAccessDenied;
+ options.Events.OnRedirectToAccessDenied = context =>
+ {
+ if (context.HttpContext.Items.TryGetValue(PermissionAuthorizationHandler.PolicyRequirementKey, out var p) &&
+ p is PolicyRequirement policyRequirement)
+ {
+ context.RedirectUri = QueryHelpers.AddQueryString(
+ context.RedirectUri,
+ UIErrorController.MissingPermissionQueryKey,
+ policyRequirement.Policy);
+ }
+ return onRedirectToAccessDenied(context);
+ };
+ }
+
public static IApplicationBuilder UsePayServer(this IApplicationBuilder app)
{
app.UseMiddleware<SetCultureMiddleware>();
diff --git a/BTCPayServer/Views/UIError/403.cshtml b/BTCPayServer/Views/UIError/403.cshtml
index b5e93cd..4fe3039 100644
--- a/BTCPayServer/Views/UIError/403.cshtml
+++ b/BTCPayServer/Views/UIError/403.cshtml
@@ -1,3 +1,4 @@
+@model string
@{
Layout = "_LayoutError.cshtml";
ViewData["Title"] = "403 - Denied";
@@ -5,6 +6,11 @@
What are you looking at?
<br /><br />
+@if (!string.IsNullOrEmpty(Model))
+{
+ <span id="details">You are missing the <b>@Model</b> permission.</span>
+ <br /><br />
+}
<a href="https://twitter.com/r0ckstardev" target="_blank" rel="noreferrer noopener">
<img src="~/img/errorpages/429_rockstardev.jpg" alt="Vin is angry because you caused 403" title="Move away that cursor" asp-append-version="true" />
</a>
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.