Merge pull request #7599 from NicolasDorier/fix/invoice-list-store-permissions
What changed, and why it matters
This change fixes a permission problem in BTCPay Server's invoice list. Previously, the invoice list page accepted a 'storeid:' search filter or a StoreId parameter that could let a user see invoices from stores they were not supposed to access. The patch forces the invoice list to always use the store ID from the URL route, ignoring any user-supplied store filter, so users can only view invoices for the store they are currently in.
Review whether any other invoice endpoints or reports still honor user-supplied store identifiers, and ensure the route-level store authorization is consistently applied across the invoice subsystem.
Security signals we found
Authorization boundary enforced by scoping query to route-supplied store ID
Removal of user-controlled StoreId from view model
Search filter 'storeid:' no longer expands the set of stores queried
Test added to verify cross-store invoice visibility is denied
Evidence from the diff
The ListInvoices action previously built a set of store IDs from both model.StoreId and the search string’s ‘storeid:’ filter, then queried invoices for all of those stores. The patch removes model.StoreId from InvoicesModel, changes the action signature to bind storeId from the route, and sets invoiceQuery.StoreId to only that route-bound store. The CreateInvoice action is also updated to take storeId as a direct parameter. Tests were adjusted to assert that a user with access to one store cannot see another store’s invoices even when searching with storeid:otherStoreId.
Changed components
BTCPayServer/Controllers/UIInvoiceController.UI.csBTCPayServer/Models/InvoicingModels/InvoicesModel.csBTCPayServer/Views/UIInvoice/ListInvoices.cshtmlBTCPayServer.Tests/UnitTest1.csInspect captured patch +31 / −30
### BTCPayServer.Tests/UnitTest1.cs
@@ -528,9 +528,8 @@ public async Task CanListInvoices()
});
AssertSearchInvoice(acc, true, invoice.Id, null);
- AssertSearchInvoice(acc, true, invoice.Id, null, acc.StoreId);
AssertSearchInvoice(acc, true, invoice.Id, $"storeid:{acc.StoreId}");
- AssertSearchInvoice(acc, false, invoice.Id, "storeid:doesnotexist");
+ AssertSearchInvoice(acc, true, invoice.Id, "storeid:doesnotexist");
AssertSearchInvoice(acc, true, invoice.Id, $"{invoice.Id}");
AssertSearchInvoice(acc, true, invoice.Id, "exceptionstatus:paidPartial");
AssertSearchInvoice(acc, false, invoice.Id, "exceptionstatus:paidOver");
@@ -539,11 +538,24 @@ public async Task CanListInvoices()
AssertSearchInvoice(acc, true, invoice.Id, "status:settled,exceptionstatus:paidPartial");
AssertSearchInvoice(acc, true, invoice.Id, "status:settled,status:invalid,exceptionstatus:paidPartial,exceptionstatus:paidOver");
+ var otherStore = tester.NewAccount();
+ await otherStore.GrantAccessAsync();
+ otherStore.RegisterDerivationScheme("BTC");
+ var otherInvoice = await otherStore.BitPay.CreateInvoiceAsync(
+ new Invoice { Price = 10, Currency = "USD" }, Facade.Merchant);
+ var storeRepository = tester.PayTester.GetService<StoreRepository>();
+ await storeRepository.AddOrUpdateStoreUser(otherStore.StoreId, acc.UserId,
+ new StoreRoleId("Multisigner Guest"));
+ var permissionService = tester.PayTester.GetService<PermissionService>();
+ var accessibleOtherStore = await storeRepository.FindStore(otherStore.StoreId, acc.UserId);
+ Assert.False(accessibleOtherStore.HasPolicy(acc.UserId, Policies.CanViewInvoices, permissionService));
+ AssertSearchInvoice(acc, false, otherInvoice.Id, $"storeid:{otherStore.StoreId}");
+
var invoiceController = acc.GetController<UIInvoiceController>();
var comment = "refunded manually from cashier wallet";
await invoiceController.Comment(invoice.Id, comment);
- var listResult = await invoiceController.ListInvoices(new InvoicesModel { StoreId = acc.StoreId });
+ var listResult = await invoiceController.ListInvoices(acc.StoreId, new InvoicesModel());
var listModel = (InvoicesModel)((ViewResult)listResult).Model;
var listedInvoice = Assert.Single(listModel.Invoices, i => i.InvoiceId == invoice.Id);
Assert.Equal(comment, listedInvoice.Comment);
@@ -622,11 +634,11 @@ public async Task CanGetRates()
response.EnsureSuccessStatusCode();
}
- private void AssertSearchInvoice(TestAccount acc, bool expected, string invoiceId, string filter, string storeId = null)
+ private void AssertSearchInvoice(TestAccount acc, bool expected, string invoiceId, string filter)
{
var result =
- (InvoicesModel)((ViewResult)acc.GetController<UIInvoiceController>(storeId is not null)
- .ListInvoices(new InvoicesModel { SearchTerm = filter, StoreId = storeId }).Result).Model;
+ (InvoicesModel)((ViewResult)acc.GetController<UIInvoiceController>()
+ .ListInvoices(acc.StoreId, new InvoicesModel { SearchTerm = filter }).Result).Model;
Assert.Equal(expected, result.Invoices.Any(i => i.InvoiceId == invoiceId));
}
### BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -1080,26 +1080,15 @@ private async Task NotifySocket(WebSocket webSocket, string invoiceId, string ex
[HttpGet("/stores/{storeId}/invoices")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
- public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
+ public async Task<IActionResult> ListInvoices(string storeId, InvoicesModel? model = null)
{
model ??= new InvoicesModel();
var fs = model.GetSearch();
if (model.FilterCommand is not null)
return model.Redirect(Request);
- string? storeId = model.StoreId;
- var storeIds = new HashSet<string>();
- if (storeId is not null)
- {
- storeIds.Add(storeId);
- }
- if (fs.GetFilterArray("storeid") is { } l)
- {
- foreach (var i in l)
- storeIds.Add(i);
- }
var apps = await _appService.GetAllApps(User.GetIdOrNull(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps);
- invoiceQuery.StoreId = storeIds.ToArray();
+ invoiceQuery.StoreId = [storeId];
invoiceQuery.Take = model.Count;
invoiceQuery.Skip = model.Skip;
invoiceQuery.IncludeRefunds = true;
@@ -1162,15 +1151,15 @@ private InvoiceQuery GetInvoiceQuery(SearchString fs, ListAppsViewModel.ListAppV
[HttpGet("/stores/{storeId}/invoices/create")]
[HttpGet("invoices/create")]
[Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> CreateInvoice(InvoicesModel? model = null)
+ public async Task<IActionResult> CreateInvoice(string? storeId = null)
{
- if (string.IsNullOrEmpty(model?.StoreId))
+ if (string.IsNullOrEmpty(storeId))
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["You need to select a store before creating an invoice."].Value;
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
- var store = await _StoreRepository.FindStore(model.StoreId);
+ var store = await _StoreRepository.FindStore(storeId);
if (store == null)
return NotFound();
@@ -1182,7 +1171,7 @@ public async Task<IActionResult> CreateInvoice(InvoicesModel? model = null)
var storeBlob = store.GetStoreBlob();
var vm = new CreateInvoiceModel
{
- StoreId = model.StoreId,
+ StoreId = storeId,
Currency = storeBlob.DefaultCurrency,
AvailablePaymentMethods = GetPaymentMethodsSelectList(store)
};
### BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
@@ -9,7 +9,6 @@ public class InvoicesModel : BasePagingViewModel
{
public List<InvoiceModel> Invoices { get; set; } = new ();
public override int CurrentPageCount => Invoices.Count;
- public string StoreId { get; set; }
public List<InvoiceAppModel> Apps { get; set; }
}
### BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
@@ -5,6 +5,7 @@
@model InvoicesModel
@{
ViewData.SetLayoutModel(new("Invoices", StringLocalizer["Invoices"]));
+ var storeId = Context.GetStoreData().Id;
var statusFilterCount = Model.Search.CountArrayFilter("status") + Model.Search.CountArrayFilter("exceptionstatus") + (Model.Search.HasBooleanFilter("includearchived") ? 1 : 0) + (Model.Search.HasBooleanFilter("unusual") ? 1 : 0);
var appFilterCount = Model.Apps.Count(app => Model.Search.HasArrayFilter("appid", app.Id));
}
@@ -49,7 +50,7 @@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
<script>
- var invoicesUrl = @Safe.Json(Url.Action(nameof(UIInvoiceController.ListInvoices), new { storeId = Model.StoreId }));
+ var invoicesUrl = @Safe.Json(Url.Action(nameof(UIInvoiceController.ListInvoices), new { storeId }));
delegate('click', '.showInvoice', e => {
e.preventDefault();
@@ -74,7 +75,7 @@
permission="@Policies.CanViewReports"
asp-controller="UIReports"
asp-action="StoreReports"
- asp-route-storeId="@Model.StoreId"
+ asp-route-storeId="@storeId"
asp-route-viewName="Invoices"
class="btn btn-secondary">
<vc:icon symbol="nav-reporting" />
@@ -83,7 +84,7 @@
<a id="page-primary"
permission="@Policies.CanCreateInvoice"
asp-action="CreateInvoice"
- asp-route-storeId="@Model.StoreId"
+ asp-route-storeId="@storeId"
asp-route-searchTerm="@Model.SearchTerm"
class="btn btn-primary"
text-translate="true">
@@ -112,7 +113,7 @@
<partial name="_StatusMessage" />
-<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
+<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8" asp-action="ListInvoices" asp-route-storeId="@storeId" method="get">
<input asp-for="Count" type="hidden" />
<vc:search-string-input search-string="Model.Search"></vc:search-string-input>
@@ -170,8 +171,8 @@
{
@* Standalone mass-action form: its controls reference it via the form="mass-action-form" attribute
so the per-row comment forms below are not nested inside it (nested forms are invalid HTML). *@
- <form id="mass-action-form" method="post" asp-action="MassAction" asp-route-storeId="@Model.StoreId">
- <input type="hidden" name="storeId" value="@Model.StoreId" />
+ <form id="mass-action-form" method="post" asp-action="MassAction" asp-route-storeId="@storeId">
+ <input type="hidden" name="storeId" value="@storeId" />
</form>
<div class="table-responsive-md">
<table id="invoices" class="table table-hover mass-action">Why this scored 67/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.