268 lines
14 KiB
C#
268 lines
14 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Campus_live_rewrite.api.server;
|
|
|
|
// Two gates in one place - the server is the gatekeeper for BOTH ways in:
|
|
//
|
|
// 1. GAME clients (the DLL): the patch stamps the shared-secret header X-Campus-Patch on EVERY
|
|
// request (its SendRequest hook). The server checks that header on EVERY request here - a
|
|
// match passes, anything else is 403'd. There is NO whitelist and NO time window: an old/no/
|
|
// tampered patch that doesn't stamp the exact secret is denied on every single call, every
|
|
// time, so there's nothing to "ride" after validating once.
|
|
//
|
|
// 2. The ADMIN panel (a browser, no DLL): can't stamp the header, so it is gated by a short-lived
|
|
// WEB SESSION TOKEN instead. You do NOT type it - a correct username/password login MINTS a
|
|
// signed, self-expiring token (AdminGate) into a cookie. The rest of the admin surface (home +
|
|
// /admin/api/*) requires a live token; when it expires you're bounced back to the login to
|
|
// mint a fresh one. The login page/API stay reachable so you can log in.
|
|
|
|
// The admin web session token. You do NOT type this - it is MINTED when you log in (correct
|
|
// username/password) and expires after a short period, like a bearer token. It's a signed,
|
|
// self-expiring value: "<expiryUnix>.<HMAC(expiryUnix)>", keyed by ConfigCampus.AdminWebToken.
|
|
// The server can validate it (signature + not-expired) without storing any session state, and
|
|
// once it lapses the browser is bounced back to the login to mint a fresh one.
|
|
public static class AdminGate
|
|
{
|
|
public const string CookieName = "admin_session";
|
|
|
|
// Short lifetime - a stale tab can't sit logged into admin indefinitely, and the whole admin
|
|
// web session resets every hour (re-login required). Change here if needed.
|
|
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
|
|
|
|
private static string Sign(string payload)
|
|
{
|
|
var key = Encoding.UTF8.GetBytes(ConfigCampus.AdminWebToken ?? "");
|
|
using var h = new HMACSHA256(key);
|
|
return Convert.ToBase64String(h.ComputeHash(Encoding.UTF8.GetBytes(payload)));
|
|
}
|
|
|
|
// Mint a fresh token at login.
|
|
public static string Issue()
|
|
{
|
|
string payload = DateTimeOffset.UtcNow.Add(Ttl).ToUnixTimeSeconds().ToString();
|
|
return payload + "." + Sign(payload);
|
|
}
|
|
|
|
public static bool IsValid(HttpContext ctx)
|
|
{
|
|
string? c = ctx.Request.Cookies[CookieName];
|
|
if (string.IsNullOrEmpty(c) || string.IsNullOrEmpty(ConfigCampus.AdminWebToken)) return false;
|
|
|
|
int dot = c.IndexOf('.');
|
|
if (dot <= 0 || dot >= c.Length - 1) return false;
|
|
string payload = c.Substring(0, dot);
|
|
string sig = c.Substring(dot + 1);
|
|
|
|
// Signature must match (constant-time) ...
|
|
var a = Encoding.UTF8.GetBytes(sig);
|
|
var b = Encoding.UTF8.GetBytes(Sign(payload));
|
|
if (!CryptographicOperations.FixedTimeEquals(a, b)) return false;
|
|
|
|
// ... and the embedded expiry must still be in the future.
|
|
if (!long.TryParse(payload, out long exp)) return false;
|
|
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < exp;
|
|
}
|
|
}
|
|
|
|
public class PatchGateMiddleware
|
|
{
|
|
private readonly RequestDelegate _next;
|
|
|
|
// Master switch for the GAME (patch) gate. The admin web-token gate is always on.
|
|
//
|
|
// TURNED OFF 2026-09-01, and it must stay off until the clients stamp a header again.
|
|
//
|
|
// Both patches used to call BestHTTP HTTPRequest.SetHeader on every request to stamp
|
|
// X-Campus-Patch. That stamping has been removed from the PC patch (SendRequest.h) and
|
|
// the Quest patch (campus_hook.cpp), so NO client sends the header any more. With
|
|
// Enable = true, this middleware would 403 every single game call and nobody could log in.
|
|
//
|
|
// WHAT THIS COSTS: the server can no longer distinguish a patched client from an old,
|
|
// absent or tampered one. The game API is open to anything that can reach it. IP bans
|
|
// (BannedIpMiddleware) and per-endpoint JWT validation still apply - this only removes
|
|
// the "must be running our DLL" check.
|
|
//
|
|
// The startup check (POST /api/patch/verify) still works and is still enforced client-side:
|
|
// both patches build that request with their own HTTP client and write the header by hand,
|
|
// which needs no game offsets. It is a startup gate, not per-request enforcement.
|
|
public static bool Enable = false;
|
|
|
|
// Header the patch stamps on every request (matches CampusPatch SendRequest.h).
|
|
public const string PatchHeaderName = "X-Campus-Patch";
|
|
|
|
public PatchGateMiddleware(RequestDelegate next) => _next = next;
|
|
|
|
// The admin surface that the web token protects: the panel, and its dev-login API.
|
|
private static bool IsAdminSurface(string p) =>
|
|
p.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// The login entry point: the login PAGE (where the token box lives) and the login API (which
|
|
// checks the token itself). These must be reachable WITHOUT the gate cookie - otherwise there'd
|
|
// be nowhere to enter the token. Bare /admin roots just redirect to the login, so allow them
|
|
// too. Everything else on the admin surface requires the gate cookie a good login stamps.
|
|
private static bool IsAdminLoginEntry(string p) =>
|
|
p.StartsWith("/api/web/admin/login", StringComparison.OrdinalIgnoreCase)
|
|
|| p.EndsWith("/login", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/admin", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/admin/", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/admin/home", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Reachable with NO patch header at all - the public browser surface. This is the WEBSITE
|
|
// base (landing page + the Campus_Net site and its login), which is meant for everyone, not
|
|
// just the game client. IP bans still apply to all of it because BannedIpMiddleware runs
|
|
// BEFORE this. Only the actual game API (everything not listed here or under the admin gate)
|
|
// requires the patch header.
|
|
private static bool IsOpen(string p) =>
|
|
p.StartsWith("/api/patch/verify", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/api/patch/proof", StringComparison.OrdinalIgnoreCase) // the login gate's proof (PatchProofs)
|
|
|| p.Equals("/", StringComparison.OrdinalIgnoreCase) // website base / landing page
|
|
|| p.Equals("/index.html", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/img", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/cdn", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/Web", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/favicon.png", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/DefaultProfileImage.jpg", StringComparison.OrdinalIgnoreCase)
|
|
// ---- Campus website (ContentRoot/Website), served by Kestrel since the Flask
|
|
// backend was retired. A browser cannot stamp X-Campus-Patch, so every asset and
|
|
// every site API call has to be open or the whole site 403s. This is the PUBLIC
|
|
// site only: /api/site/* is account signup/login/me for the website and reads the
|
|
// same PlayerDB the game does, but it exposes nothing the game API does.
|
|
|| IsSiteAsset(p)
|
|
|| p.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/api/search/", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/api/health", StringComparison.OrdinalIgnoreCase)
|
|
// Tachyon voice server's room lookup (GET /tachyon?id=<AccountId>). Server-to-server,
|
|
// no patch header - must stay reachable even if the patch gate is switched on.
|
|
|| p.Equals("/tachyon", StringComparison.OrdinalIgnoreCase)
|
|
// Public website clean URLs (WebController) + the launcher download. Browser pages, no
|
|
// patch header - keep them open like the rest of the site.
|
|
|| p.Equals("/campus.exe", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/profile", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/news", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/rooms", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/patch-notes", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/weekly", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/challenges", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/settings", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/stats", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/messages", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/download", StringComparison.OrdinalIgnoreCase)
|
|
|| p.Equals("/accessibility", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/user/", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/room/", StringComparison.OrdinalIgnoreCase)
|
|
|| p.StartsWith("/image/", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Static files that make up the website. Matched by extension rather than by listing every
|
|
// page, so adding a page to Website/ does not silently 403 until someone remembers to come
|
|
// back here. Extensions are deliberately narrow - no .py/.bat/.json - so a stray file
|
|
// dropped in the web root is not served just because it is in the folder.
|
|
private static bool IsSiteAsset(string p)
|
|
{
|
|
// IsOpen is evaluated BEFORE IsAdminSurface in Invoke, so a bare extension match here
|
|
// would hand out the admin panel to anyone who asked for /admin/<something>.html.
|
|
// The admin surface is never a public site asset.
|
|
if (IsAdminSurface(p)) return false;
|
|
|
|
if (p.StartsWith("/assets/", StringComparison.OrdinalIgnoreCase)) return true;
|
|
|
|
foreach (var ext in SiteAssetExtensions)
|
|
if (p.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
private static readonly string[] SiteAssetExtensions =
|
|
{ ".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".woff", ".woff2" };
|
|
|
|
public static string ClientIp(HttpContext ctx) =>
|
|
ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault()
|
|
?? ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault()
|
|
?? ctx.Connection.RemoteIpAddress?.ToString()
|
|
?? "";
|
|
|
|
public async Task Invoke(HttpContext context)
|
|
{
|
|
string path = context.Request.Path.HasValue ? context.Request.Path.Value! : "/";
|
|
|
|
if (IsOpen(path))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
// ---- Admin surface: gated by the WEB TOKEN (browser, no DLL) ----
|
|
if (IsAdminSurface(path))
|
|
{
|
|
// The login page/API is where the token is entered, so it's always reachable.
|
|
if (IsAdminLoginEntry(path))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
// Rest of the panel (home + /admin/api/*) needs a live session token from login.
|
|
if (AdminGate.IsValid(context))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
// No gate cookie: APIs get a clean 403; pages fall back to the login screen.
|
|
if (path.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase)
|
|
|| path.StartsWith("/admin/api", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsync("");
|
|
}
|
|
else
|
|
{
|
|
context.Response.Redirect("/admin/v4/login");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---- Everything else: the GAME patch gate (per-request header) ----
|
|
if (!Enable)
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
// Accept EITHER form of auth on a game endpoint:
|
|
//
|
|
// (a) IN-GAME auth: the patch signature header, stamped by the DLL on every request.
|
|
// (b) WEB auth: a live admin session token. The admin panel is a browser (no DLL, so no
|
|
// patch header), but its dashboard legitimately calls game endpoints like
|
|
// /Accounts/account/me. A valid admin session proves an authenticated operator, so
|
|
// let those calls through too - otherwise the panel 403s on its own data.
|
|
//
|
|
// So: game client OR logged-in admin passes; a random browser with neither is denied.
|
|
string sent = context.Request.Headers[PatchHeaderName].FirstOrDefault() ?? "";
|
|
string expected = ConfigCampus.PatchSignature ?? "";
|
|
bool inGameAuth = !string.IsNullOrEmpty(expected)
|
|
&& CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(sent), Encoding.UTF8.GetBytes(expected));
|
|
|
|
if (inGameAuth || AdminGate.IsValid(context))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
// Genuine server-internal calls (health checks, loopback) don't come through the proxy and
|
|
// carry no forwarded IP, so they can't stamp the header - let those through. Real clients
|
|
// always arrive with a forwarded IP, so this never opens the gate for them.
|
|
string clientIp = ClientIp(context);
|
|
if (System.Net.IPAddress.TryParse(clientIp, out var parsed) && System.Net.IPAddress.IsLoopback(parsed))
|
|
{
|
|
await _next(context);
|
|
return;
|
|
}
|
|
|
|
var ua = context.Request.Headers["User-Agent"].FirstOrDefault();
|
|
Console.WriteLine($"[patchgate] DENIED {context.Request.Method} {path} from {clientIp} - patch header {(string.IsNullOrEmpty(sent) ? "absent" : "mismatch")} (UA={ua})");
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsync("");
|
|
}
|
|
}
|