the files
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Campus_live_rewrite.api.server;
|
||||
|
||||
// Per-IP flood / brute-force guard. Sits EARLY in the pipeline (right after the banned-IP
|
||||
// check) so a flood is dropped before it touches routing, controllers, LiteDB or the access
|
||||
// log - the cheapest possible rejection.
|
||||
//
|
||||
// Two fixed-window buckets, both keyed on the TRUSTWORTHY client IP:
|
||||
//
|
||||
// * GENERAL: a high ceiling that only an actual request flood crosses (see
|
||||
// ConfigCampus.FloodMaxPerWindow). This is the anti-DoS ceiling - it keeps one address
|
||||
// from driving the box to max memory by hammering it.
|
||||
// * AUTH: a tight ceiling on the credential endpoints (/Auth/connect/token, /api/site/login).
|
||||
// This is the brute-force / token-farming guard the operator asked for: you cannot sit and
|
||||
// hammer the token endpoint guessing passwords or minting bearer tokens.
|
||||
//
|
||||
// WHY the IP must be the LAST X-Forwarded-For entry: behind Caddy every request's socket peer
|
||||
// is 127.0.0.1, so RemoteIpAddress is useless for per-IP limiting, and the FIRST XFF entry is
|
||||
// whatever the client sent - a spoof. Caddy APPENDS the real peer as the last entry, so that
|
||||
// entry is authoritative and cannot be rotated by a client sending its own XFF. Same rule the
|
||||
// website signup limiter uses. CF-Connecting-IP is deliberately NOT trusted (no Cloudflare in
|
||||
// front - it would be a free spoof).
|
||||
public class TrafficGuardMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
public TrafficGuardMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
// key -> (count, window-start unix seconds). Separate namespaces ("G:" / "A:") per bucket.
|
||||
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _hits = new();
|
||||
|
||||
// The dictionary would otherwise grow one entry per (bucket, IP) forever. Sweep stale
|
||||
// entries occasionally - cheap, amortised, and bounded.
|
||||
private static long _lastSweep;
|
||||
private const int SweepEverySeconds = 120;
|
||||
|
||||
private static bool IsAuthPath(string p) =>
|
||||
p.StartsWith("/Auth/connect/token", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.StartsWith("/api/site/login", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
if (!ConfigCampus.TrafficGuardEnabled)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
string ip = ClientIp(context);
|
||||
|
||||
// Never throttle loopback: Caddy, health checks and the admin SSH tunnel all originate
|
||||
// there, and an empty/unparseable IP is treated as loopback so we never lock everyone
|
||||
// out over a missing header.
|
||||
if (string.IsNullOrWhiteSpace(ip) || IsLoopback(ip) || AdminProtectionMiddleware.IsLimitExempt(ip))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
MaybeSweep(now);
|
||||
|
||||
string path = context.Request.Path.HasValue ? context.Request.Path.Value! : "/";
|
||||
bool auth = IsAuthPath(path);
|
||||
|
||||
// Brute-force lockout: an IP that failed login too many times is blocked from the credential
|
||||
// endpoints for a few minutes (see LoginGuard). Checked BEFORE the rate limiter and the
|
||||
// controller, so a locked-out address can't keep guessing.
|
||||
if (auth && LoginGuard.IsLockedOut(ip, out int lockRetry))
|
||||
{
|
||||
await Reject(context, lockRetry, path, ip, "login-lockout");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth endpoints are checked against BOTH ceilings; everything else only the general one.
|
||||
if (auth && Throttled("A:" + ip, ConfigCampus.AuthMaxPerWindow, ConfigCampus.AuthWindowSeconds, now, out int aRetry))
|
||||
{
|
||||
await Reject(context, aRetry, path, ip, "auth");
|
||||
return;
|
||||
}
|
||||
if (Throttled("G:" + ip, ConfigCampus.FloodMaxPerWindow, ConfigCampus.FloodWindowSeconds, now, out int gRetry))
|
||||
{
|
||||
await Reject(context, gRetry, path, ip, "flood");
|
||||
return;
|
||||
}
|
||||
// An address that posted uploads the game didn't make is held to a much lower ceiling on
|
||||
// EVERYTHING for the strike window (UploadGuard) - the "everything rate-limits them" half
|
||||
// of the upload rule, on top of the ban that lands at the strike count.
|
||||
if (UploadGuard.IsPenalisedIp(ip)
|
||||
&& Throttled("P:" + ip, ConfigCampus.UploadPenaltyMaxPerWindow, ConfigCampus.FloodWindowSeconds, now, out int pRetry))
|
||||
{
|
||||
await Reject(context, pRetry, path, ip, "upload-penalty");
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
// Fixed-window counter. Returns true (and a Retry-After) once the window count exceeds cap.
|
||||
//
|
||||
// A REJECTED request no longer counts: the client retries /connect/token every few seconds
|
||||
// after a 429, and with every retry counted the window never drained - 317 refusals in one
|
||||
// hour from one address, a player who could only get in by changing IP (a VPN). Now the
|
||||
// window fills, refuses until it ends, and then admits again.
|
||||
private static bool Throttled(string key, int cap, int windowSeconds, long now, out int retryAfter)
|
||||
{
|
||||
var entry = _hits.AddOrUpdate(
|
||||
key,
|
||||
_ => (1, now),
|
||||
(_, cur) => (now - cur.windowStart >= windowSeconds) ? (1, now)
|
||||
: (cur.count > cap ? cur.count : cur.count + 1, cur.windowStart));
|
||||
|
||||
if (entry.count > cap)
|
||||
{
|
||||
retryAfter = (int)Math.Max(1, windowSeconds - (now - entry.windowStart));
|
||||
return true;
|
||||
}
|
||||
retryAfter = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task Reject(HttpContext context, int retryAfter, string path, string ip, string kind)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.Response.Headers["Retry-After"] = retryAfter.ToString();
|
||||
Console.WriteLine($"[trafficguard] 429 ({kind}) {context.Request.Method} {path} from {ip} - retry in {retryAfter}s");
|
||||
await context.Response.WriteAsync("Too many requests.");
|
||||
}
|
||||
|
||||
private static void MaybeSweep(long now)
|
||||
{
|
||||
if (now - Interlocked.Read(ref _lastSweep) < SweepEverySeconds) return;
|
||||
Interlocked.Exchange(ref _lastSweep, now);
|
||||
// Drop any entry whose window is well past (older than the widest window we use). Cheap
|
||||
// enough to do inline every couple of minutes.
|
||||
long maxWindow = Math.Max(ConfigCampus.FloodWindowSeconds, ConfigCampus.AuthWindowSeconds);
|
||||
foreach (var kv in _hits)
|
||||
if (now - kv.Value.windowStart > maxWindow + 60)
|
||||
_hits.TryRemove(kv.Key, out _);
|
||||
}
|
||||
|
||||
// Trustworthy client IP = the LAST X-Forwarded-For entry (Caddy-appended), else the socket.
|
||||
private static string ClientIp(HttpContext ctx)
|
||||
{
|
||||
string xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(xff))
|
||||
{
|
||||
var parts = xff.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length > 0) return parts[parts.Length - 1];
|
||||
}
|
||||
return ctx.Connection.RemoteIpAddress?.ToString() ?? "";
|
||||
}
|
||||
|
||||
private static bool IsLoopback(string ip)
|
||||
{
|
||||
if (ip == "::1" || ip.StartsWith("127.")) return true;
|
||||
return IPAddress.TryParse(ip, out var parsed) && IPAddress.IsLoopback(parsed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user