What changed, and why it matters
This commit adds a new feature that lets users rename labels attached to wallets and payment requests. It introduces new web forms, controller actions, and a database rename routine. The change is a feature addition, not a documented security fix. There is one notable design quirk: the wallet-label edit action does not show an explicit authorization attribute in the diff, while the payment-request edit action does. That could simply be because wallet routes already inherit authorization from the controller, but the diff alone does not prove it, so it should be treated as a low-confidence observation rather than a confirmed vulnerability.
Review whether UIWalletsController.EditWalletLabel inherits adequate authorization from the controller or route conventions; if not, add an explicit [Authorize] attribute. Verify that anti-forgery tokens are enforced on both new POST actions and that the label input is properly validated and encoded on output. Consider adding a database uniqueness constraint or conflict handling to prevent duplicate labels during rename.
Security signals we found
New POST endpoints for renaming labels added to two controllers
Payment-request endpoint has explicit authorization policy; wallet endpoint does not show one in the diff
Repository uses parameterized Dapper query for the UPDATE, reducing SQL injection risk
Client-side JavaScript builds form action URL with encodeURIComponent(label)
No visible anti-forgery token or rate-limiting changes in the diff
Rename operation updates link rows and deletes old label object; potential for race conditions or data inconsistency not addressed
Evidence from the diff
The patch implements label editing across two controllers (UIWalletsController and UIPaymentRequestController) and a shared WalletRepository.RenameWalletLabel method. The repository method ensures the new label object exists, updates WalletObjectLinks rows, then removes the old label object. The UI adds an edit modal and JavaScript to set the form action. The payment-request action is decorated with [Authorize(Policy = Policies.CanModifyPaymentRequests, …)]; the wallet action has no explicit authorization attribute in the visible diff. No anti-forgery token handling, output encoding, or SQL-injection protections are visible beyond the existing Dapper parameterized query, which appears correctly parameterized. No security relevance is stated by the vendor.
Changed components
BTCPayServer/Controllers/UIPaymentRequestController.csBTCPayServer/Controllers/UIWalletsController.csBTCPayServer/Services/WalletRepository.csBTCPayServer/Views/UIPaymentRequest/PaymentRequestLabels.cshtmlBTCPayServer/Views/UIWallets/WalletLabels.cshtmlInspect captured patch +184 / −0
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index 23650ac..e323694 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -654,6 +654,38 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
}
+ [HttpPost("/stores/{storeId}/payment-requests/labels/{id}/edit")]
+ [Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> EditPaymentRequestLabel(string storeId, string id, string newLabel)
+ {
+ if (string.IsNullOrWhiteSpace(newLabel))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Label name cannot be empty."].Value;
+ return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
+ }
+
+ newLabel = newLabel.Trim();
+ if (newLabel == id)
+ {
+ return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
+ }
+
+ var store = GetCurrentStore();
+ var defaultNetwork = _networkProvider.DefaultNetwork;
+ var walletId = new WalletId(store.Id, defaultNetwork.CryptoCode);
+
+ if (await _walletRepository.RenameWalletLabel(walletId, id, newLabel))
+ {
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully renamed."].Value;
+ }
+ else
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be renamed."].Value;
+ }
+
+ return RedirectToAction(nameof(PaymentRequestLabels), new { storeId });
+ }
+
private string GetUserId() => _UserManager.GetUserId(User);
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index c36d019..3114fec 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -1936,6 +1936,35 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(WalletLabels), new { walletId });
}
+ [HttpPost("{walletId}/labels/{id}/edit")]
+ public async Task<IActionResult> EditWalletLabel(
+ [ModelBinder(typeof(WalletIdModelBinder))]
+ WalletId walletId, string id, string newLabel)
+ {
+ if (string.IsNullOrWhiteSpace(newLabel))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Label name cannot be empty."].Value;
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
+ newLabel = newLabel.Trim();
+ if (newLabel == id)
+ {
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
+ if (await WalletRepository.RenameWalletLabel(walletId, id, newLabel))
+ {
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully renamed."].Value;
+ }
+ else
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be renamed."].Value;
+ }
+
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
private string? GetImage(BTCPayNetwork network)
{
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs
index 6f75e61..56b17f8 100644
--- a/BTCPayServer/Services/WalletRepository.cs
+++ b/BTCPayServer/Services/WalletRepository.cs
@@ -726,6 +726,43 @@ namespace BTCPayServer.Services
return count > 0;
}
+ public async Task<bool> RenameWalletLabel(WalletId id, string oldLabel, string newLabel)
+ {
+ ArgumentNullException.ThrowIfNull(id);
+ oldLabel = oldLabel.Trim();
+ newLabel = newLabel.Trim().Truncate(MaxLabelSize);
+
+ if (oldLabel == newLabel)
+ return true;
+
+ // First, ensure the new label object exists (required for foreign key constraint)
+ var newLabelObjId = new WalletObjectId(id, WalletObjectData.Types.Label, newLabel);
+ await EnsureWalletObject(newLabelObjId);
+
+ await using var ctx = _ContextFactory.CreateContext();
+ var connection = ctx.Database.GetDbConnection();
+
+ // Update all links from old label to new label
+ var updated = await connection.ExecuteAsync(
+ """
+ UPDATE "WalletObjectLinks"
+ SET "AId" = @NewLabel
+ WHERE "WalletId" = @WalletId
+ AND "AType" = @LabelType
+ AND "AId" = @OldLabel
+ """,
+ new { WalletId = id.ToString(), LabelType = WalletObjectData.Types.Label, OldLabel = oldLabel, NewLabel = newLabel });
+
+ // If any links were updated, remove the old label object
+ if (updated > 0)
+ {
+ var oldLabelObjId = new WalletObjectId(id, WalletObjectData.Types.Label, oldLabel);
+ await RemoveWalletObjects(oldLabelObjId);
+ }
+
+ return updated > 0;
+ }
+
public async Task SetWalletObject(WalletObjectId id, JObject? data)
{
ArgumentNullException.ThrowIfNull(id);
diff --git a/BTCPayServer/Views/UIPaymentRequest/PaymentRequestLabels.cshtml b/BTCPayServer/Views/UIPaymentRequest/PaymentRequestLabels.cshtml
index 39adab8..e965276 100644
--- a/BTCPayServer/Views/UIPaymentRequest/PaymentRequestLabels.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/PaymentRequestLabels.cshtml
@@ -8,6 +8,22 @@
delegate('click', '.btn-delete', event => {
event.preventDefault()
})
+
+ delegate('click', '.btn-edit', event => {
+ const button = event.target.closest('.btn-edit')
+ const label = button.dataset.label
+ const modal = document.getElementById('EditLabelModal')
+ const input = modal.querySelector('#EditLabelInput')
+ const form = modal.querySelector('form')
+ const oldLabelInput = modal.querySelector('#OldLabel')
+ const storeId = '@Model.StoreId'
+
+ input.value = label
+ oldLabelInput.value = label
+ form.action = `/stores/${storeId}/payment-requests/labels/${encodeURIComponent(label)}/edit`
+
+ new bootstrap.Modal(modal).show()
+ })
</script>
}
@@ -51,6 +67,9 @@
title="View Payment Requests with this label">
Payment Requests
</a>
+ <button class="btn btn-link btn-edit p-0 me-3" type="button"
+ data-label="@label.Label" text-translate="true">Edit
+ </button>
<form method="post" asp-action="DeletePaymentRequestLabel" asp-route-storeId="@Model.StoreId" asp-route-id="@label.Label"
class="d-inline">
<button class="btn btn-link btn-delete p-0 me-3" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
@@ -67,6 +86,30 @@
<partial name="_Confirm"
model="@(new ConfirmModel(StringLocalizer["Delete label"],
StringLocalizer["This label will be deleted from all payment requests."], StringLocalizer["Delete"]))" />
+
+ <div class="modal fade" id="EditLabelModal" tabindex="-1" aria-labelledby="EditLabelModalLabel" aria-hidden="true">
+ <div class="modal-dialog">
+ <form method="post" asp-action="EditPaymentRequestLabel" asp-route-storeId="@Model.StoreId">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="EditLabelModalLabel" text-translate="true">Edit Label</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <input type="hidden" id="OldLabel" name="id" />
+ <div class="form-group">
+ <label for="EditLabelInput" class="form-label" text-translate="true">Label Name</label>
+ <input type="text" class="form-control" id="EditLabelInput" name="newLabel" required maxlength="50" />
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" text-translate="true">Cancel</button>
+ <button type="submit" class="btn btn-primary" text-translate="true">Save</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
}
else
{
diff --git a/BTCPayServer/Views/UIWallets/WalletLabels.cshtml b/BTCPayServer/Views/UIWallets/WalletLabels.cshtml
index 87135a8..f4fc349 100644
--- a/BTCPayServer/Views/UIWallets/WalletLabels.cshtml
+++ b/BTCPayServer/Views/UIWallets/WalletLabels.cshtml
@@ -11,6 +11,22 @@
delegate('click', '.btn-delete', event => {
event.preventDefault()
})
+
+ delegate('click', '.btn-edit', event => {
+ const button = event.target.closest('.btn-edit')
+ const label = button.dataset.label
+ const modal = document.getElementById('EditLabelModal')
+ const input = modal.querySelector('#EditLabelInput')
+ const form = modal.querySelector('form')
+ const oldLabelInput = modal.querySelector('#OldLabel')
+ const walletId = '@walletId'
+
+ input.value = label
+ oldLabelInput.value = label
+ form.action = `/wallets/${walletId}/labels/${encodeURIComponent(label)}/edit`
+
+ new bootstrap.Modal(modal).show()
+ })
</script>
}
@@ -54,6 +70,9 @@
title="View Reserved Addresses with this label">
Addresses
</a>
+ <button class="btn btn-link btn-edit p-0 me-3" type="button"
+ data-label="@label.Label" text-translate="true">Edit
+ </button>
<form method="post" asp-action="DeleteWalletLabel" asp-route-walletId="@Model.WalletId" asp-route-id="@label.Label"
class="d-inline">
<button class="btn btn-link btn-delete p-0 me-3" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
@@ -70,6 +89,30 @@
<partial name="_Confirm"
model="@(new ConfirmModel(StringLocalizer["Delete label"],
StringLocalizer["This label will be deleted from this wallet and its associated transactions."], StringLocalizer["Delete"]))" />
+
+ <div class="modal fade" id="EditLabelModal" tabindex="-1" aria-labelledby="EditLabelModalLabel" aria-hidden="true">
+ <div class="modal-dialog">
+ <form method="post" asp-action="EditWalletLabel" asp-route-walletId="@Model.WalletId">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="EditLabelModalLabel" text-translate="true">Edit Label</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <input type="hidden" id="OldLabel" name="id" />
+ <div class="form-group">
+ <label for="EditLabelInput" class="form-label" text-translate="true">Label Name</label>
+ <input type="text" class="form-control" id="EditLabelInput" name="newLabel" required maxlength="50" />
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" text-translate="true">Cancel</button>
+ <button type="submit" class="btn btn-primary" text-translate="true">Save</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
}
else
{
Why this scored 22/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.