aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-07-17 17:04:31 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-07-17 17:14:27 +0200
commit62a5ded9205b10ddaef60ce3e05bf80e79f4c742 (patch)
tree5bc7c7580a620a66a29c4b17b1a38b763aed320e
parenta96dc8bd9b1e2fc2a897a7d73839abf5bc5b81d4 (diff)
Prevent unauthenticated re-run of the startup wizard on misconfiguration
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs22
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs5
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs70
3 files changed, 97 insertions, 0 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 69e23bcb63..0c1c7d3f5b 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Manager;
@@ -417,6 +419,8 @@ namespace Emby.Server.Implementations
{
Logger.LogInformation("Running startup tasks");
+ EnsureStartupWizardIntegrity();
+
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
@@ -436,6 +440,24 @@ namespace Emby.Server.Implementations
return Task.CompletedTask;
}
+ private void EnsureStartupWizardIntegrity()
+ {
+ if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
+ {
+ return;
+ }
+
+ var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
+ .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
+
+ if (hasConfiguredAdministrator)
+ {
+ Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
+ ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
+ ConfigurationManager.SaveConfiguration();
+ }
+ }
+
/// <inheritdoc/>
public void Init(IServiceCollection serviceCollection)
{
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index fa6d9efe36..47c8f21241 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController
return NotFound();
}
+ if (!string.IsNullOrEmpty(user.Password))
+ {
+ return Forbid();
+ }
+
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{
return BadRequest("Password must not be empty");
diff --git a/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
new file mode 100644
index 0000000000..bad11a9257
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
@@ -0,0 +1,70 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class StartupControllerTests
+{
+ private readonly StartupController _subject;
+ private readonly Mock<IUserManager> _mockUserManager;
+ private readonly Mock<IServerConfigurationManager> _mockConfig;
+
+ public StartupControllerTests()
+ {
+ _mockUserManager = new Mock<IUserManager>();
+ _mockConfig = new Mock<IServerConfigurationManager>();
+ _subject = new StartupController(_mockConfig.Object, _mockUserManager.Object);
+ }
+
+ private static User CreateUser()
+ => new User(
+ "jellyfin",
+ typeof(DefaultAuthenticationProvider).FullName!,
+ typeof(DefaultPasswordResetProvider).FullName!);
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoUserExists_ReturnsNotFound()
+ {
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns((User?)null);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "admin", Password = "pw" });
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenPasswordAlreadyConfigured_ReturnsForbidden()
+ {
+ var user = CreateUser();
+ user.Password = "already-set-hash";
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "attacker", Password = "new-pw" });
+
+ // The startup wizard must never overwrite the password of an already-provisioned
+ // account, even if IsStartupWizardCompleted has been cleared.
+ Assert.IsType<ForbidResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(It.IsAny<System.Guid>(), It.IsAny<string>()), Times.Never);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoPasswordYet_SetsPassword()
+ {
+ var user = CreateUser();
+ Assert.True(string.IsNullOrEmpty(user.Password));
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "jellyfin", Password = "first-pw" });
+
+ Assert.IsType<NoContentResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(user.Id, "first-pw"), Times.Once);
+ }
+}