Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/Middleware/TrafficGuardMiddleware.cs
T
2026-10-14 06:59:51 +02:00

160 lines
7.3 KiB
C#

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);
}
}