diff --git a/docs/Admin-panel.md b/docs/Admin-panel.md index 76bd458..c1a662c 100644 --- a/docs/Admin-panel.md +++ b/docs/Admin-panel.md @@ -8,12 +8,14 @@ The panel is protected by a password set in `config.json`: ```json "Admin": { - "Password": "changeme", + "Password": "your-strong-password-here", "MarketplaceUrl": "https://raw.githubusercontent.com/your-org/your-repo/main/marketplace/plugins.json" } ``` -On first visit the login page is shown. After a successful login a session cookie valid for 8 hours is set. Clicking **Sign out** clears it immediately. +The password is hashed (SHA256) in memory and in cookies — the plaintext is never stored in the browser or compared directly. On first visit the login page is shown. After a successful login a session cookie (HttpOnly, Secure, SameSite=Strict) valid for 8 hours is set. Clicking **Sign out** clears it immediately. + +After 5 failed login attempts from the same IP, that IP is locked out for 15 minutes. All API endpoints under `/api/admin/` return `401 Unauthorized` if the cookie is absent or incorrect. diff --git a/docs/Server-configuration.md b/docs/Server-configuration.md index 28555da..9a366e2 100644 --- a/docs/Server-configuration.md +++ b/docs/Server-configuration.md @@ -65,7 +65,7 @@ The `Admin` configuration is used to set the admin password and plugin marketpla | Key | Default | Description | | :--- | :--- | :--- | -| Password | `CHANGE-ME` | The admin password for the server, used for authentication. Make sure to change this to a strong, complex password. | +| Password | *(empty — must be set)* | The admin password for the server, used for authentication. Set a strong, complex password. The server logs a warning at startup if this is left empty or set to a known default. | | MarketplaceUrl | `https://raw.githubusercontent.com/HayashiUme/Empostor/main/marketplace/plugins.json` | A URL pointing to a `plugins.json` file. This file defines the list of plugins available in the plugin marketplace. | ### DiscordWebhook diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index aedf17e..fb68fad 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,7 +150,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Api/Config/AdminConfig.cs b/src/Empostor.Api/Config/AdminConfig.cs index 32651b8..9102179 100644 --- a/src/Empostor.Api/Config/AdminConfig.cs +++ b/src/Empostor.Api/Config/AdminConfig.cs @@ -4,7 +4,7 @@ namespace Empostor.Api.Config { public const string Section = "Admin"; - public string Password { get; set; } = "admin123"; + public string Password { get; set; } = string.Empty; public string MarketplaceUrl { get; set; } = "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/plugins.json"; diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 9c8d1b7..9d991b4 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -34,12 +34,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[8.0.27, )", - "resolved": "8.0.27", - "contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg==" - }, "StyleCop.Analyzers": { "type": "Direct", "requested": "[1.2.0-beta.556, )", @@ -145,7 +139,6 @@ "NextFast.Hazel.Abstractions": { "type": "Project" } - }, - "net8.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Server/Http/AdminAuthHelper.cs b/src/Empostor.Server/Http/AdminAuthHelper.cs new file mode 100644 index 0000000..ee05218 --- /dev/null +++ b/src/Empostor.Server/Http/AdminAuthHelper.cs @@ -0,0 +1,37 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Empostor.Server.Http; + +/// +/// Shared admin authentication utilities: SHA256 hashing and constant-time comparison. +/// +internal static class AdminAuthHelper +{ + /// + /// Computes the hex-encoded SHA256 hash of the input string. + /// Used so the plaintext password never enters a cookie or lingers in memory. + /// + internal static string ComputeHash(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes); + } + + /// + /// Compares two strings in constant time to prevent timing side-channel attacks. + /// Uses CryptographicOperations.FixedTimeEquals on the UTF-8 byte representations. + /// + internal static bool ConstantTimeEquals(string a, string b) + { + if (a.Length != b.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(a), + Encoding.UTF8.GetBytes(b)); + } +} diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 83ad207..f1cab20 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -31,6 +32,9 @@ namespace Empostor.Server.Http private static Dictionary? _adminStrings; private static readonly object _stringsLock = new(); + private const int LoginLockoutThreshold = 5; + private static readonly TimeSpan LoginLockoutDuration = TimeSpan.FromMinutes(15); + private readonly ILogger _logger; private readonly IGameManager _gameManager; private readonly IClientManager _clientManager; @@ -42,6 +46,8 @@ namespace Empostor.Server.Http private readonly PlayerStatsConfig _statsConfig; private readonly ChatFilterStore _chatFilter; private readonly DiscordWebhookStore _discordWebhook; + private readonly string _passwordHash; + private static readonly ConcurrentDictionary _loginFailures = new(); public AdminController( ILogger logger, @@ -67,6 +73,15 @@ namespace Empostor.Server.Http _statsConfig = statsConfig.Value; _chatFilter = chatFilter; _discordWebhook = discordWebhook; + _passwordHash = AdminAuthHelper.ComputeHash(_config.Password); + + if (string.IsNullOrEmpty(_config.Password) + || _config.Password == "CHANGE-ME" + || _config.Password == "admin123") + { + _logger.LogWarning( + "[Admin] SECURITY: Default or empty admin password detected! Set a strong password in config.json -> Admin.Password to protect the admin panel."); + } } private static Dictionary LoadStrings() @@ -83,7 +98,8 @@ namespace Empostor.Server.Http } private bool IsAuthenticated() - => Request.Cookies.TryGetValue("empostor_admin", out var v) && v == _config.Password; + => Request.Cookies.TryGetValue("empostor_admin", out var v) + && AdminAuthHelper.ConstantTimeEquals(v, _passwordHash); [HttpGet("/admin")] public IActionResult Panel() @@ -92,17 +108,50 @@ namespace Empostor.Server.Http [HttpPost("/admin/login")] public IActionResult Login([FromForm] string password) { - if (password != _config.Password) + var ip = (HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback).ToString(); + + // Rate-limit check: block IPs that have failed too many times recently + if (_loginFailures.TryGetValue(ip, out var entry)) { - _logger.LogWarning("[Admin] Failed login from {Ip}", HttpContext.Connection.RemoteIpAddress); + if (entry.Count >= LoginLockoutThreshold + && DateTime.UtcNow - entry.FirstAttempt < LoginLockoutDuration) + { + var remaining = (int)(LoginLockoutDuration - (DateTime.UtcNow - entry.FirstAttempt)).TotalMinutes; + _logger.LogWarning("[Admin] Rate-limited login attempt from {Ip} (locked out for ~{Min}m more)", ip, Math.Max(1, remaining)); + return Content(LoginHtml.Replace("", + $"

Too many failed attempts. Try again in {Math.Max(1, remaining)} minute(s).

"), + "text/html; charset=utf-8"); + } + + // Reset if lockout window has passed + if (DateTime.UtcNow - entry.FirstAttempt >= LoginLockoutDuration) + { + _loginFailures.TryRemove(ip, out _); + } + } + + var submittedHash = AdminAuthHelper.ComputeHash(password); + + if (!AdminAuthHelper.ConstantTimeEquals(submittedHash, _passwordHash)) + { + _loginFailures.AddOrUpdate(ip, + _ => (1, DateTime.UtcNow), + (_, e) => (e.Count + 1, e.FirstAttempt)); + + _logger.LogWarning("[Admin] Failed login from {Ip} (attempt {Count})", ip, + _loginFailures.TryGetValue(ip, out var updated) ? updated.Count : 1); return Content(LoginHtml.Replace("", "

Incorrect password.

"), "text/html; charset=utf-8"); } - Response.Cookies.Append("empostor_admin", password, new CookieOptions + // Successful login — clear rate-limit entry for this IP + _loginFailures.TryRemove(ip, out _); + + Response.Cookies.Append("empostor_admin", _passwordHash, new CookieOptions { HttpOnly = true, + Secure = true, SameSite = SameSiteMode.Strict, MaxAge = TimeSpan.FromHours(8), }); diff --git a/src/Empostor.Server/Http/MarketplaceController.cs b/src/Empostor.Server/Http/MarketplaceController.cs index 6ea30aa..379c7d6 100644 --- a/src/Empostor.Server/Http/MarketplaceController.cs +++ b/src/Empostor.Server/Http/MarketplaceController.cs @@ -25,6 +25,7 @@ namespace Empostor.Server.Http private readonly IHttpClientFactory _http; private readonly AdminConfig _config; private readonly PluginLoaderService _pluginLoaderService; + private readonly string _passwordHash; public MarketplaceController( ILogger logger, @@ -36,10 +37,12 @@ namespace Empostor.Server.Http _http = http; _config = config.Value; _pluginLoaderService = pluginLoaderService; + _passwordHash = AdminAuthHelper.ComputeHash(_config.Password); } private bool IsAuthenticated() - => Request.Cookies.TryGetValue("empostor_admin", out var v) && v == _config.Password; + => Request.Cookies.TryGetValue("empostor_admin", out var v) + && AdminAuthHelper.ConstantTimeEquals(v, _passwordHash); [HttpGet("/api/admin/marketplace/plugins")] public async Task ListPlugins() diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 98a3929..ce49167 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -46,7 +46,7 @@ "UmeApiKey": "sk-empostor-globalapikey" }, "Admin": { - "Password": "CHANGE-ME", + "Password": "", "MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json" }, "DiscordWebhook": { diff --git a/src/Empostor.Server/packages.lock.json b/src/Empostor.Server/packages.lock.json index cc34b07..9d36848 100644 --- a/src/Empostor.Server/packages.lock.json +++ b/src/Empostor.Server/packages.lock.json @@ -58,12 +58,6 @@ "resolved": "8.0.1", "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[8.0.27, )", - "resolved": "8.0.27", - "contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg==" - }, "Serilog.Extensions.Hosting": { "type": "Direct", "requested": "[8.0.0, )",