feat: Add Update button to disabled plugins section (#7260)
What changed, and why it matters
This commit adds an 'Update' button for disabled plugins in BTCPay Server's plugin management page. It lets users update a disabled plugin directly from the disabled-plugins list instead of scrolling to the available-plugins section. The change also fetches all plugins unfiltered once and does search filtering in memory, and adds unit tests. There is no clear security bug in the diff, but the change touches plugin installation commands and identifier matching, which are security-sensitive areas.
Review the plugin install/update flow for identifier normalization consistency across the entire plugin lifecycle (install, enable, disable, uninstall), and ensure that case-insensitive plugin identifier handling cannot be abused to confuse one plugin for another or bypass allow/deny lists. Verify that the unfiltered remote plugin fetch does not expose internal data or increase attack surface.
Security signals we found
Plugin installation/update command path modified
Case-insensitive identifier matching introduced for plugin commands and available-plugin map lookups
New static helper for resolving available updates to disabled plugins
DependenciesMet check retained in UI before update scheduling
No input validation/sanitization changes visible in diff
Evidence from the diff
The patch modifies UIServerController.Plugins.cs and the ListPlugins Razor view. It introduces a static helper ListPluginsViewModel.GetDisabledPluginUpdates that, given a map of disabled plugin identifiers/versions and available plugins, returns those with a newer available version. The controller now always fetches the full remote plugin list (search=null) and filters in memory, builds a case-insensitive identifier map keeping the newest version per identifier, and passes DisabledPluginUpdates to the view. The view adds an Update/Schedule update button for disabled plugins with a pending-install state, reusing existing PluginManager.DependenciesMet checks. Existing command lookups were made case-insensitive. Seven unit tests cover version matching, case-insensitive matching, newest-version selection, null safety, and empty lists.
Changed components
BTCPayServer/Controllers/UIServerController.Plugins.csBTCPayServer/Views/UIServer/ListPlugins.cshtmlBTCPayServer.Tests/FastTests.csInspect captured patch +174 / −11
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index 0f7f4e5..a6b9173 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -11,6 +11,8 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Controllers;
+using BTCPayServer.Plugins;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
@@ -2517,5 +2519,114 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Equal("BTC-hasjdfhasjkfjlajn", new PaymentMethodIdJsonConverter().ReadJson(reader, typeof(PaymentMethodId), null,
JsonSerializer.CreateDefault()).ToString());
}
+
+ [Fact]
+ public void GetDisabledPluginUpdates_ReturnsUpdateWhenNewerVersionAvailable()
+ {
+ var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>()
+ {
+ { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.1.0") }
+ };
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Single(result);
+ Assert.Equal(new Version(1, 1, 0), result["TestPlugin"].Version);
+ }
+
+ [Fact]
+ public void GetDisabledPluginUpdates_NoUpdateWhenSameVersion()
+ {
+ var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>()
+ {
+ { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.0.0") }
+ };
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void GetDisabledPluginUpdates_NoUpdateWhenNoAvailablePlugins()
+ {
+ var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>();
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void GetDisabledPluginUpdates_SkipsNullVersion()
+ {
+ var disabled = new Dictionary<string, Version> { { "TestPlugin", null } };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>()
+ {
+ { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.1.0") }
+ };
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void GetDisabledPluginUpdates_CaseInsensitiveIdentifierMatching()
+ {
+ var disabled = new Dictionary<string, Version> { { "MyPlugin", new Version(1, 0, 0, 0) } };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>(StringComparer.OrdinalIgnoreCase)
+ {
+ { "myplugin", MakeAvailablePlugin("myplugin", "1.1.0") }
+ };
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Single(result);
+ Assert.Equal(new Version(1, 1, 0), result["MyPlugin"].Version);
+ }
+
+ [Fact]
+ public void GetDisabledPluginUpdates_UsesNewestVersionFromMultipleEntries()
+ {
+ var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
+ // Build the dictionary the same way the controller does
+ var allPlugins = new[]
+ {
+ MakeAvailablePlugin("TestPlugin", "1.1.0"),
+ MakeAvailablePlugin("TestPlugin", "1.3.0"),
+ MakeAvailablePlugin("TestPlugin", "1.2.0")
+ };
+ var available = new Dictionary<string, PluginService.AvailablePlugin>(StringComparer.OrdinalIgnoreCase);
+ foreach (var p in allPlugins)
+ {
+ if (!available.TryGetValue(p.Identifier, out var existing) || p.Version > existing.Version)
+ available[p.Identifier] = p;
+ }
+
+ var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
+
+ Assert.Single(result);
+ Assert.Equal(new Version(1, 3, 0), result["TestPlugin"].Version);
+ }
+
+ private static PluginService.AvailablePlugin MakeAvailablePlugin(
+ string identifier, string version, params (string id, string condition)[] dependencies)
+ {
+ return new PluginService.AvailablePlugin
+ {
+ Identifier = identifier,
+ Name = identifier,
+ Version = Version.Parse(version),
+ Dependencies = dependencies.Select(d => new IBTCPayServerPlugin.PluginDependency
+ {
+ Identifier = d.id,
+ Condition = d.condition
+ }).ToArray()
+ };
+ }
}
}
diff --git a/BTCPayServer/Controllers/UIServerController.Plugins.cs b/BTCPayServer/Controllers/UIServerController.Plugins.cs
index 0b6677f..bd8490b 100644
--- a/BTCPayServer/Controllers/UIServerController.Plugins.cs
+++ b/BTCPayServer/Controllers/UIServerController.Plugins.cs
@@ -22,9 +22,15 @@ namespace BTCPayServer.Controllers
string search = null)
{
IEnumerable<PluginService.AvailablePlugin> availablePlugins;
+ IEnumerable<PluginService.AvailablePlugin> allPlugins;
try
{
- availablePlugins = await pluginService.GetRemotePlugins(search);
+ allPlugins = await pluginService.GetRemotePlugins(null);
+ availablePlugins = string.IsNullOrEmpty(search)
+ ? allPlugins
+ : allPlugins.Where(p =>
+ p.Identifier.Contains(search, StringComparison.OrdinalIgnoreCase) ||
+ p.Name.Contains(search, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
@@ -34,19 +40,28 @@ namespace BTCPayServer.Controllers
Message = StringLocalizer["Remote plugins lookup failed. Try again later. Error: {0}", ex.Message].Value
});
availablePlugins = Array.Empty<PluginService.AvailablePlugin>();
+ allPlugins = [];
}
- var availablePluginsByIdentifier = new Dictionary<string, AvailablePlugin>();
- foreach (var p in availablePlugins)
- availablePluginsByIdentifier.TryAdd(p.Identifier, p);
+ var availablePluginsByIdentifier = new Dictionary<string, AvailablePlugin>(StringComparer.OrdinalIgnoreCase);
+ foreach (var p in allPlugins)
+ {
+ if (!availablePluginsByIdentifier.TryGetValue(p.Identifier, out var existing) || p.Version > existing.Version)
+ availablePluginsByIdentifier[p.Identifier] = p;
+ }
+
+ var disabled = pluginService.GetDisabledPlugins();
+ var installed = pluginService.Installed;
+ var disabledPluginUpdates = ListPluginsViewModel.GetDisabledPluginUpdates(disabled, availablePluginsByIdentifier);
var res = new ListPluginsViewModel()
{
Plugins = pluginService.LoadedPlugins,
- Installed = pluginService.Installed,
+ Installed = installed,
Available = availablePlugins,
Commands = pluginService.GetPendingCommands(),
- Disabled = pluginService.GetDisabledPlugins(),
+ Disabled = disabled,
CanShowRestart = true,
- DownloadedPluginsByIdentifier = availablePluginsByIdentifier
+ DownloadedPluginsByIdentifier = availablePluginsByIdentifier,
+ DisabledPluginUpdates = disabledPluginUpdates
};
return View(res);
}
@@ -60,6 +75,24 @@ namespace BTCPayServer.Controllers
public Dictionary<string, Version> Disabled { get; set; }
public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
public Dictionary<string, Version> Installed { get; set; }
+ public Dictionary<string, PluginService.AvailablePlugin> DisabledPluginUpdates { get; set; }
+
+ public static Dictionary<string, PluginService.AvailablePlugin> GetDisabledPluginUpdates(
+ Dictionary<string, Version> disabled,
+ Dictionary<string, AvailablePlugin> availablePluginsByIdentifier)
+ {
+ var result = new Dictionary<string, PluginService.AvailablePlugin>();
+ foreach (var (disabledPlugin, disabledVersion) in disabled)
+ {
+ if (disabledVersion == null) continue;
+ if (availablePluginsByIdentifier.TryGetValue(disabledPlugin, out var available))
+ {
+ if (available.Version > disabledVersion)
+ result[disabledPlugin] = available;
+ }
+ }
+ return result;
+ }
}
[HttpPost("server/plugins/uninstall-all")]
diff --git a/BTCPayServer/Views/UIServer/ListPlugins.cshtml b/BTCPayServer/Views/UIServer/ListPlugins.cshtml
index 03292f2..1c36852 100644
--- a/BTCPayServer/Views/UIServer/ListPlugins.cshtml
+++ b/BTCPayServer/Views/UIServer/ListPlugins.cshtml
@@ -102,16 +102,35 @@
}
</span>
@{
- var uninstalled = Model.Commands.Any(c => c.plugin == plugin && c.command == "delete");
- var enabled = Model.Commands.Any(c => c.plugin == plugin && c.command == "enable");
+ var uninstalled = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "delete");
+ var enabled = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "enable");
+ var pendingInstall = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "install");
+ Model.DisabledPluginUpdates.TryGetValue(plugin, out var recommendedUpdate);
}
<div class="d-flex gap-2">
+ @if (pendingInstall)
+ {
+ <button type="button" class="btn btn-sm btn-outline-primary" disabled text-translate="true">Marked for update</button>
+ }
+ else if (recommendedUpdate != null && !uninstalled && !enabled)
+ {
+ <form asp-action="InstallPlugin" asp-route-plugin="@plugin" asp-route-version="@recommendedUpdate.Version">
+ @if (PluginManager.DependenciesMet(recommendedUpdate.Dependencies, installed))
+ {
+ <button type="submit" class="btn btn-sm btn-primary" text-translate="true">Update</button>
+ }
+ else
+ {
+ <button title="Schedule upgrade for when the dependencies have been met to ensure a smooth update" data-bs-toggle="tooltip" type="submit" class="btn btn-sm btn-primary" text-translate="true">Schedule update</button>
+ }
+ </form>
+ }
<form asp-action="EnablePlugin" asp-route-plugin="@plugin">
@if (enabled)
{
<button type="submit" class="btn btn-sm btn-outline-primary" disabled text-translate="true">Marked for enabling</button>
}
- else if (!uninstalled)
+ else if (!uninstalled && !pendingInstall)
{
<button type="submit" class="btn btn-sm btn-outline-primary" text-translate="true">Enable</button>
}
@@ -122,7 +141,7 @@
{
<button type="submit" class="btn btn-sm btn-outline-danger" disabled text-translate="true">Marked for deletion</button>
}
- else if (!enabled)
+ else if (!enabled && !pendingInstall)
{
<button type="submit" class="btn btn-sm btn-outline-danger" text-translate="true">Uninstall</button>
}
Why this scored 21/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.