What changed, and why it matters
This is a broad security patch for BTCPay Server 2.4.3. It fixes several access-control gaps (for example, lower-privileged store users could manage server admins or see sensitive API keys), hardens user-profile changes by requiring the current password, removes a third-party comment system (Disqus) that could inject scripts, adds rate limits to public invoice creation, stops plugins with known incompatibilities from loading, and tightens validation of Bitcoin transactions and multisig wallet setup. The changelog explicitly calls it a security release and recommends updating for multi-user servers.
Upgrade to BTCPay Server 2.4.3 promptly, especially on shared or multi-user instances. Review store membership and server-admin assignments after upgrading, and verify that any custom plugins are compatible with the new loader blacklist.
Security signals we found
Authorization bypass fix: non-server-admin store managers can no longer add or modify server-admin store membership
User profile update now requires current password for email changes, not only password changes
Invitation URLs are no longer returned to callers lacking CanManageUsers
Legacy Bitpay API key hidden from users without store modification rights
Rate limiting added to anonymous public invoice creation endpoints
Disqus integration removed, closing a third-party script-injection surface and CSP bypass
NFC LNURL-withdraw now gated by explicit store NfcEnabled setting
PSBT final-script and UTXO validation added to pending transactions and PayJoin
Hot-wallet flag cannot be enabled via derivation-scheme API
Known incompatible plugins are blocked from loading
Dependency and runtime image version bumps
Evidence from the diff
The commit bundles many defensive changes across 78 files. Key signals: (1) authorization hardening in GreenfieldStoreUsersController and UIStoresController.Users to prevent non-server-admins from adding/updating server-admin store membership; (2) GreenfieldUsersController now requires current password for both email and password changes and gates invitation URLs on CanManageUsers; (3) Bitpay legacy API key no longer shown to users lacking CanModifyStoreSettings; (4) public invoice endpoints (Bitpay, PayButton, Forms) get rate limiting; (5) Disqus integration removed from Crowdfund, eliminating external script injection and CSP exceptions; (6) NFC payments now require an explicit store-level NfcEnabled flag; (7) PayJoin and pending-transaction PSBT handling validates UTXO data and final scripts; (8) hot-wallet IsHotWallet flag cannot be set through the derivation-scheme API; (9) plugin loader blacklists known incompatible plugin versions; (10) route-value provider ordering changed and antiforgery token removed from PostRedirect form; (11) dependency bumps (HtmlSanitizer, EF Core, NBitcoin, runtime images, LND). Several of these are direct fixes for privilege-escalation or injection paths, while others are defense-in-depth.
Changed components
Greenfield API (users, store users, API keys, apps)UI store/user/role controllersBitpay invoice and token controllersCrowdfund plugin (Disqus removal, HTML sanitization)NFC pluginPayJoin endpoint controllerPending transaction service / multisig pluginPayButton and public forms controllersPlugin loaderWallet setup/on-chain wallet controllersSubscription plugin authorizationASP.NET MVC model binding / antiforgeryInspect captured patch +425 / −247
### BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj
@@ -31,9 +31,9 @@
<None Include="icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
- <PackageReference Include="HtmlSanitizer" Version="9.1.982" />
- <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="10.0.10" />
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
+ <PackageReference Include="HtmlSanitizer" Version="9.2.995" />
+ <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="10.0.11" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
### BTCPayServer.Client/BTCPayServer.Client.csproj
@@ -30,8 +30,8 @@
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
- <PackageReference Include="BTCPayServer.Lightning.Common" Version="1.7.1" />
- <PackageReference Include="NBitcoin" Version="10.0.8" />
+ <PackageReference Include="BTCPayServer.Lightning.Common" Version="1.7.2" />
+ <PackageReference Include="NBitcoin" Version="10.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
### BTCPayServer.Client/Models/CrowdfundAppData.cs
@@ -20,8 +20,6 @@ public abstract class CrowdfundBaseData : AppBaseData
public string? MainImageUrl { get; set; }
public string? NotificationUrl { get; set; }
public string? Tagline { get; set; }
- public bool? DisqusEnabled { get; set; }
- public string? DisqusShortname { get; set; }
public bool? SoundsEnabled { get; set; }
public bool? AnimationsEnabled { get; set; }
public int? ResetEveryAmount { get; set; }
### BTCPayServer.Client/Models/UpdateApplicationUserRequest.cs
@@ -18,7 +18,7 @@ public class UpdateApplicationUserRequest
public string Email { get; set; }
/// <summary>
- /// current password of the user
+ /// current password of the user, required to change the email or the password
/// </summary>
public string CurrentPassword { get; set; }
### BTCPayServer.Data/BTCPayServer.Data.csproj
@@ -3,12 +3,12 @@
<Import Project="../Build/Common.csproj" />
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
+ <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.11" />
<PackageReference Include="NBitcoin.Altcoins" Version="6.0.4" />
<PackageReference Include="Dapper" Version="2.1.79" />
</ItemGroup>
### BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
@@ -283,7 +283,11 @@ await GetBySelector(dbSet, storeId, selector) :
: (await ctx.Customers.GetBySelector(storeId, selector))?.Id;
if (customerId is null)
return null;
- return await subscribers.IncludeAll().Where(s => s.OfferingId == offeringId && s.CustomerId == customerId).FirstOrDefaultAsync();
+ return await subscribers.IncludeAll()
+ .Where(s => s.OfferingId == offeringId &&
+ s.Offering.App.StoreDataId == storeId &&
+ s.CustomerId == customerId)
+ .FirstOrDefaultAsync();
}
public static Task<CustomerData?> GetBySelector(this IQueryable<CustomerData> customers, string storeId, CustomerSelector selector)
### BTCPayServer.Rating/BTCPayServer.Rating.csproj
@@ -6,7 +6,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
- <PackageReference Include="NBitcoin" Version="10.0.8" />
+ <PackageReference Include="NBitcoin" Version="10.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="DigitalRuby.ExchangeSharp" Version="1.2.1" />
</ItemGroup>
### BTCPayServer.Tests/ApiKeysTests.cs
@@ -602,6 +602,7 @@ public class UITestApiKeyController : Controller
{
[HttpPost]
[Route("postredirect-callback-test")]
+ [IgnoreAntiforgeryToken]
public ActionResult PostRedirectCallbackTestpage(IFormCollection data)
{
var list = data.Keys.Aggregate(new Dictionary<string, string>(), (res, key) =>
### BTCPayServer.Tests/BTCPayServer.Tests.csproj
@@ -43,7 +43,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Microsoft.Playwright" Version="1.61.0" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
- <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.10" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.11" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
### BTCPayServer.Tests/Dockerfile
@@ -1,4 +1,4 @@
-FROM mcr.microsoft.com/dotnet/sdk:10.0.302-noble AS builder
+FROM mcr.microsoft.com/dotnet/sdk:10.0.400-noble AS builder
WORKDIR /source
COPY nuget.config nuget.config
### BTCPayServer.Tests/PlaywrightTester.cs
@@ -284,7 +284,7 @@ public async Task<Mnemonic> GenerateWallet(string cryptoCode = "BTC", string see
{
TestLogs.LogInformation($"Replacing the wallet");
await Page.ClickAsync("#ActionsDropdownToggle");
- await Page.ClickAsync("#ChangeWalletLink");
+ await Page.ClickAsync(".wallet-settings__replace-wallet");
await Page.FillAsync("#ConfirmInput", "REPLACE");
await Page.ClickAsync("#ConfirmContinue");
}
### BTCPayServer.Tests/RolesTests.cs
@@ -751,23 +751,8 @@ await s.Page.EvaluateAsync(
await s.LogIn(walletManager);
await s.GoToUrl(WalletDelete(storeId, cryptoCode));
- await s.Page.EvaluateAsync(
- @"({ otherStoreId }) => {
- const form = document.getElementById('ConfirmForm');
- if (!form) throw new Error('Missing confirm form');
- for (const [name, value] of [['storeId', otherStoreId], ['StoreId', otherStoreId]]) {
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = name;
- input.value = value;
- form.appendChild(input);
- }
- form.submit();
- }",
- new { otherStoreId });
await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
Assert.DoesNotContain("/errors/403", s.Page.Url, StringComparison.OrdinalIgnoreCase);
- await GetStoreWalletSettings(otherStoreId);
}
[Fact]
### BTCPayServer.Tests/WalletTests.cs
@@ -152,6 +152,41 @@ public async Task CanImportWallet()
Assert.Contains("There are no transactions yet", await s.Page.Locator("#WalletTransactions").TextContentAsync());
}
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanAddLabelsOnWalletSendPage()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Server.ExplorerNode.GenerateAsync(1);
+ await s.RegisterNewUser(true);
+ await s.CreateNewStore();
+ await s.GenerateWallet(isHotWallet: true);
+
+ await s.GoToUrl($"wallets/{s.WalletId}/send");
+
+ // Regression: app-relative URLs must be resolved by the UrlResolutionTagHelper,
+ // otherwise tom-select 404s and the label manager never initializes
+ var content = await s.Page.ContentAsync();
+ Assert.DoesNotContain("src=\"~/", content);
+ Assert.DoesNotContain("href=\"~/", content);
+
+ // The label manager is initialized by tom-select
+ await s.Page.WaitForSelectorAsync("input.label-manager.tomselected");
+ var hasTomSelect = await s.Page.EvaluateAsync<bool>(
+ "() => !!document.querySelector('input.label-manager').tomselect");
+ Assert.True(hasTomSelect, "TomSelect was not initialized on the label manager");
+
+ // Can add a label?
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ await s.Page.ClickAsync("div.label-manager input");
+ await s.Page.FillAsync("div.label-manager input", "send-label");
+ await s.Page.Keyboard.PressAsync("Enter");
+ await s.Page.WaitForSelectorAsync("[data-value='send-label']");
+ });
+ }
+
[Fact]
[Trait("Playwright", "Playwright-2")]
public async Task CanManageWallet()
### BTCPayServer.Tests/docker-compose.altcoins.yml
@@ -241,7 +241,7 @@ services:
- "postgres_test_datadir:/var/lib/postgresql"
merchant_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@@ -279,7 +279,7 @@ services:
- bitcoind
customer_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
### BTCPayServer.Tests/docker-compose.mutinynet.yml
@@ -179,7 +179,7 @@ services:
- "postgres_test_datadir:/var/lib/postgresql"
merchant_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@@ -217,7 +217,7 @@ services:
- bitcoind
customer_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
### BTCPayServer.Tests/docker-compose.testnet.yml
@@ -171,7 +171,7 @@ services:
- "postgres_test_datadir:/var/lib/postgresql"
merchant_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@@ -209,7 +209,7 @@ services:
- bitcoind
customer_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
### BTCPayServer.Tests/docker-compose.yml
@@ -226,7 +226,7 @@ services:
- "postgres_test_datadir:/var/lib/postgresql"
merchant_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@@ -264,7 +264,7 @@ services:
- bitcoind
customer_lnd:
- image: btcpayserver/lnd:v0.21.0-beta
+ image: btcpayserver/lnd:v0.21.2-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
### BTCPayServer/BTCPayServer.csproj
@@ -34,11 +34,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="NBitcoin" Version="10.0.8" />
+ <PackageReference Include="NBitcoin" Version="10.0.9" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.5" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" />
- <PackageReference Include="BTCPayServer.Lightning.All" Version="1.7.6" />
+ <PackageReference Include="BTCPayServer.Lightning.All" Version="1.7.7" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
@@ -53,14 +53,14 @@
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
- <PackageReference Include="SSH.NET" Version="2025.1.0" />
+ <PackageReference Include="SSH.NET" Version="2026.0.0" />
<PackageReference Include="TwentyTwenty.Storage" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Amazon" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.26.1" />
- <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.10" />
- <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="10.0.10" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.11" />
+ <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
### BTCPayServer/Controllers/GreenField/GreenfieldApiKeysController.cs
@@ -38,7 +38,7 @@ public Task<IActionResult> CreateAPIKey(CreateApiKeyRequest request)
=> CreateUserAPIKey(User.GetId(), request);
[HttpPost("~/api/v1/users/{idOrEmail}/api-keys")]
- [Authorize(Policy = Policies.CanManageUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreateUserAPIKey(string idOrEmail, CreateApiKeyRequest request)
{
request ??= new CreateApiKeyRequest();
@@ -47,6 +47,7 @@ public async Task<IActionResult> CreateUserAPIKey(string idOrEmail, CreateApiKey
var userId = (await userManager.FindByIdOrEmail(idOrEmail))?.Id;
if (userId is null)
return this.UserNotFound();
+
var key = new APIKeyData()
{
Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
### BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs
@@ -342,9 +342,6 @@ private CrowdfundSettings ToCrowdfundSettings(CrowdfundAppRequest request)
NotificationUrl = request.NotificationUrl?.Trim(),
Tagline = request.Tagline?.Trim(),
PerksTemplate = request.PerksTemplate is not null ? AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate.Trim())) : null,
- // If Disqus shortname is not null or empty we assume that Disqus should be enabled
- DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()),
- DisqusShortname = request.DisqusShortname?.Trim(),
// If explicit parameter is not passed for enabling sounds/animations, turn them on if custom sounds/colors are passed
SoundsEnabled = request.SoundsEnabled ?? parsedSounds != null,
AnimationsEnabled = request.AnimationsEnabled ?? parsedColors != null,
@@ -503,8 +500,6 @@ private async Task<CrowdfundAppData> ToCrowdfundModel(AppData appData)
MainImageUrl = settings.MainImageUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), settings.MainImageUrl),
NotificationUrl = settings.NotificationUrl,
Tagline = settings.Tagline,
- DisqusEnabled = settings.DisqusEnabled,
- DisqusShortname = settings.DisqusShortname,
SoundsEnabled = settings.SoundsEnabled,
AnimationsEnabled = settings.AnimationsEnabled,
ResetEveryAmount = settings.ResetEveryAmount,
### BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs
@@ -5,6 +5,7 @@
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
+using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@@ -22,6 +23,7 @@ namespace BTCPayServer.Controllers.Greenfield
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreUsersController : ControllerBase
{
+ private readonly IAuthorizationService _authorizationService;
private readonly StoreRepository _storeRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly CallbackGenerator _callbackGenerator;
@@ -31,11 +33,13 @@ public GreenfieldStoreUsersController(
StoreRepository storeRepository,
UserManager<ApplicationUser> userManager,
CallbackGenerator callbackGenerator,
- UriResolver uriResolver)
+ UriResolver uriResolver,
+ IAuthorizationService authorizationService)
{
_storeRepository = storeRepository;
_userManager = userManager;
_callbackGenerator = callbackGenerator;
+ _authorizationService = authorizationService;
_uriResolver = uriResolver;
}
@@ -70,6 +74,11 @@ public async Task<IActionResult> AddOrUpdateStoreUser(string storeId, StoreUserD
if (user == null)
return UserNotFound();
+ if (await _userManager.IsInRoleAsync(user, Roles.ServerAdmin) &&
+ !(await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded)
+ return this.CreateAPIPermissionError(Policies.CanModifyServerSettings,
+ "Only a server admin can add or update the store membership of a server admin");
+
StoreRoleId roleId = null;
if (request.StoreRole is not null)
{
@@ -104,12 +113,13 @@ public async Task<IActionResult> AddOrUpdateStoreUser(string storeId, StoreUserD
private async Task<IEnumerable<StoreUserData>> ToAPI(StoreData store)
{
var storeUsers = new List<StoreUserData>();
+ var canManageStoreUsers = (await _authorizationService.AuthorizeAsync(User, store.Id, Policies.CanModifyStoreSettings)).Succeeded;
foreach (var storeUser in store.UserStores)
{
var user = await _userManager.FindByIdOrEmail(storeUser.ApplicationUserId);
if (user == null)
continue;
- var data = await UserService.ForAPI<StoreUserData>(user, [], _callbackGenerator, _uriResolver, Request);
+ var data = await UserService.ForAPI<StoreUserData>(user, [], _callbackGenerator, _uriResolver, Request, canManageStoreUsers);
data.StoreRole = storeUser.StoreRoleId;
// Deprecated properties
### BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs
@@ -85,7 +85,8 @@ public async Task<IActionResult> GetUser(string idOrEmail)
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user != null)
{
- return Ok(await ForAPI(user));
+ var canManageUsers = (await _authorizationService.AuthorizeAsync(User, null, Policies.CanManageUsers)).Succeeded;
+ return Ok(await ForAPI(user, canManageUsers));
}
return this.UserNotFound();
}
@@ -130,10 +131,11 @@ public async Task<IActionResult> ApproveUser(string idOrEmail, ApproveUserReques
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
{
var usersWithRoles = await _userService.GetUsersWithRoles();
+ var canManageUsers = (await _authorizationService.AuthorizeAsync(User, null, Policies.CanManageUsers)).Succeeded;
List<ApplicationUserData> users = [];
foreach (var user in usersWithRoles)
{
- users.Add(await UserService.ForAPI<ApplicationUserData>(user.User, user.Roles, _callbackGenerator, _uriResolver, Request));
+ users.Add(await UserService.ForAPI<ApplicationUserData>(user.User, user.Roles, _callbackGenerator, _uriResolver, Request, canManageUsers));
}
return Ok(users);
}
@@ -143,7 +145,7 @@ public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
public async Task<ActionResult<ApplicationUserData>> GetCurrentUser()
{
var user = await _userManager.GetUserAsync(User);
- return await ForAPI(user!);
+ return await ForAPI(user!, true);
}
[Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -161,35 +163,37 @@ public async Task<IActionResult> UpdateCurrentUser(UpdateApplicationUserRequest
bool needUpdate = false;
var setNewPassword = !string.IsNullOrEmpty(request.NewPassword);
- if (setNewPassword)
+ var email = user.Email;
+ var setNewEmail = !string.IsNullOrEmpty(request.Email) && request.Email != email && ModelState.IsValid;
+ var currentPasswordValid = (setNewPassword || setNewEmail)
+ && !string.IsNullOrEmpty(request.CurrentPassword)
+ && await _userManager.CheckPasswordAsync(user, request.CurrentPassword);
+ if ((setNewPassword || setNewEmail) && !currentPasswordValid)
+ {
+ ModelState.AddModelError(nameof(request.CurrentPassword), "The current password is not correct.");
+ }
+
+ if (setNewPassword && currentPasswordValid)
{
- if (!await _userManager.CheckPasswordAsync(user, request.CurrentPassword))
+ var passwordValidation = await _passwordValidator.ValidateAsync(_userManager, user, request.NewPassword);
+ if (passwordValidation.Succeeded)
{
- ModelState.AddModelError(nameof(request.CurrentPassword), "The current password is not correct.");
+ var setUserResult = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
+ if (!setUserResult.Succeeded)
+ {
+ ModelState.AddModelError(nameof(request.Email), "Unexpected error occurred setting password for user.");
+ }
}
else
{
- var passwordValidation = await _passwordValidator.ValidateAsync(_userManager, user, request.NewPassword);
- if (passwordValidation.Succeeded)
- {
- var setUserResult = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
- if (!setUserResult.Succeeded)
- {
- ModelState.AddModelError(nameof(request.Email), "Unexpected error occurred setting password for user.");
- }
- }
- else
+ foreach (var error in passwordValidation.Errors)
{
- foreach (var error in passwordValidation.Errors)
- {
- ModelState.AddModelError(nameof(request.NewPassword), error.Description);
- }
+ ModelState.AddModelError(nameof(request.NewPassword), error.Description);
}
}
}
- var email = user.Email;
- if (!string.IsNullOrEmpty(request.Email) && request.Email != email)
+ if (setNewEmail && currentPasswordValid && ModelState.IsValid)
{
var setUserResult = await _userManager.SetUserNameAsync(user, request.Email);
if (!setUserResult.Succeeded)
@@ -246,7 +250,7 @@ public async Task<IActionResult> UpdateCurrentUser(UpdateApplicationUserRequest
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
- var model = await ForAPI(user);
+ var model = await ForAPI(user, true);
return Ok(model);
}
@@ -278,7 +282,7 @@ public async Task<IActionResult> UploadCurrentUserProfilePicture(IFormFile? file
user.SetBlob(blob);
await _userManager.UpdateAsync(user);
_eventAggregator.Publish(new UserEvent.Updated(user));
- var model = await ForAPI(user);
+ var model = await ForAPI(user, true);
return Ok(model);
}
catch (Exception e)
@@ -418,7 +422,7 @@ public async Task<IActionResult> CreateUser(CreateApplicationUserRequest request
}
}
_eventAggregator.Publish(await UserEvent.Registered.Create(user, await _userManager.GetUserAsync(User), _callbackGenerator, request.SendInvitationEmail is not false));
- var model = await ForAPI(user);
+ var model = await ForAPI(user, true);
return CreatedAtAction(string.Empty, model);
}
@@ -452,10 +456,10 @@ public async Task<IActionResult> DeleteUser(string idOrEmail)
return Ok();
}
- private async Task<ApplicationUserData> ForAPI(ApplicationUser data)
+ private async Task<ApplicationUserData> ForAPI(ApplicationUser data, bool includeInvitationUrl)
{
var roles = (await _userManager.GetRolesAsync(data)).ToArray();
- return await UserService.ForAPI<ApplicationUserData>(data, roles, _callbackGenerator, _uriResolver, Request);
+ return await UserService.ForAPI<ApplicationUserData>(data, roles, _callbackGenerator, _uriResolver, Request, includeInvitationUrl);
}
}
}
### BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -887,6 +887,7 @@ string GetPaymentMethodImage(PaymentMethodId paymentMethodId2)
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
ShowPayInWalletButton = storeBlob.ShowPayInWalletButton,
ShowStoreHeader = storeBlob.ShowStoreHeader,
+ NfcEnabled = storeBlob.NfcEnabled,
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob),
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
CelebratePayment = storeBlob.CelebratePayment,
### BTCPayServer/Controllers/UILNURLAuthController.cs
@@ -117,6 +117,18 @@ public async Task<IActionResult> CreateResponse(string userId, string sig, strin
});
}
+ [HttpGet("register/complete")]
+ public IActionResult CreateComplete()
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ Message = StringLocalizer["The lightning node will now act as a security device for your account"].Value
+ });
+
+ return RedirectToList();
+ }
+
[HttpGet("login-check")]
[AllowAnonymous]
@@ -144,17 +156,8 @@ public async Task<IActionResult> LoginResponse(string userId, string sig, string
}
[NonAction]
- public ActionResult RedirectToList(string successMessage = null)
+ public ActionResult RedirectToList()
{
- if (successMessage != null)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Html = successMessage
- });
- }
-
return RedirectToAction("TwoFactorAuthentication", "UIManage");
}
}
### BTCPayServer/Controllers/UIPullPaymentController.cs
@@ -176,9 +176,7 @@ public async Task<IActionResult> ClaimPullPayment(string pullPaymentId, ViewPull
await using var ctx = dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(pullPaymentId);
if (pp is null)
- {
- ModelState.AddModelError(nameof(pullPaymentId), StringLocalizer["This pull payment does not exists"]);
- }
+ return NotFound();
if (string.IsNullOrEmpty(vm.Destination))
{
### BTCPayServer/Controllers/UIServerController.Roles.cs
@@ -169,6 +169,7 @@ public class UpdateRoleViewModel
{
[Required]
[Display(Name = "Role")]
+ [FromForm]
public string Role { get; set; }
[Display(Name = "Permissions")] public HashSet<string> Permissions { get; set; } = new();
### BTCPayServer/Controllers/UIStoresController.Roles.cs
@@ -1,3 +1,4 @@
+using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -122,9 +123,9 @@ public async Task<IActionResult> DeleteRole(
[FromServices] StoreRepository storeRepository,
string role)
{
- var roleId = await storeRepository.ResolveStoreRoleId(storeId, role);
- if (roleId == null)
+ if (string.IsNullOrWhiteSpace(role) || role.Contains("::", StringComparison.Ordinal))
return NotFound();
+ var roleId = new StoreRoleId(storeId, role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
if (roleData == null)
@@ -146,9 +147,9 @@ public async Task<IActionResult> DeleteRolePost(
[FromServices] StoreRepository storeRepository,
string role)
{
- var roleId = await storeRepository.ResolveStoreRoleId(storeId, role);
- if (roleId == null)
+ if (string.IsNullOrWhiteSpace(role) || role.Contains("::", StringComparison.Ordinal))
return NotFound();
+ var roleId = new StoreRoleId(storeId, role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
if (roleData == null)
### BTCPayServer/Controllers/UIStoresController.Settings.cs
@@ -240,6 +240,7 @@ public async Task<IActionResult> CheckoutAppearance()
vm.CelebratePayment = storeBlob.CelebratePayment;
vm.PlaySoundOnPayment = storeBlob.PlaySoundOnPayment;
+ vm.NfcEnabled = storeBlob.NfcEnabled;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.ShowPayInWalletButton = storeBlob.ShowPayInWalletButton;
vm.ShowStoreHeader = storeBlob.ShowStoreHeader;
@@ -377,6 +378,7 @@ public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel
blob.ShowStoreHeader = model.ShowStoreHeader;
blob.CelebratePayment = model.CelebratePayment;
blob.PlaySoundOnPayment = model.PlaySoundOnPayment;
+ blob.NfcEnabled = model.NfcEnabled;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
blob.LazyPaymentMethods = model.LazyPaymentMethods;
### BTCPayServer/Controllers/UIStoresController.Users.cs
@@ -96,6 +96,12 @@ public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel
return View(vm);
}
+ if (await _userManager.IsInRoleAsync(user, Roles.ServerAdmin) && !await IsServerAdmin())
+ {
+ ModelState.AddModelError(nameof(vm.Email), StringLocalizer["Only a server admin can add or update the store membership of a server admin"]);
+ return View(vm);
+ }
+
var res = await _storeRepo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId);
if (res is AddOrUpdateStoreUserResult.Success)
{
@@ -135,6 +141,9 @@ public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel
private async Task<bool> IsAdmin()
=> (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser))).Succeeded;
+ private async Task<bool> IsServerAdmin()
+ => (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
+
[HttpPost("{storeId}/users/{userId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateStoreUser(string storeId, string userId, StoreUsersViewModel.StoreUserViewModel vm)
@@ -143,6 +152,13 @@ public async Task<IActionResult> UpdateStoreUser(string storeId, string userId,
var storeUsers = await _storeRepo.GetStoreUsers(storeId);
var user = storeUsers.First(user => user.Id == userId);
+ var applicationUser = await _userManager.FindByIdAsync(userId);
+ if (applicationUser is not null && await _userManager.IsInRoleAsync(applicationUser, Roles.ServerAdmin) && !await IsServerAdmin())
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Only a server admin can add or update the store membership of a server admin."].Value;
+ return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
+ }
+
var res = await _storeRepo.AddOrUpdateStoreUser(storeId, userId, roleId);
if (res is AddOrUpdateStoreUserResult.Success)
{
### BTCPayServer/Data/StoreBlob.cs
@@ -242,6 +242,10 @@ public string LightningDescriptionTemplate
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool PlaySoundOnPayment { get; set; }
+ [DefaultValue(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
+ public bool NfcEnabled { get; set; }
+
[JsonConverter(typeof(UnresolvedUriJsonConverter))]
public UnresolvedUri PaymentSoundUrl { get; set; }
### BTCPayServer/HostedServices/GithubVersionFetcher.cs
@@ -62,18 +62,7 @@ public virtual async Task<string> Fetch(CancellationToken cancellation)
var isReleaseVersionTag = _releaseVersionTag.IsMatch(tag);
if (isReleaseVersionTag)
- {
return tag.TrimStart('v');
- }
- else
- {
- return null;
- }
- }
- else
- {
- _logger.LogWarning($"Unsuccessful status code returned during new version check. " +
- $"Url: {_updateurl}, HTTP Code: {resp.StatusCode}, Response Body: {strResp}");
}
return null;
### BTCPayServer/HostedServices/PendingTransactionService.cs
@@ -197,9 +197,27 @@ public async Task<PendingTransaction> CreatePendingTransaction(string storeId, s
return (pendingTransaction, false);
}
+ var originalPsbt = PSBT.Parse(blob.PSBT, network);
+ if (originalPsbt.Inputs.Any(i => i.GetCoin() is null))
+ {
+ logger.LogWarning(
+ "Rejected PSBT for pending transaction {PendingTransactionId}: the original PSBT lacks UTXO data",
+ pendingTransaction.Id);
+ return (pendingTransaction, false);
+ }
+
var mergedPsbt = BuildEffectivePsbt(blob, network);
var beforeProgress = GetSignatureProgress(mergedPsbt);
mergedPsbt.Combine(psbt);
+
+ if (!HasValidFinalScripts(mergedPsbt))
+ {
+ logger.LogWarning(
+ "Rejected PSBT for pending transaction {PendingTransactionId}: it carries final scripts that do not satisfy the spent coins",
+ pendingTransaction.Id);
+ return (pendingTransaction, false);
+ }
+
var afterProgress = GetSignatureProgress(mergedPsbt);
var meaningfulDelta = HasMeaningfulDelta(beforeProgress, afterProgress);
@@ -377,6 +395,20 @@ private bool TryRefreshStoredProgress(PendingTransaction pendingTransaction)
return true;
}
+ internal static bool HasValidFinalScripts(PSBT psbt)
+ {
+ if (!psbt.Inputs.Any(i => i.IsFinalized()))
+ return true;
+
+ if (psbt.Inputs.Any(i => i.GetCoin() is null))
+ return false;
+
+ var precomputed = psbt.PrecomputeTransactionData();
+ return psbt.Inputs
+ .Where(i => i.IsFinalized())
+ .All(i => i.VerifyScript(ScriptVerify.Standard, precomputed, out _));
+ }
+
private static PSBT BuildEffectivePsbt(PendingTransactionBlob blob, Network network)
{
var effectivePsbt = PSBT.Parse(blob.PSBT, network);
### BTCPayServer/HostedServices/PullPaymentHostedService.cs
@@ -7,6 +7,7 @@
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
+using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
@@ -561,7 +562,7 @@ private async Task HandleApproval(PayoutApproval req)
}
payout.Amount = Extensions.RoundUp(cryptoAmount,
- network.Divisibility);
+ payoutHandler is LightningLikePayoutHandler ? network.Divisibility + 3 : network.Divisibility);
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Approved, payout));
### BTCPayServer/Hosting/Startup.cs
@@ -23,6 +23,7 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
@@ -161,6 +162,9 @@ public void ConfigureServices(IServiceCollection services)
o.Filters.Add(new XXSSProtectionAttribute());
o.Filters.Add(new ReferrerPolicyAttribute("same-origin"));
o.ModelBinderProviders.Insert(0, new ModelBinders.DefaultModelBinderProvider());
+ var routeProvider = o.ValueProviderFactories.OfType<RouteValueProviderFactory>().Single();
+ o.ValueProviderFactories.Remove(routeProvider);
+ o.ValueProviderFactories.Insert(0, routeProvider);
if (!Configuration.GetOrDefault<bool>("nocsp", false))
o.Filters.Add(new ContentSecurityPolicyAttribute(CSPTemplate.AntiXSS));
o.Filters.Add(new JsonHttpExceptionFilter());
### BTCPayServer/Models/InvoicingModels/CheckoutModel.cs
@@ -33,6 +33,7 @@ public class AvailablePaymentMethod
public string DefaultLang { get; set; }
public bool ShowPayInWalletButton { get; set; }
public bool ShowStoreHeader { get; set; }
+ public bool NfcEnabled { get; set; }
public List<AvailablePaymentMethod> AvailablePaymentMethods { get; set; } = new();
public bool IsModal { get; set; }
public bool IsUnsetTopUp { get; set; }
### BTCPayServer/Models/StoreViewModels/CheckoutAppearanceViewModel.cs
@@ -44,6 +44,9 @@ public void SetLanguages(LanguageService langService, string defaultLang)
[Display(Name = "Enable sounds on checkout page")]
public bool PlaySoundOnPayment { get; set; }
+ [Display(Name = "Enable NFC payments on checkout (contactless cards and LNURL-withdraw)")]
+ public bool NfcEnabled { get; set; }
+
[Display(Name = "Only enable the payment method after user explicitly chooses it")]
public bool LazyPaymentMethods { get; set; }
### BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs
@@ -285,6 +285,17 @@ public Task ValidatePaymentMethodConfig(PaymentMethodConfigValidationContext val
if (acc.AccountKey is null)
validationContext.ModelState.AddModelError(nameof(res.AccountKeySettings), "Missing AccountKey");
}
+
+ if (res.IsHotWallet)
+ {
+ var previous = validationContext.PreviousConfig?.ToObject<DerivationSchemeSettings>(Serializer);
+ if (previous?.IsHotWallet is not true ||
+ previous.AccountDerivation?.ToString() != res.AccountDerivation?.ToString())
+ {
+ validationContext.ModelState.AddModelError(nameof(res.IsHotWallet),
+ "IsHotWallet cannot be set through this API. Generate or import a wallet with private keys instead.");
+ }
+ }
return Task.CompletedTask;
}
### BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs
@@ -259,6 +259,15 @@ ObjectResult CreatePayjoinErrorAndLog(int httpCode, PayjoinReceiverWellknownErro
WalletId? walletId = null;
foreach (var output in psbt.Outputs)
{
+ due = null;
+ originalPaymentOutput = null;
+ paymentAddress = null;
+ paymentAddressIndex = null;
+ invoice = null;
+ accountDerivation = null;
+ walletId = null;
+ ctx.Invoice = null;
+
var walletReceiveMatch =
_walletReceiveService.GetByScriptPubKey(network.CryptoCode, output.ScriptPubKey);
if (walletReceiveMatch is null)
@@ -372,14 +381,22 @@ accountDerivation is null ||
if (due is null || due > Money.Zero)
return InvoiceNotFullyPaid();
+ var receiverStore = await _storeRepository.FindStore(walletId.StoreId);
+ var receiverSettings = receiverStore?.GetDerivationSchemeSettings(_handlers, walletId.CryptoCode);
+ if (receiverSettings?.IsHotWallet is not true ||
+ receiverSettings.AccountDerivation?.ToString() != accountDerivation.ToString())
+ {
+ return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable,
+ "The receiving store's wallet is not a hot wallet");
+ }
+
if (selectedUTXOs.Count == 0)
{
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for contributing to a payjoin");
}
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), ctx.OriginalTransaction, network);
- //check if wallet of store is configured to be hot wallet
var extKeyStr = await explorer.GetMetadataAsync<string>(
accountDerivation,
WellknownMetadataKeys.AccountHDKey);
### BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs
@@ -28,6 +28,8 @@ namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<LightningAutomatedPayoutBlob>
{
+ public const double MaxRoutingFeePercent = 3.0;
+
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly IOptions<LightningNetworkOptions> _options;
@@ -229,7 +231,8 @@ async Task<ResultVM> TrypayBolt(
var pay = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
- Amount = new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC)
+ Amount = new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC),
+ MaxFeePercent = MaxRoutingFeePercent
}, cancellationToken);
if (pay is { Result: PayResult.CouldNotFindRoute })
{
### BTCPayServer/Plugins/Bitpay/Controllers/BitpayInvoiceController.cs
@@ -18,6 +18,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitpayClient;
+using NicolasDorier.RateLimits;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Plugins.Bitpay.Controllers;
@@ -31,16 +32,19 @@ public class BitpayInvoiceController : ControllerBase
private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _InvoiceRepository;
+ private readonly IRateLimitService _rateLimitService;
public BitpayInvoiceController(UIInvoiceController invoiceController,
Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
CurrencyNameTable currencyNameTable,
- InvoiceRepository invoiceRepository)
+ InvoiceRepository invoiceRepository,
+ IRateLimitService rateLimitService)
{
_InvoiceController = invoiceController;
_bitpayExtensions = bitpayExtensions;
_currencyNameTable = currencyNameTable;
_InvoiceRepository = invoiceRepository;
+ _rateLimitService = rateLimitService;
}
[HttpPost]
@@ -53,6 +57,10 @@ public async Task<DataWrapper<InvoiceResponse>> CreateInvoice([FromBody] BitpayC
var store = HttpContext.GetStoreDataOrNull();
if (store == null)
throw new BitpayHttpException(404, "Store not found");
+ if (User.Identity?.AuthenticationType == Security.BitpayAuthenticationTypes.Anonymous &&
+ HttpContext.Connection.RemoteIpAddress is { } addr &&
+ !await _rateLimitService.Throttle(ZoneLimits.PublicInvoices, addr.ToString(), cancellationToken))
+ throw new BitpayHttpException(429, "Too many requests");
return await CreateInvoiceCore(invoice, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
}
### BTCPayServer/Plugins/Bitpay/Controllers/UIStoresTokenController.cs
@@ -33,7 +33,8 @@ public class UIStoresTokenController(
StoreRepository storeRepository,
IHtmlHelper html,
PaymentMethodHandlerDictionary handlers,
- PermissionService permissionService) : Controller
+ PermissionService permissionService,
+ IAuthorizationService authorizationService) : Controller
{
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
public StoreData CurrentStore => HttpContext.GetStoreDataOrNull() ?? throw new InvalidOperationException("Store not found");
@@ -56,8 +57,17 @@ public async Task<IActionResult> ListTokens()
Id = t.Value
}).ToArray();
- model.ApiKey = (await tokenRepository.GetLegacyAPIKeys(CurrentStore.Id)).FirstOrDefault();
- model.EncodedApiKey = model.ApiKey == null ? "*API Key*" : Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey));
+ var userId = GetUserId();
+ var canModify = userId != null && (await authorizationService.AuthorizeAsync(User, CurrentStore.Id, Policies.CanModifyStoreSettings)).Succeeded;
+ if (canModify)
+ {
+ model.ApiKey = (await tokenRepository.GetLegacyAPIKeys(CurrentStore.Id)).FirstOrDefault();
+ model.EncodedApiKey = model.ApiKey == null ? "*API Key*" : Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey));
+ }
+ else
+ {
+ model.EncodedApiKey = "*API Key*";
+ }
return View(model);
}
@@ -125,6 +135,10 @@ public async Task<IActionResult> CreateToken(string storeId, CreateTokenViewMode
};
if (store == null)
return Challenge(AuthenticationSchemes.Cookie);
+
+ if (!(await authorizationService.AuthorizeAsync(User, store.Id, Policies.CanModifyStoreSettings)).Succeeded)
+ return Challenge(AuthenticationSchemes.Cookie);
+
var tokenRequest = new TokenRequest()
{
Label = model.Label,
### BTCPayServer/Plugins/Bitpay/Views/ListTokens.cshtml
@@ -83,7 +83,7 @@
<h3 class="settings-section__heading settings-section__heading--spaced" text-translate="true">Legacy API Keys</h3>
<div class="settings-section">
<p text-translate="true">Alternatively, you can use the invoice API by including the following HTTP Header in your requests:</p>
- <p><code>Authorization: Basic @Model.EncodedApiKey</code></p>
+ <p permission="@Policies.CanModifyStoreSettings"><code>Authorization: Basic @Model.EncodedApiKey</code></p>
<form method="post" asp-action="GenerateAPIKey" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings">
<div class="form-group mb-0">
### BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs
@@ -390,9 +390,7 @@ public async Task<IActionResult> UpdateCrowdfund(string appId)
NotificationUrl = settings.NotificationUrl,
Tagline = settings.Tagline,
PerksTemplate = settings.PerksTemplate,
- DisqusEnabled = settings.DisqusEnabled,
SoundsEnabled = settings.SoundsEnabled,
- DisqusShortname = settings.DisqusShortname,
AnimationsEnabled = settings.AnimationsEnabled,
ResetEveryAmount = settings.ResetEveryAmount,
ResetEvery = resetEvery,
@@ -521,9 +519,7 @@ public async Task<IActionResult> UpdateCrowdfund(string appId, UpdateCrowdfundVi
NotificationUrl = vm.NotificationUrl,
Tagline = vm.Tagline,
PerksTemplate = vm.PerksTemplate,
- DisqusEnabled = vm.DisqusEnabled,
SoundsEnabled = vm.SoundsEnabled,
- DisqusShortname = vm.DisqusShortname,
AnimationsEnabled = vm.AnimationsEnabled,
ResetEveryAmount = vm.ResetEveryAmount,
ResetEvery = Enum.Parse<CrowdfundResetEvery>(vm.ResetEvery),
### BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs
@@ -15,6 +15,7 @@
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
+using Ganss.Xss;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
@@ -50,6 +51,7 @@ public class CrowdfundAppType : AppBaseType, IHasSaleStatsAppType, IHasItemStats
private readonly InvoiceRepository _invoiceRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly PrettyNameProvider _prettyNameProvider;
+ private readonly HtmlSanitizer _htmlSanitizer;
public const string AppType = "Crowdfund";
public CrowdfundAppType(
@@ -60,7 +62,8 @@ public CrowdfundAppType(
PrettyNameProvider prettyNameProvider,
DisplayFormatter displayFormatter,
IHttpContextAccessor httpContextAccessor,
- CurrencyNameTable currencyNameTable)
+ CurrencyNameTable currencyNameTable,
+ HtmlSanitizer htmlSanitizer)
{
Description = Type = AppType;
_linkGenerator = linkGenerator;
@@ -71,6 +74,7 @@ public CrowdfundAppType(
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
_prettyNameProvider = prettyNameProvider;
+ _htmlSanitizer = htmlSanitizer;
}
public override Task<string> ConfigureLink(AppData app)
@@ -185,13 +189,15 @@ public Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData, InvoiceEnti
? _linkGenerator.GetPathByAction(nameof(UICrowdfundController.CrowdfundForm), "UICrowdfund",
new { area = CrowdfundPlugin.Area, appId = appData.Id }, _options.Value.RootPath)
: null;
+ foreach (var perk in perks)
+ perk.Description = _htmlSanitizer.Sanitize(perk.Description ?? "");
var vm = new ViewCrowdfundViewModel
{
Title = settings.Title,
Tagline = settings.Tagline,
HtmlLang = settings.HtmlLang,
HtmlMetaTags= settings.HtmlMetaTags,
- Description = settings.Description,
+ Description = _htmlSanitizer.Sanitize(settings.Description ?? ""),
StoreName = store.StoreName,
StoreId = appData.StoreDataId,
AppId = appData.Id,
@@ -202,9 +208,7 @@ public Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData, InvoiceEnti
EnforceTargetAmount = settings.EnforceTargetAmount,
Perks = perks,
Enabled = settings.Enabled,
- DisqusEnabled = settings.DisqusEnabled,
SoundsEnabled = settings.SoundsEnabled,
- DisqusShortname = settings.DisqusShortname,
AnimationsEnabled = settings.AnimationsEnabled,
ResetEveryAmount = settings.ResetEveryAmount,
ResetEvery = Enum.GetName(typeof(Services.Apps.CrowdfundResetEvery), settings.ResetEvery),
### BTCPayServer/Plugins/Crowdfund/Models/UpdateCrowdfundViewModel.cs
@@ -61,13 +61,6 @@ public class UpdateCrowdfundViewModel
[Display(Name = "Enable sounds on new payments")]
public bool SoundsEnabled { get; set; }
- [Required]
- [Display(Name = "Enable Disqus Comments")]
- public bool DisqusEnabled { get; set; }
-
- [Display(Name = "Disqus Shortname")]
- public string DisqusShortname { get; set; }
-
[Display(Name = "Start date")]
public DateTime? StartDate { get; set; }
### BTCPayServer/Plugins/Crowdfund/Models/ViewCrowdfundViewModel.cs
@@ -30,9 +30,7 @@ public class ViewCrowdfundViewModel
public StoreBrandingViewModel StoreBranding { get; set; }
public AppItem[] Perks { get; set; }
public bool SimpleDisplay { get; set; }
- public bool DisqusEnabled { get; set; }
public bool SoundsEnabled { get; set; }
- public string DisqusShortname { get; set; }
public bool AnimationsEnabled { get; set; }
public string[] AnimationColors { get; set; }
public string[] Sounds { get; set; }
### BTCPayServer/Plugins/Crowdfund/Views/Public/ViewCrowdfund.cshtml
@@ -7,11 +7,6 @@
ViewData["StoreBranding"] = Model.StoreBranding;
Layout = null;
Csp.UnsafeEval();
- if (!string.IsNullOrEmpty(Model.DisqusShortname))
- {
- Csp.Add("script-src", $"https://{Model.DisqusShortname}.disqus.com");
- Csp.Add("script-src", "https://c.disquscdn.com");
- }
}
<!DOCTYPE html>
<html lang="@Model.HtmlLang" class="h-100" @(Env.IsDeveloping ? " data-devenv" : "") id="Crowdfund-@Model.AppId">
@@ -213,21 +208,7 @@
<div class="row mt-4 justify-content-between gap-5">
<div :class="{ 'col-lg-7 col-sm-12': hasPerks, 'col-12': !hasPerks }" id="crowdfund-body-description-container">
- <template v-if="srvModel.disqusEnabled && srvModel.disqusShortname">
- <b-tabs>
- <b-tab title="Details" active>
- <div class="overflow-hidden pt-3" v-html="srvModel.description" id="crowdfund-body-description">
- </div>
- </b-tab>
- <b-tab title="Discussion">
- <div id="disqus_thread" class="mt-4"></div>
- </b-tab>
- </b-tabs>
- </template>
- <template v-else>
- <div class="overflow-hidden" v-html="srvModel.description" id="crowdfund-body-description">
- </div>
- </template>
+ <div class="overflow-hidden" id="crowdfund-body-description" v-pre>@Safe.Raw(Model.Description)</div>
</div>
<div class="col-lg-4 col-sm-12" id="crowdfund-body-contribution-container" v-if="hasPerks">
<contribute :target-currency="srvModel.targetCurrency"
### BTCPayServer/Plugins/Crowdfund/Views/UpdateCrowdfund.cshtml
@@ -320,32 +320,6 @@ Please insert valid HTML here. Only meta tags accepted.'>
</div>
</div>
</div>
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-discussion-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-discussion" aria-expanded="false" aria-controls="additional-discussion">
- <span text-translate="true">Discussion</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-discussion" class="accordion-collapse collapse" aria-labelledby="additional-discussion-header">
- <div class="accordion-body">
- <div class="form-group mb-0">
- <div class="d-flex align-items-center">
- <input asp-for="DisqusEnabled" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#DisqusEnabledSettings" aria-expanded="@Model.DisqusEnabled" aria-controls="DisqusEnabledSettings"/>
- <label asp-for="DisqusEnabled" class="form-check-label"></label>
- <span asp-validation-for="DisqusEnabled" class="text-danger"></span>
- </div>
- </div>
- <div class="collapse @(Model.DisqusEnabled ? "show" : "")" id="DisqusEnabledSettings">
- <div class="form-group mb-0 pt-3">
- <label asp-for="DisqusShortname" class="form-label"></label>
- <input asp-for="DisqusShortname" class="form-control" />
- <span asp-validation-for="DisqusShortname" class="text-danger"></span>
- </div>
- </div>
- </div>
- </div>
- </div>
<div class="accordion-item">
<h2 class="accordion-header" id="additional-notification-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-notification" aria-expanded="false" aria-controls="additional-notification">
### BTCPayServer/Plugins/Forms/UIFormsController.cs
@@ -18,6 +18,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
+using NicolasDorier.RateLimits;
namespace BTCPayServer.Forms;
@@ -172,6 +173,7 @@ async Task<ViewResult> GetFormView(FormData formData, Form? form = null)
[AllowAnonymous]
[HttpPost("~/forms/{formId}")]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
+ [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> SubmitForm(string formId)
{
var formData = await formDataService.GetForm(formId);
### BTCPayServer/Plugins/Multisig/Controllers/UIMultisigWalletsController.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using BTCPayServer.Plugins.Multisig.Models;
@@ -27,6 +28,7 @@ namespace BTCPayServer.Plugins.Multisig.Controllers;
[Area(MultisigPlugin.Area)]
public class UIMultisigWalletsController(
StoreRepository storeRepository,
+ IAuthorizationService authorizationService,
ExplorerClientProvider explorerProvider,
BTCPayWalletProvider walletProvider,
MultisigService multisigService,
@@ -39,6 +41,14 @@ public class UIMultisigWalletsController(
private static bool IsSupportedCryptoCode(string? cryptoCode) =>
string.Equals(cryptoCode, "BTC", StringComparison.OrdinalIgnoreCase);
+ private async Task<bool> CanSetupMultisigWallet(StoreData store, string cryptoCode)
+ {
+ if (!multisigService.HasOnChainWallet(store, cryptoCode))
+ return true;
+
+ return (await authorizationService.AuthorizeAsync(User, store.Id, Policies.CanModifyStoreSettings)).Succeeded;
+ }
+
[HttpGet("{storeId}/onchain/{cryptoCode}/import/multisig")]
public async Task<IActionResult> SetupMultisig(string storeId, string cryptoCode)
{
@@ -49,6 +59,9 @@ public async Task<IActionResult> SetupMultisig(string storeId, string cryptoCode
if (!IsSupportedCryptoCode(vm.CryptoCode))
return NotFound();
+ if (!await CanSetupMultisigWallet(HttpContext.GetStoreData(), vm.CryptoCode))
+ return Forbid();
+
await multisigService.PopulateSetupViewModel(vm);
return View(vm);
}
@@ -63,6 +76,9 @@ public async Task<IActionResult> SetupMultisig(string storeId, string cryptoCode
if (!IsSupportedCryptoCode(vm.CryptoCode))
return NotFound();
+ if (!await CanSetupMultisigWallet(store, vm.CryptoCode))
+ return Forbid();
+
var selectedIds = (vm.MultisigParticipantUserIds ?? Array.Empty<string>())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
@@ -148,6 +164,9 @@ public async Task<IActionResult> FinalizeMultisigSetup(string multisigSetupId, M
if (pending is null)
return NotFound();
+ if (!await CanSetupMultisigWallet(store, pending.CryptoCode))
+ return Forbid();
+
vm.StoreId = pending.StoreId;
vm.CryptoCode = pending.CryptoCode;
vm.MultisigRequestId = pending.RequestId;
### BTCPayServer/Plugins/NFC/NFCController.cs
@@ -3,6 +3,7 @@
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
@@ -12,6 +13,7 @@
using LNURL;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
using NBitcoin;
using Newtonsoft.Json.Linq;
@@ -24,16 +26,19 @@ public class NFCController : Controller
private readonly InvoiceRepository _invoiceRepository;
private readonly InvoiceActivator _invoiceActivator;
private readonly StoreRepository _storeRepository;
+ private readonly ILogger<NFCController> _logger;
public NFCController(IHttpClientFactory httpClientFactory,
InvoiceRepository invoiceRepository,
InvoiceActivator invoiceActivator,
- StoreRepository storeRepository)
+ StoreRepository storeRepository,
+ ILogger<NFCController> logger)
{
_httpClientFactory = httpClientFactory;
_invoiceRepository = invoiceRepository;
_invoiceActivator = invoiceActivator;
_storeRepository = storeRepository;
+ _logger = logger;
}
public class SubmitRequest
@@ -45,6 +50,7 @@ public class SubmitRequest
[AllowAnonymous]
[IgnoreAntiforgeryToken]
+ [HttpPost]
public async Task<IActionResult> SubmitLNURLWithdrawForInvoice([FromBody] SubmitRequest request)
{
var invoice = await _invoiceRepository.GetInvoice(request.InvoiceId);
@@ -53,6 +59,12 @@ public async Task<IActionResult> SubmitLNURLWithdrawForInvoice([FromBody] Submit
return NotFound();
}
+ var store = await _storeRepository.FindStore(invoice.StoreId);
+ if (store?.GetStoreBlob().NfcEnabled is not true)
+ {
+ return NotFound();
+ }
+
var methods = invoice.GetPaymentPrompts();
PaymentPrompt lnPaymentMethod = null;
if (!methods.TryGetValue(PaymentTypes.LNURL.GetPaymentMethodId("BTC"), out var lnurlPaymentMethod) &&
@@ -87,6 +99,10 @@ public async Task<IActionResult> SubmitLNURLWithdrawForInvoice([FromBody] Submit
{
info = await LNURL.LNURL.FetchInformation(uri, tag, httpClient) as LNURLWithdrawRequest;
}
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
+ {
+ return BadRequest("Could not fetch info from LNURL-Withdraw");
+ }
catch (Exception ex)
{
var details = ex.InnerException?.Message ?? ex.Message;
@@ -189,6 +205,10 @@ public async Task<IActionResult> SubmitLNURLWithdrawForInvoice([FromBody] Submit
return BadRequest(result.Reason ?? "Unknown error");
}
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
+ {
+ return BadRequest("Could not complete the LNURL-Withdraw request");
+ }
catch (Exception ex)
{
return BadRequest(ex.Message);
### BTCPayServer/Plugins/NFC/Views/CheckoutEnd.cshtml
@@ -34,7 +34,7 @@ Vue.component("lnurl-withdraw-checkout", {
(activePaymentMethodId === 'BTC-CHAIN' && isUnified && lnurlwAvailable) ||
// Lightning with LNURL available
(activePaymentMethodId === 'BTC-LN' && lnurlwAvailable))
- return isAvailable && (this.nfcSupported || this.testFallback)
+ return isAvailable && this.model.nfcEnabled && (this.nfcSupported || this.testFallback)
},
testFallback () {
return !this.nfcSupported && window.location.search.match('lnurlwtest=(1|true)')
### BTCPayServer/Plugins/PayButton/Controllers/UIPublicPayButtonController.cs
@@ -34,6 +34,7 @@ public class UIPublicPayButtonController(
[IgnoreAntiforgeryToken]
[EnableCors(CorsPolicies.All)]
[Route("api/v1/invoices")]
+ [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> PayButtonHandle(PayButtonViewModel model)
{
return await PayButtonHandle(model, CancellationToken.None);
### BTCPayServer/Plugins/PluginManager.cs
@@ -227,6 +227,12 @@ public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollec
logger.LogInformation($"Skipping disabled plugin {pluginIdentifier}");
continue;
}
+ if (IsIncompatible(pluginIdentifier, TryReadPluginVersion(directory, pluginIdentifier)))
+ {
+ logger.LogWarning($"Refusing to load the plugin {pluginIdentifier}, this version cannot be run. It has been disabled and will stay disabled until it is updated to a newer version.");
+ ExecuteCommand(("disable", pluginIdentifier), pluginsFolder);
+ continue;
+ }
pluginsToPreload.Add((pluginIdentifier, pluginFilePath));
}
@@ -586,6 +592,47 @@ public static void DisablePlugin(string pluginDir, string plugin)
}
+ internal static Version? TryReadPluginVersion(string pluginDirectory, string pluginIdentifier)
+ {
+ var manifestFileName = Path.Join(pluginDirectory, pluginIdentifier + ".json");
+ if (!File.Exists(manifestFileName))
+ return null;
+ try
+ {
+ return JObject.Parse(File.ReadAllText(manifestFileName))
+ .ToObject<PluginService.AvailablePlugin>()?.Version;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static readonly Dictionary<string, Version> IncompatiblePlugins =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "BTCPayServer.Plugins.ShopifyPlugin", new Version(1, 1, 5) },
+ { "BTCPayServer.Plugins.Ecwid", new Version(1, 1, 0) },
+ { "SamRockProtocol", new Version(1, 1, 0) },
+ { "BTCPayServer.Plugins.Stripe", new Version(1, 0, 12) },
+ { "BTCPayServer.Plugins.BigCommercePlugin", new Version(1, 0, 7) },
+ { "BTCPayServer.RockstarDev.Plugins.MarkPaidCheckout", new Version(0, 1, 2) },
+ { "BTCPayServer.Plugins.ArkPayServer", new Version(2, 4, 2) },
+ { "BTCPayServer.Plugins.Cashu", new Version(1, 0, 4) }
+ };
+
+ internal static bool IsIncompatible(string? pluginIdentifier, Version? version)
+ {
+ if (pluginIdentifier is null || !IncompatiblePlugins.TryGetValue(pluginIdentifier, out var lastAffected))
+ return false;
+ if (version is null)
+ return true;
+ return TruncateToBuild(version) <= TruncateToBuild(lastAffected);
+ }
+
+ private static Version TruncateToBuild(Version version)
+ => new(version.Major, version.Minor, Math.Max(version.Build, 0));
+
// Loads the list of disabled plugins from the file
private static HashSet<string> GetDisabledPluginIdentifiers(string pluginsFolder)
{
### BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -273,6 +273,7 @@ IActionResult Error(string message)
foreach (var cartItem in jposData.Cart)
{
cartItem.Count = Math.Max(1, cartItem.Count);
+ cartItem.Price = Math.Max(0, cartItem.Price);
}
if (jposData.Cart.Any(cartItem => string.IsNullOrEmpty(cartItem.Id)))
return NotFound();
### BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
@@ -13,7 +13,7 @@
}
@section PageHeadContent {
- <link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true">
+ <link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true" />
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
}
### BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -48,7 +48,7 @@ EmailTriggerViewModels emailTriggers
) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
{
[HttpPost("stores/{storeId}/offerings/{offeringId}/new-subscriber")]
- [Authorize(Policy = SubscriptionsPolicies.CanModifyOfferings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ [Authorize(Policy = SubscriptionsPolicies.CanManageSubscribers, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewSubscriber(
string storeId, string offeringId,
string planId,
@@ -84,6 +84,7 @@ public async Task<IActionResult> NewSubscriber(
}
[HttpGet("stores/{storeId}/offerings")]
+ [Authorize(Policy = SubscriptionsPolicies.CanModifyOfferings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult CreateOffering(string storeId)
{
return View();
@@ -239,6 +240,7 @@ private RedirectToActionResult GoToOffering(string storeId, string offeringId, S
=> RedirectToAction(nameof(Offering), new { storeId, offeringId, section = section });
[HttpPost("stores/{storeId}/offerings")]
+ [Authorize(Policy = SubscriptionsPolicies.CanModifyOfferings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreateOffering(string storeId, CreateOfferingViewModel vm, string? command = null)
{
if (env.CheatMode && command == "create-fake")
@@ -274,6 +276,7 @@ public static string CreateOfferingCondition(string offeringId, SubscriberData.P
static string Predicate(string condition) => $"$ ?({condition})";
[HttpPost("stores/{storeId}/offerings/{offeringId}/Mails")]
+ [Authorize(Policy = SubscriptionsPolicies.CanModifyOfferings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> SaveMailSettings(string storeId, string offeringId, SubscriptionsViewModel vm, string? addEmailRule = null)
{
await using var ctx = DbContextFactory.CreateContext();
@@ -455,6 +458,7 @@ public async Task<IActionResult> ConfigureOffering(string storeId, string offeri
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/configure")]
+ [Authorize(Policy = SubscriptionsPolicies.CanModifyOfferings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfigureOffering(
string storeId,
string offeringId,
@@ -643,7 +647,7 @@ public async Task<IActionResult> AddPlan(string storeId, string offeringId, AddE
plan.OptimisticActivation = vm.OptimisticActivation;
plan.Renewable = vm.Renewable;
plan.RecurringType = vm.RecurringType;
- plan.OfferingId = vm.OfferingId;
+ plan.OfferingId = offering.Id;
plan.PlanChanges ??= new();
foreach (var vmPC in vm.PlanChanges)
### BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
@@ -153,16 +153,16 @@ public async Task<IActionResult> SubscriberPortal(string portalSessionId, string
return View(vm);
}
- Regex invoiceIdRegex = new(@"\(Inv: ([^\)]*)\)", RegexOptions.Compiled);
+ private static readonly Regex _invoiceIdRegex = new(@"\(Inv: ([^\)]*)\)", RegexOptions.Compiled);
private string AddInvoiceLink(string desc)
{
- var match = invoiceIdRegex.Match(desc);
+ var match = _invoiceIdRegex.Match(desc);
if (!match.Success)
return desc;
var invoiceId = match.Groups[1].Value;
var link = LinkGenerator.ReceiptLink(invoiceId, Request.GetRequestBaseUrl());
- return invoiceIdRegex.Replace(desc, $"(Inv: <a href=\"{link}\">$1</a>)");
+ return _invoiceIdRegex.Replace(desc, $"(Inv: <a href=\"{link}\">$1</a>)");
}
[HttpPost]
### BTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtml
@@ -107,10 +107,12 @@
data-plan-name="@p.Data.Name"
data-allow-trial="@(p.Data.TrialDays > 0)">
<td class="fw-semibold text-nowrap plan-name-col">
- <span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ <span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
+ permission="@SubscriptionsPolicies.CanManageSubscribers">
@p.Data.Name
</span>
- <div class="dropdown-menu">
+ <span not-permission="@SubscriptionsPolicies.CanManageSubscribers">@p.Data.Name</span>
+ <div class="dropdown-menu" permission="@SubscriptionsPolicies.CanManageSubscribers">
<a
href="#"
text-translate="true"
@@ -182,7 +184,7 @@
{
<a
href="#"
- permission="@SubscriptionsPolicies.CanModifyOfferings"
+ permission="@SubscriptionsPolicies.CanManageSubscribers"
text-translate="true"
role="button"
id="page-primary"
@@ -382,7 +384,8 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 text-translate="true">Mails</h4>
<div class="d-flex justify-content-start sticky-footer">
- <button id="page-primary" class="btn btn-success px-4" type="submit" text-translate="true">Save</button>
+ <button id="page-primary" class="btn btn-success px-4" type="submit" text-translate="true"
+ permission="@SubscriptionsPolicies.CanModifyOfferings">Save</button>
</div>
</div>
@@ -420,7 +423,8 @@
<label asp-for="PaymentRemindersDays" class="form-label" text-translate="true">Email Reminder Days Before Due</label>
<div class="input-group">
<input inputmode="number" asp-for="PaymentRemindersDays" class="form-control" style="max-width:12ch;"
- min="0" />
+ min="0" permission="@SubscriptionsPolicies.CanModifyOfferings" />
+ <span class="form-control" not-permission="@SubscriptionsPolicies.CanModifyOfferings">@Model.PaymentRemindersDays</span>
<span class="input-group-text" text-translate="true">days</span>
</div>
<span asp-validation-for="PaymentRemindersDays" class="text-danger"></span>
@@ -434,8 +438,9 @@
<div class="card-body">
<div class="d-flex align-items-center gap-4 mb-3">
<h5 class="mb-0" text-translate="true">Email rules</h5>
- <span class="dropdown-toggle btn btn-outline-secondary" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Add email rule</span>
- <div class="dropdown-menu">
+ <span class="dropdown-toggle btn btn-outline-secondary" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
+ permission="@SubscriptionsPolicies.CanModifyOfferings">Add email rule</span>
+ <div class="dropdown-menu" permission="@SubscriptionsPolicies.CanModifyOfferings">
@foreach (var availableRule in Model.AvailableTriggers)
{
<button
### BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -604,7 +604,6 @@ static Translations()
"Display the key or QR code to configure an authenticator app with your current setup.": "",
"Display the search bar": "",
"Display Title": "",
- "Disqus Shortname": "",
"Do not allow additional contributions after target has been reached": "",
"Do not photograph it. Do not store it digitally.": "",
"Do not photograph the recovery phrase, and do not store it digitally.": "",
@@ -677,7 +676,6 @@ static Translations()
"Enable Authenticator": "",
"Enable Authenticator App": "",
"Enable background animations on new payments": "",
- "Enable Disqus Comments": "",
"Enable experimental features": "",
"Enable fallback rates": "",
"Enable LNURL": "",
### BTCPayServer/Plugins/Wallets/Controllers/UIStoreOnChainWalletsController.cs
@@ -120,6 +120,7 @@ public async Task<IActionResult> ImportWallet(
[HttpPost("{storeId}/onchain/{cryptoCode}/modify")]
[HttpPost("{storeId}/onchain/{cryptoCode}/import/{method:regex(^(hardware|file|xpub|scan|seed)$)}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateWallet(
WalletSetupViewModel vm,
[FromRoute] string storeId = null,
@@ -720,6 +721,7 @@ public async Task<ActionResult> DeleteWallet([FromRoute] string storeId, [FromRo
}
[HttpPost("{storeId}/onchain/{cryptoCode}/delete")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfirmDeleteWallet([FromRoute] string storeId, [FromRoute] string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
### BTCPayServer/Plugins/Wallets/Views/UIStoreOnChainWallets/WalletSettings.cshtml
@@ -51,7 +51,9 @@
}
<a asp-controller="UIStoreOnChainWallets" asp-action="ReplaceWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode"
id="ChangeWalletLink"
- class="dropdown-item"
+ class="dropdown-item wallet-settings__replace-wallet"
+ permission="@Policies.CanModifyStoreSettings"
+ permission-resource="@Model.StoreId"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Replace {0} wallet", Model.CryptoCode]"
@@ -65,6 +67,8 @@
<button type="submit"
id="Delete"
class="dropdown-item"
+ permission="@Policies.CanModifyStoreSettings"
+ permission-resource="@Model.StoreId"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Remove {0} wallet", Model.CryptoCode]"
### BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletPSBTDecoded.cshtml
@@ -12,7 +12,7 @@
}
@section PageHeadContent {
- <link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
+ <link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true" />
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
<style>
.nav-pills .nav-link.active {
### BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletReceive.cshtml
@@ -15,7 +15,7 @@
@section PageHeadContent
{
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
- <link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
+ <link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet" />
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
}
### BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletSend.cshtml
@@ -33,7 +33,7 @@
margin-left: 0;
}
</style>
- <link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
+ <link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet" />
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
}
### BTCPayServer/Services/Apps/CrowdfundSettings.cs
@@ -40,9 +40,7 @@ public decimal? TargetAmount
public string NotificationUrl { get; set; }
public string Tagline { get; set; }
public string PerksTemplate { get; set; }
- public bool DisqusEnabled { get; set; }
public bool SoundsEnabled { get; set; }
- public string DisqusShortname { get; set; }
public bool AnimationsEnabled { get; set; }
public int ResetEveryAmount { get; set; } = 1;
public CrowdfundResetEvery ResetEvery { get; set; } = CrowdfundResetEvery.Never;
### BTCPayServer/Services/Stores/StoreRepository.cs
@@ -828,7 +828,10 @@ public async Task<StoreData> GetDefaultStoreTemplate()
if (!string.IsNullOrWhiteSpace(r.DefaultPaymentMethodId) && PaymentMethodId.TryParse(r.DefaultPaymentMethodId, out var paymentMethodId))
data.SetDefaultPaymentId(paymentMethodId);
if (r?.Blob is not null)
+ {
+ r.Blob.EmailSettings = null;
data.SetStoreBlob(r.Blob);
+ }
return data;
}
public async Task SetDefaultStoreTemplate(string storeId, string userId)
### BTCPayServer/Services/UserService.cs
@@ -70,7 +70,8 @@ public static async Task<T> ForAPI<T>(
string?[] roles,
CallbackGenerator callbackGenerator,
UriResolver uriResolver,
- HttpRequest request) where T : ApplicationUserData, new()
+ HttpRequest request,
+ bool includeInvitationUrl) where T : ApplicationUserData, new()
{
var blob = data.GetBlob() ?? new UserBlob();
return new T
@@ -90,8 +91,7 @@ public static async Task<T> ForAPI<T>(
ImageUrl = string.IsNullOrEmpty(blob.ImageUrl)
? null
: await uriResolver.Resolve(request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
- InvitationUrl = string.IsNullOrEmpty(blob.InvitationToken) ? null
- : callbackGenerator.ForInvitation(data.Id, blob.InvitationToken)
+ InvitationUrl = !includeInvitationUrl || string.IsNullOrEmpty(blob.InvitationToken) ? null : callbackGenerator.ForInvitation(data.Id, blob.InvitationToken)
};
}
### BTCPayServer/Views/Shared/PostRedirect.cshtml
@@ -40,7 +40,6 @@
{
var url = Model.AllowExternal ? Model.FormUrl : Url.EnsureLocal(Model.FormUrl, this.Context.Request);
<form method="post" id="postform" action="@url" rel="noreferrer noopener">
- @Html.AntiForgeryToken()
@foreach (var o in Model.FormParameters)
{
foreach (var v in o.Value)
### BTCPayServer/Views/UILNURLAuth/Create.cshtml
@@ -52,7 +52,7 @@
if (request.readyState === 4 && request.status === 200) {
setTimeout(check, 1000);
} else if (request.readyState === 4 ){
- window.location.href = @Safe.Json(Url.Action("RedirectToList", new { successMessage = "The lightning node will now act as a security device for your account" }));
+ window.location.href = @Safe.Json(Url.Action("CreateComplete"));
}
}
### BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml
@@ -64,7 +64,7 @@
<main class="flex-grow-1">
<div class="d-flex flex-column justify-content-center gap-4">
<partial name="_StoreHeader" model="(Model.Title, Model.StoreBranding)" />
- <div class="text-center mt-n3">
+ <div class="text-center mt-n3" v-pre>
Invoice from
@if (!string.IsNullOrEmpty(Model.StoreWebsite))
{
@@ -215,7 +215,7 @@
{
<section class="tile">
<h2 class="h4 mb-3" text-translate="true">Memo</h2>
- <div id="InvoiceDescription" v-html="srvModel.description">@Safe.Raw(Model.Description)</div>
+ <div id="InvoiceDescription" v-pre>@Safe.Raw(Model.Description)</div>
</section>
}
### BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml
@@ -133,6 +133,14 @@
</div>
</h3>
<div class="settings-section">
+ <div class="form-group d-flex">
+ <input asp-for="NfcEnabled" type="checkbox" class="btcpay-toggle me-3"/>
+ <div>
+ <label asp-for="NfcEnabled" class="form-check-label"></label>
+ <span asp-validation-for="NfcEnabled" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Shows a Pay by NFC button on checkout when the customer's device supports it. The card's LNURL-withdraw is submitted to the server, which then fetches it.</div>
+ </div>
+ </div>
<div id="CheckNFC" class="form-group d-none">
<button type="button" class="btn btn-outline-secondary" text-translate="true">Check if NFC is supported and enabled on this device</button>
</div>
### BTCPayServer/wwwroot/crowdfund/app.js
@@ -275,31 +275,6 @@ app = new Vue({
});
- if (srvModel.disqusEnabled) {
- window.disqus_config = function () {
- // Replace PAGE_URL with your page's canonical URL variable
- this.page.url = window.location.href;
-
- // Replace PAGE_IDENTIFIER with your page's unique identifier variable
- this.page.identifier = self.srvModel.appId;
- };
-
- (function () { // REQUIRED CONFIGURATION VARIABLE: EDIT THE SHORTNAME BELOW
- var d = document, s = d.createElement('script');
-
- // IMPORTANT: Replace EXAMPLE with your forum shortname!
- s.src = "https://" + self.srvModel.disqusShortname + ".disqus.com/embed.js";
- s.async = true;
- s.setAttribute('data-timestamp', +new Date());
- (d.head || d.body).appendChild(s);
-
- var s2 = d.createElement('script');
- s2.src = "//" + self.srvModel.disqusShortname + ".disqus.com/count.js";
- s2.async = true;
- s.setAttribute('data-timestamp', +new Date());
- (d.head || d.body).appendChild(s);
- })();
- }
eventAggregator.$on("info-updated", function (model) {
console.warn("UPDATED", self.srvModel, arguments);
self.srvModel = model;
### BTCPayServer/wwwroot/swagger/v1/swagger.template.apps.json
@@ -980,16 +980,6 @@
"example": "I can't believe it's not butter",
"nullable": true
},
- "disqusEnabled": {
- "type": "boolean",
- "description": "Whether Disqus is enabled for the app",
- "nullable": true
- },
- "disqusShortname": {
- "type": "string",
- "description": "Disqus shortname to used for the app",
- "nullable": true
- },
"soundsEnabled": {
"type": "boolean",
"description": "Whether sounds on new contributions are enabled",
### BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json
@@ -63,7 +63,7 @@
},
"currentPassword": {
"type": "string",
- "description": "The current password of the user",
+ "description": "The current password of the user. Required when changing the email or the password.",
"nullable": true
},
"newPassword": {
@@ -95,6 +95,16 @@
}
}
},
+ "422": {
+ "description": "Unable to validate the request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ValidationProblemDetails"
+ }
+ }
+ }
+ },
"404": {
"description": "The user could not be found"
}
### Build/Version.csproj
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
- <Version>2.4.2</Version>
+ <Version>2.4.3</Version>
</PropertyGroup>
</Project>
### Changelog.md
@@ -1,5 +1,9 @@
# Changelog
+## 2.4.3
+
+This is a security release; updating is recommended for servers shared with many users.
+
## 2.4.2
This release contains fix of a critical vulnerability that is being actively exploited. You need to update as fast as you can.
### Dockerfile
@@ -1,4 +1,4 @@
-FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.302-noble AS builder
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.400-noble AS builder
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
WORKDIR /source
COPY nuget.config nuget.config
@@ -21,7 +21,7 @@ ARG CONFIGURATION_NAME=Release
ARG GIT_COMMIT
RUN cd BTCPayServer && dotnet publish -p:GitCommit=${GIT_COMMIT} --output /app/ --configuration ${CONFIGURATION_NAME}
-FROM mcr.microsoft.com/dotnet/aspnet:10.0.10-noble
+FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*Why this scored 78/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.