Add custom textbox for checkout (#7182)
What changed, and why it matters
This commit adds a new store setting that lets merchants type a short custom message shown on the checkout page. The text is limited to 500 characters, stored as plain text, and displayed using a safe Vue.js directive that escapes HTML. There is no obvious security bug in the change itself, but it is a new user-controlled text field rendered on a payment page, so it needs careful handling to avoid future misuse (for example, if someone later changes how the text is displayed).
No immediate patch is required. Verify that downstream consumers of `CheckoutModel.CheckoutText` (APIs, receipts, emails, plugins) also HTML-escape or treat it as plain text. Consider adding server-side normalization (e.g., trimming, newline preservation only) and ensure the field cannot be repurposed for rich HTML later without explicit sanitization.
Security signals we found
New user-controlled string rendered in checkout UI
Server-side MaxLength(500) and client-side maxlength both present
Vue v-text directive used for output, which escapes HTML
No sanitization or allow-listing beyond length limit
Plain-text-only intent stated in UI help text
Evidence from the diff
The patch introduces a CheckoutText property on StoreBlob, exposed through CheckoutAppearanceViewModel with a [MaxLength(500)] attribute and a client-side maxlength="500" textarea. The value is persisted in store settings and passed to the checkout view via CheckoutModel. In Checkout.cshtml it is rendered with v-text="srvModel.checkoutText", which uses Vue’s text interpolation and therefore HTML-escapes the content. CSS uses white-space: pre-wrap and overflow-wrap: break-word to preserve newlines and prevent layout breakage. No output encoding bypass, script injection, or validation weakness is visible in the diff.
Changed components
BTCPayServer/Controllers/UIInvoiceController.UI.csBTCPayServer/Controllers/UIStoresController.Settings.csBTCPayServer/Data/StoreBlob.csBTCPayServer/Models/InvoicingModels/CheckoutModel.csBTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.csBTCPayServer/Views/UIInvoice/Checkout.cshtmlBTCPayServer/Views/UIStores/CheckoutAppearance.cshtmlBTCPayServer/wwwroot/checkout/checkout.cssInspect captured patch +27 / −0
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index f188f39..ed4c3ab 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -889,6 +889,7 @@ namespace BTCPayServer.Controllers
RedirectAutomatically = invoice.RedirectAutomatically,
StoreName = store.StoreName,
StoreSupportUrl = supportUrl,
+ CheckoutText = storeBlob.CheckoutText,
TxCount = accounting.TxRequired,
TxCountForFee = storeBlob.NetworkFeeMode switch
{
diff --git a/BTCPayServer/Controllers/UIStoresController.Settings.cs b/BTCPayServer/Controllers/UIStoresController.Settings.cs
index f01ff9f..b59b108 100644
--- a/BTCPayServer/Controllers/UIStoresController.Settings.cs
+++ b/BTCPayServer/Controllers/UIStoresController.Settings.cs
@@ -250,6 +250,7 @@ public partial class UIStoresController
: await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.PaymentSoundUrl);
vm.HtmlTitle = storeBlob.HtmlTitle;
vm.SupportUrl = storeBlob.StoreSupportUrl;
+ vm.CheckoutText = storeBlob.CheckoutText;
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
vm.AutoDetectLanguage = storeBlob.AutoDetectLanguage;
@@ -382,6 +383,7 @@ public partial class UIStoresController
blob.ReceiptOptions = model.ReceiptOptions.ToDTO();
blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle;
blob.StoreSupportUrl = string.IsNullOrWhiteSpace(model.SupportUrl) ? null : model.SupportUrl.IsValidEmail() ? $"mailto:{model.SupportUrl}" : model.SupportUrl;
+ blob.CheckoutText = string.IsNullOrWhiteSpace(model.CheckoutText) ? null : model.CheckoutText;
blob.DisplayExpirationTimer = TimeSpan.FromMinutes(model.DisplayExpirationTimer);
blob.AutoDetectLanguage = model.AutoDetectLanguage;
blob.DefaultLang = model.DefaultLang;
diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs
index 7e505ce..6df938c 100644
--- a/BTCPayServer/Data/StoreBlob.cs
+++ b/BTCPayServer/Data/StoreBlob.cs
@@ -176,6 +176,7 @@ namespace BTCPayServer.Data
public List<PaymentMethodCriteria> PaymentMethodCriteria { get; set; }
public string HtmlTitle { get; set; }
+ public string CheckoutText { get; set; }
public bool AutoDetectLanguage { get; set; }
diff --git a/BTCPayServer/Models/InvoicingModels/CheckoutModel.cs b/BTCPayServer/Models/InvoicingModels/CheckoutModel.cs
index 95b33e2..3057500 100644
--- a/BTCPayServer/Models/InvoicingModels/CheckoutModel.cs
+++ b/BTCPayServer/Models/InvoicingModels/CheckoutModel.cs
@@ -64,6 +64,7 @@ namespace BTCPayServer.Models.InvoicingModels
public int TxCountForFee { get; set; }
public string Paid { get; set; }
public string StoreSupportUrl { get; set; }
+ public string CheckoutText { get; set; }
public string OrderId { get; set; }
public decimal NetworkFee { get; set; }
diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs
index de623bf..3b1b126 100644
--- a/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs
+++ b/BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs
@@ -68,6 +68,10 @@ namespace BTCPayServer.Models.StoreViewModels
[MaxLength(500)]
public string SupportUrl { get; set; }
+ [Display(Name = "Custom checkout text")]
+ [MaxLength(500)]
+ public string CheckoutText { get; set; }
+
[Display(Name = "Show a timer … minutes before invoice expiration")]
[Range(1, 60 * 24 * 24)]
public int DisplayExpirationTimer { get; set; }
diff --git a/BTCPayServer/Views/UIInvoice/Checkout.cshtml b/BTCPayServer/Views/UIInvoice/Checkout.cshtml
index f9af3a5..2fae202 100644
--- a/BTCPayServer/Views/UIInvoice/Checkout.cshtml
+++ b/BTCPayServer/Views/UIInvoice/Checkout.cshtml
@@ -223,6 +223,11 @@
</div>
</section>
</main>
+ <main v-if="isActive && srvModel.checkoutText" class="tile checkout-text">
+ <section>
+ <div class="text-muted text-center" v-text="srvModel.checkoutText"></div>
+ </section>
+ </main>
@if (Env.CheatMode)
{
<checkout-cheating invoice-id="@Model.InvoiceId" :due="due" :is-settled="isSettled" :is-processing="isProcessing" :payment-method-id="pmId" :crypto-code="srvModel.paymentMethodCurrency"></checkout-cheating>
diff --git a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml
index 06bac2b..70b38bc 100644
--- a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml
+++ b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml
@@ -220,6 +220,12 @@
<span asp-validation-for="SupportUrl" class="text-danger"></span>
<div class="form-text" html-translate="true">A "Contact Us" button with this link will be shown on the checkout page. Can contain the placeholders <code>{OrderId}</code> and <code>{InvoiceId}</code>. Can be any valid URI, such as a website, email, and Nostr.</div>
</div>
+ <div class="form-group">
+ <label asp-for="CheckoutText" class="form-label"></label>
+ <textarea asp-for="CheckoutText" class="form-control" rows="3" maxlength="500"></textarea>
+ <span asp-validation-for="CheckoutText" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Custom text displayed on the checkout page below the payment details. Plain text only, newlines are supported.</div>
+ </div>
<div class="d-flex my-3">
<input asp-for="LazyPaymentMethods" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="LazyPaymentMethods" class="form-check-label"></label>
diff --git a/BTCPayServer/wwwroot/checkout/checkout.css b/BTCPayServer/wwwroot/checkout/checkout.css
index 23fdc84..d34bf3e 100644
--- a/BTCPayServer/wwwroot/checkout/checkout.css
+++ b/BTCPayServer/wwwroot/checkout/checkout.css
@@ -180,6 +180,13 @@ section dl > div dd {
margin-top: var(--btcpay-space-l);
}
+.checkout-text {
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ font-size: var(--btcpay-font-size-s);
+ line-height: 1.4;
+}
+
@media (max-width: 400px) {
/* Pull it up if there's no store header */
#Checkout > main.tile:first-child {
Why this scored 32/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.