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, )",