diff --git a/src/ReverseLlama.Server/AuthRateLimiter.cs b/src/ReverseLlama.Server/AuthRateLimiter.cs new file mode 100644 index 0000000..54a6401 --- /dev/null +++ b/src/ReverseLlama.Server/AuthRateLimiter.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; + +namespace ReverseLlama.Server; + +internal sealed class AuthRateLimiter +{ + private readonly ConcurrentDictionary _attempts = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public AuthRateLimiter(ILogger logger) + { + _logger = logger; + } + + public void RecordFailure(string ipAddress, string endpoint) + { + var info = _attempts.GetOrAdd(ipAddress, _ => new AuthAttemptInfo()); + + lock (info) + { + info.Count++; + info.LastAttemptUtc = DateTime.UtcNow; + + if (info.Count >= 20) + { + info.BlockedUntilUtc = DateTime.UtcNow.AddHours(48); + _logger.LogWarning( + "IP {IpAddress} blocked for 48 hours after {Count} failed auth attempts (last: {Endpoint})", + ipAddress, info.Count, endpoint); + } + else + { + _logger.LogWarning( + "Failed auth attempt #{Count} from {IpAddress} on {Endpoint}", + info.Count, ipAddress, endpoint); + } + } + } + + public (bool Allowed, TimeSpan? RetryAfter, bool IsBlocked) CheckRateLimit(string ipAddress) + { + if (!_attempts.TryGetValue(ipAddress, out var info)) + { + return (true, null, false); + } + + lock (info) + { + if (info.BlockedUntilUtc is { } blockedUntil) + { + if (blockedUntil > DateTime.UtcNow) + { + return (false, blockedUntil - DateTime.UtcNow, true); + } + + info.Count = 0; + info.BlockedUntilUtc = null; + info.LastAttemptUtc = DateTime.MinValue; + return (true, null, false); + } + + var waitTime = CalculateWaitTime(info.Count); + if (waitTime is { } wait) + { + var elapsed = DateTime.UtcNow - info.LastAttemptUtc; + if (elapsed < wait) + { + return (false, wait - elapsed, false); + } + } + + return (true, null, false); + } + } + + public static string GetClientIp(HttpRequest request) + { + if (request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) + { + var first = forwardedFor.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(first)) + { + var commaIndex = first.IndexOf(','); + return commaIndex > 0 ? first[..commaIndex].Trim() : first.Trim(); + } + } + + if (request.Headers.TryGetValue("X-Real-IP", out var realIp)) + { + var first = realIp.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(first)) + { + return first.Trim(); + } + } + + return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private static TimeSpan? CalculateWaitTime(int attemptCount) => + attemptCount switch + { + < 3 => null, + < 5 => TimeSpan.FromSeconds(5), + < 10 => TimeSpan.FromSeconds(5 + (attemptCount - 5) * 5), + < 20 => TimeSpan.FromMinutes(attemptCount - 9), + _ => null + }; + + private sealed class AuthAttemptInfo + { + public int Count; + public DateTime LastAttemptUtc; + public DateTime? BlockedUntilUtc; + } +} diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 6b0289d..1f3d061 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddSingleton(settings); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddElmah().Configure( options => options.ConnectionString = builder.Configuration.GetConnectionString("ElmahConnection")); @@ -101,6 +102,67 @@ app.Use(async (context, next) => await next(); }); +var rateLimiter = app.Services.GetRequiredService(); + +app.Use(async (context, next) => +{ + if (HttpMethods.IsOptions(context.Request.Method)) + { + await next(); + return; + } + + var ip = AuthRateLimiter.GetClientIp(context.Request); + var (allowed, retryAfter, _) = rateLimiter.CheckRateLimit(ip); + + if (!allowed) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.RetryAfter = ((int)retryAfter!.Value.TotalSeconds).ToString(); + context.Response.ContentType = "application/json"; + var seconds = (int)retryAfter!.Value.TotalSeconds; + string retryMessage; + if (seconds >= 3600) + { + var hours = seconds / 3600; + retryMessage = $"Please try again in {hours} hour{(hours == 1 ? "" : "s")}."; + } + else if (seconds >= 60) + { + var minutes = seconds / 60; + retryMessage = $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}."; + } + else + { + retryMessage = $"Please try again in {seconds} second{(seconds == 1 ? "" : "s")}."; + } + await context.Response.WriteAsJsonAsync(new { error = $"Too many requests. {retryMessage}" }, context.RequestAborted); + return; + } + + context.Response.OnStarting(() => + { + if (context.Response.StatusCode is StatusCodes.Status401Unauthorized) + { + rateLimiter.RecordFailure(ip, context.Request.Path); + } + else if (context.Response.StatusCode is StatusCodes.Status302Found + && context.Request.Path.StartsWithSegments("/api/admin")) + { + var location = context.Response.Headers.Location.FirstOrDefault(); + if (location is not null + && location.Contains("/admin/login", StringComparison.OrdinalIgnoreCase)) + { + rateLimiter.RecordFailure(ip, context.Request.Path); + } + } + + return Task.CompletedTask; + }); + + await next(); +}); + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30)