PoS: Unpermissioned store users can browse login links and invoices from Update PoS page (#7305)
What changed, and why it matters
This update fixes a permissions issue in BTCPay Server's Point of Sale (PoS) settings page. Previously, store users who only had permission to view settings—but not modify them—could still load the 'Update Point of Sale' page and see sensitive things like invoice lists and login links. The fix makes the page read-only for those users by disabling or hiding edit controls unless the user has modify-permissions. The same permission helper was also extended to cover more HTML elements (buttons, inputs, divs), and a small navigation markup cleanup was done for Crowdfund and PoS menus.
Review whether any other plugin views rely solely on a form-level `permissioned` wrapper and may still leak information to view-only users. Verify that server-side action methods for UpdatePointOfSale also enforce `CanModifyStoreSettings` independently of UI controls, since disabled buttons can be bypassed. Consider adding automated tests for view-only access to sensitive app pages.
Security signals we found
Authorization bypass / information disclosure: unpermissioned store users could access the Update PoS page and view login links and invoice references.
UI-level access control fix: permissioned tag helper now disables individual inputs/buttons and wraps forms/divs in disabled fieldsets.
Scope expansion of permissioned tag helper to cover div, button, and input elements.
Navigation markup cleanup in Crowdfund and PoS nav extensions, aligning with view-only permission handling.
Evidence from the diff
The commit addresses an authorization-enforcement gap in the PoS plugin’s UpdatePointOfSale.cshtml. Previously the entire form was wrapped in a single permissioned attribute, but the page still rendered for users lacking CanModifyStoreSettings, exposing invoice links and login links. The patch removes the form-level permission check and instead applies permissioned to individual submit buttons and the editable form container. It also extends PermissionedFormTagHelper to handle <div>, <button>, and <input> elements: for buttons/inputs it adds a disabled attribute; for forms/divs it wraps children in a disabled fieldset. Two nav extension views (Crowdfund and PoS) had redundant <span class="nav-link"> wrappers removed, likely to avoid styling/clickability issues for users with view-only permissions.
Changed components
BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.csBTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtmlBTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtmlBTCPayServer/Plugins/Crowdfund/Views/NavExtension.cshtmlInspect captured patch +21 / −15
diff --git a/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs b/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs
index 22d5988..b9a072f 100644
--- a/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs
+++ b/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs
@@ -7,6 +7,9 @@ using Microsoft.AspNetCore.Razor.TagHelpers;
namespace BTCPayServer.Abstractions.TagHelpers;
[HtmlTargetElement("form", Attributes = "[permissioned]")]
+[HtmlTargetElement("div", Attributes = "[permissioned]")]
+[HtmlTargetElement("button", Attributes = "[permissioned]")]
+[HtmlTargetElement("input", Attributes = "[permissioned]")]
public partial class PermissionedFormTagHelper(
IAuthorizationService authorizationService,
IHttpContextAccessor httpContextAccessor)
@@ -24,9 +27,16 @@ public partial class PermissionedFormTagHelper(
PermissionResource, Permissioned);
if (!res.Succeeded)
{
- var content = await output.GetChildContentAsync();
- var html = SubmitButtonRegex().Replace(content.GetContent(), "");
- output.Content.SetHtmlContent($"<fieldset disabled>{html}</fieldset>");
+ if (context.TagName is "input" or "button")
+ {
+ output.Attributes.Add("disabled", "disabled");
+ }
+ else
+ {
+ var content = await output.GetChildContentAsync();
+ var html = SubmitButtonRegex().Replace(content.GetContent(), "");
+ output.Content.SetHtmlContent($"<fieldset disabled>{html}</fieldset>");
+ }
}
}
diff --git a/BTCPayServer/Plugins/Crowdfund/Views/NavExtension.cshtml b/BTCPayServer/Plugins/Crowdfund/Views/NavExtension.cshtml
index 60a58bb..a39356b 100644
--- a/BTCPayServer/Plugins/Crowdfund/Views/NavExtension.cshtml
+++ b/BTCPayServer/Plugins/Crowdfund/Views/NavExtension.cshtml
@@ -20,10 +20,8 @@
@if (apps.Any())
{
<li layout-menu-item="CreateApp-@appType.Type" class="nav-item" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
- <span class="nav-link">
- <vc:icon symbol="nav-crowdfund" />
- <span text-translate="true">Crowdfund</span>
- </span>
+ <vc:icon symbol="nav-crowdfund" />
+ <span text-translate="true">Crowdfund</span>
</li>
}
@foreach (var app in apps)
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml
index 153f663..87480db 100644
--- a/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml
+++ b/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml
@@ -20,10 +20,8 @@
@if (apps.Any())
{
<li layout-menu-item="CreateApp-@appType.Type" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
- <span class="nav-link">
- <vc:icon symbol="nav-pointofsale" />
- <span text-translate="true">Point of Sale</span>
- </span>
+ <vc:icon symbol="nav-pointofsale" />
+ <span text-translate="true">Point of Sale</span>
</li>
}
@foreach (var app in apps)
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
index 420bba4..167d1d0 100644
--- a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
+++ b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
@@ -25,15 +25,15 @@
<script src="~/pos/admin.js" asp-append-version="true"></script>
}
-<form method="post" permissioned="@Policies.CanModifyStoreSettings">
+<form method="post">
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<div>
- <button id="page-primary" type="submit" class="btn btn-primary order-sm-1" text-translate="true">Save</button>
+ <button id="page-primary" type="submit" class="btn btn-primary order-sm-1" text-translate="true" permissioned="@Policies.CanModifyStoreSettings">Save</button>
<a class="btn btn-secondary" asp-action="ListInvoices" asp-controller="UIInvoice" asp-route-storeId="@Model.StoreId" asp-route-searchterm="@Model.SearchTerm" text-translate="true">Invoices</a>
@if (Model.Archived)
{
- <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" text-translate="true">Unarchive</button>
+ <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" text-translate="true" permissioned="@Policies.CanModifyStoreSettings">Unarchive</button>
}
else
{
@@ -56,7 +56,7 @@
{
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
- <div class="row" style="max-width:540px;">
+ <div permissioned="@Policies.CanModifyStoreSettings" class="row" style="max-width:540px;">
<div class="col-sm-6">
<div class="form-group">
<label asp-for="AppName" class="form-label" data-required></label>
Why this scored 68/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.