Validate support URL scheme to prevent stored script injection (#7537)
What changed, and why it matters
This commit fixes a stored cross-site scripting (XSS) risk in BTCPay Server's store settings. Merchants can set a 'Support URL' that is shown to customers during checkout. Before this fix, an attacker with access to store settings could enter a URL using the 'javascript:' scheme, such as 'javascript:document.body.dataset.pwned=1'. When a customer later clicked the support link, the browser would run the attacker's JavaScript in the checkout page. The patch now rejects any support URL whose scheme is not http, https, or mailto, both in the web UI and in the Greenfield API.
Upgrade to a BTCPay Server release that includes this commit. If self-hosting from source, apply the patch and verify that the Support URL field rejects javascript:, data:, and other non-http/https/mailto schemes in both the store settings UI and the Greenfield API.
Security signals we found
Stored XSS via javascript: URI in SupportUrl
Missing scheme validation on user-supplied URL
Greenfield API and UI controller both patched
Test case explicitly uses javascript: payload
Evidence from the diff
The patch adds server-side validation for the StoreSupportUrl/SupportUrl field in two controllers. GreenfieldStoresController.Validate now requires an absolute URI with scheme http, https, or mailto. UIStoresController.Settings now trims the value, accepts empty (null), a valid email (prefixed with mailto:), or an absolute http/https URL; anything else adds a ModelState error. The view-model assignment is also adjusted to strip the mailto: prefix when displaying the value in the checkout-appearance form. A test case was updated to expect validation errors for CssUrl, LogoUrl, BrandColor, and SupportUrl, using ‘javascript:document.body.dataset.pwned=1’ as the malicious support URL.
Changed components
BTCPayServer/Controllers/GreenField/GreenfieldStoresController.csBTCPayServer/Controllers/UIStoresController.Settings.csBTCPayServer.Tests/GreenfieldAPITests.csInspect captured patch +19 / −3
### BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -1427,11 +1427,12 @@ public async Task StoresControllerTests()
Assert.Equal("A", newStore.Name);
// validate
- await AssertValidationError(["CssUrl", "LogoUrl", "BrandColor"], async () =>
+ await AssertValidationError(["CssUrl", "LogoUrl", "BrandColor", "SupportUrl"], async () =>
await client.UpdateStore(newStore.Id, new UpdateStoreRequest
{
CssUrl = "style.css",
LogoUrl = "logo.svg",
+ SupportUrl = "javascript:document.body.dataset.pwned=1",
BrandColor = "invalid"
}));
### BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs
@@ -352,6 +352,11 @@ private IActionResult Validate(StoreBaseData request)
{
ModelState.AddModelError(nameof(request.BrandColor), "Brand color is not a valid HEX Color (e.g. #F7931A)");
}
+ if (!string.IsNullOrEmpty(request.SupportUrl) &&
+ (!Uri.TryCreate(request.SupportUrl, UriKind.Absolute, out var supportUri) || supportUri.Scheme is not ("http" or "https" or "mailto")))
+ {
+ ModelState.AddModelError(nameof(request.SupportUrl), "Support URL is not a valid url");
+ }
if (request.InvoiceExpiration < TimeSpan.FromMinutes(1) && request.InvoiceExpiration > TimeSpan.FromMinutes(60 * 24 * 24))
ModelState.AddModelError(nameof(request.InvoiceExpiration), "InvoiceExpiration can only be between 1 and 34560 mins");
if (request.DisplayExpirationTimer < TimeSpan.FromMinutes(1) && request.DisplayExpirationTimer > TimeSpan.FromMinutes(60 * 24 * 24))
### BTCPayServer/Controllers/UIStoresController.Settings.cs
@@ -252,7 +252,8 @@ public async Task<IActionResult> CheckoutAppearance()
? string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout/payment.mp3")
: await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.PaymentSoundUrl);
vm.HtmlTitle = storeBlob.HtmlTitle;
- vm.SupportUrl = storeBlob.StoreSupportUrl;
+ vm.SupportUrl = storeBlob.StoreSupportUrl?.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) is true
+ ? storeBlob.StoreSupportUrl["mailto:".Length..] : storeBlob.StoreSupportUrl;
vm.CheckoutText = storeBlob.CheckoutText;
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
@@ -334,6 +335,16 @@ public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel
needUpdate = true;
}
+ var supportUrl = model.SupportUrl?.Trim();
+ if (string.IsNullOrEmpty(supportUrl))
+ blob.StoreSupportUrl = null;
+ else if (supportUrl.IsValidEmail())
+ blob.StoreSupportUrl = $"mailto:{supportUrl}";
+ else if (Uri.TryCreate(supportUrl, UriKind.Absolute, out var supportUri) && supportUri.Scheme is "http" or "https")
+ blob.StoreSupportUrl = supportUrl;
+ else
+ ModelState.AddModelError(nameof(model.SupportUrl), "Support URL is not a valid url");
+
if (!ModelState.IsValid)
{
return View(model);
@@ -387,7 +398,6 @@ public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel
blob.RedirectAutomatically = model.RedirectAutomatically;
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;Why this scored 66/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.