mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-05-15 06:02:28 +00:00
Implement POST request rate limiting (#490)
* Initial work for rate limiting * Refactor GameServerStartup and change default rate limit config * Adjust config naming and add Enabled option to global and override rate limits * Fix LBP3 republish bug * Fix bugs in rate limiting and allow for multiple matched overrides * Add this qualifier for private variable * Changes from self review
This commit is contained in:
parent
110d81f117
commit
3ad211e5c8
16 changed files with 451 additions and 206 deletions
|
@ -59,29 +59,27 @@ public class UserEndpoints : ApiEndpointController
|
|||
[HttpPost("user/inviteToken")]
|
||||
public async Task<IActionResult> CreateUserInviteToken()
|
||||
{
|
||||
if (Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration ||
|
||||
Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled)
|
||||
{
|
||||
if (!Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration &&
|
||||
!Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled)
|
||||
return this.NotFound();
|
||||
|
||||
string authHeader = this.Request.Headers["Authorization"];
|
||||
if (!string.IsNullOrWhiteSpace(authHeader))
|
||||
{
|
||||
string authToken = authHeader.Substring(authHeader.IndexOf(' ') + 1);
|
||||
if (string.IsNullOrWhiteSpace(authHeader)) return this.NotFound();
|
||||
|
||||
string authToken = authHeader[(authHeader.IndexOf(' ') + 1)..];
|
||||
|
||||
APIKey? apiKey = await this.database.APIKeys.FirstOrDefaultAsync(k => k.Key == authToken);
|
||||
if (apiKey == null) return this.StatusCode(403, null);
|
||||
|
||||
RegistrationToken token = new();
|
||||
token.Created = DateTime.Now;
|
||||
token.Token = CryptoHelper.GenerateAuthToken();
|
||||
RegistrationToken token = new()
|
||||
{
|
||||
Created = DateTime.Now,
|
||||
Token = CryptoHelper.GenerateAuthToken(),
|
||||
};
|
||||
|
||||
this.database.RegistrationTokens.Add(token);
|
||||
await this.database.SaveChangesAsync();
|
||||
|
||||
return Ok(token.Token);
|
||||
}
|
||||
|
||||
}
|
||||
return this.NotFound();
|
||||
return this.Ok(token.Token);
|
||||
}
|
||||
}
|
|
@ -60,12 +60,12 @@ public class PublishController : ControllerBase
|
|||
Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId);
|
||||
if (oldSlot == null)
|
||||
{
|
||||
Logger.Warn("Rejecting level reupload, could not find old slot", LogArea.Publish);
|
||||
Logger.Warn("Rejecting level republish, could not find old slot", LogArea.Publish);
|
||||
return this.NotFound();
|
||||
}
|
||||
if (oldSlot.CreatorId != user.UserId)
|
||||
{
|
||||
Logger.Warn("Rejecting level reupload, old slot's creator is not publishing user", LogArea.Publish);
|
||||
Logger.Warn("Rejecting level republish, old slot's creator is not publishing user", LogArea.Publish);
|
||||
return this.BadRequest();
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ public class PublishController : ControllerBase
|
|||
/// Endpoint actually used to publish a level
|
||||
/// </summary>
|
||||
[HttpPost("publish")]
|
||||
public async Task<IActionResult> Publish()
|
||||
public async Task<IActionResult> Publish([FromQuery] string? game)
|
||||
{
|
||||
(User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request);
|
||||
|
||||
|
@ -178,6 +178,22 @@ public class PublishController : ControllerBase
|
|||
return this.BadRequest();
|
||||
}
|
||||
|
||||
// I hate lbp3
|
||||
if (game != null)
|
||||
{
|
||||
GameVersion intendedVersion = FromAbbreviation(game);
|
||||
if (intendedVersion != GameVersion.Unknown && intendedVersion != slotVersion)
|
||||
{
|
||||
// Delete the useless rootLevel that lbp3 just uploaded
|
||||
if(slotVersion == GameVersion.LittleBigPlanet3)
|
||||
FileHelper.DeleteResource(slot.RootLevel);
|
||||
|
||||
slot.GameVersion = oldSlot.GameVersion;
|
||||
slot.RootLevel = oldSlot.RootLevel;
|
||||
slot.ResourceCollection = oldSlot.ResourceCollection;
|
||||
}
|
||||
}
|
||||
|
||||
oldSlot.Location.X = slot.Location.X;
|
||||
oldSlot.Location.Y = slot.Location.Y;
|
||||
|
||||
|
@ -277,6 +293,19 @@ public class PublishController : ControllerBase
|
|||
return this.Ok();
|
||||
}
|
||||
|
||||
private static GameVersion FromAbbreviation(string abbr)
|
||||
{
|
||||
return abbr switch
|
||||
{
|
||||
"lbp1" => GameVersion.LittleBigPlanet1,
|
||||
"lbp2" => GameVersion.LittleBigPlanet2,
|
||||
"lbp3" => GameVersion.LittleBigPlanet3,
|
||||
"lbpv" => GameVersion.LittleBigPlanetVita,
|
||||
"lbppsp" => GameVersion.LittleBigPlanetPSP,
|
||||
_ => GameVersion.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Slot?> getSlotFromBody()
|
||||
{
|
||||
this.Request.Body.Position = 0;
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Helpers;
|
||||
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
|
||||
|
||||
public class DigestMiddleware : Middleware
|
||||
{
|
||||
private readonly bool computeDigests;
|
||||
|
||||
public DigestMiddleware(RequestDelegate next, bool computeDigests) : base(next)
|
||||
{
|
||||
this.computeDigests = computeDigests;
|
||||
}
|
||||
|
||||
public override async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Client digest check.
|
||||
if (!context.Request.Cookies.TryGetValue("MM_AUTH", out string? authCookie) || authCookie == null)
|
||||
authCookie = string.Empty;
|
||||
string digestPath = context.Request.Path;
|
||||
#if !DEBUG
|
||||
const string url = "/LITTLEBIGPLANETPS3_XML";
|
||||
string strippedPath = digestPath.Contains(url) ? digestPath[url.Length..] : "";
|
||||
#endif
|
||||
Stream body = context.Request.Body;
|
||||
|
||||
bool usedAlternateDigestKey = false;
|
||||
|
||||
if (this.computeDigests && digestPath.StartsWith("/LITTLEBIGPLANETPS3_XML"))
|
||||
{
|
||||
// The game sets X-Digest-B on a resource upload instead of X-Digest-A
|
||||
string digestHeaderKey = "X-Digest-A";
|
||||
bool excludeBodyFromDigest = false;
|
||||
if (digestPath.Contains("/upload/"))
|
||||
{
|
||||
digestHeaderKey = "X-Digest-B";
|
||||
excludeBodyFromDigest = true;
|
||||
}
|
||||
|
||||
string clientRequestDigest = await CryptoHelper.ComputeDigest
|
||||
(digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey, excludeBodyFromDigest);
|
||||
|
||||
// Check the digest we've just calculated against the digest header if the game set the header. They should match.
|
||||
if (context.Request.Headers.TryGetValue(digestHeaderKey, out StringValues sentDigest))
|
||||
{
|
||||
if (clientRequestDigest != sentDigest)
|
||||
{
|
||||
// If we got here, the normal ServerDigestKey failed to validate. Lets try again with the alternate digest key.
|
||||
usedAlternateDigestKey = true;
|
||||
|
||||
// Reset the body stream
|
||||
body.Position = 0;
|
||||
|
||||
clientRequestDigest = await CryptoHelper.ComputeDigest
|
||||
(digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey, excludeBodyFromDigest);
|
||||
if (clientRequestDigest != sentDigest)
|
||||
{
|
||||
#if DEBUG
|
||||
Console.WriteLine("Digest failed");
|
||||
Console.WriteLine("digestKey: " + ServerConfiguration.Instance.DigestKey.PrimaryDigestKey);
|
||||
Console.WriteLine("altDigestKey: " + ServerConfiguration.Instance.DigestKey.AlternateDigestKey);
|
||||
Console.WriteLine("computed digest: " + clientRequestDigest);
|
||||
#endif
|
||||
// We still failed to validate. Abort the request.
|
||||
context.Response.StatusCode = 403;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !DEBUG
|
||||
// The game doesn't start sending digests until after the announcement so if it's not one of those requests
|
||||
// and it doesn't include a digest we need to reject the request
|
||||
else if (!ServerStatics.IsUnitTesting && !strippedPath.Equals("/login") && !strippedPath.Equals("/eula")
|
||||
&& !strippedPath.Equals("/announce") && !strippedPath.Equals("/status") && !strippedPath.Equals("/farc_hashes"))
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
context.Response.Headers.Add("X-Digest-B", clientRequestDigest);
|
||||
context.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
// This does the same as above, but for the response stream.
|
||||
await using MemoryStream responseBuffer = new();
|
||||
Stream oldResponseStream = context.Response.Body;
|
||||
context.Response.Body = responseBuffer;
|
||||
|
||||
await this.next(context); // Handle the request so we can get the server digest hash
|
||||
responseBuffer.Position = 0;
|
||||
|
||||
// Compute the server digest hash.
|
||||
if (this.computeDigests)
|
||||
{
|
||||
responseBuffer.Position = 0;
|
||||
|
||||
string digestKey = usedAlternateDigestKey
|
||||
? ServerConfiguration.Instance.DigestKey.AlternateDigestKey
|
||||
: ServerConfiguration.Instance.DigestKey.PrimaryDigestKey;
|
||||
|
||||
// Compute the digest for the response.
|
||||
string serverDigest = await CryptoHelper.ComputeDigest(context.Request.Path, authCookie, responseBuffer, digestKey);
|
||||
context.Response.Headers.Add("X-Digest-A", serverDigest);
|
||||
}
|
||||
|
||||
// Add a content-length header if it isn't present to disable response chunking
|
||||
if (!context.Response.Headers.ContainsKey("Content-Length"))
|
||||
context.Response.Headers.Add("Content-Length", responseBuffer.Length.ToString());
|
||||
|
||||
// Copy the buffered response to the actual response stream.
|
||||
responseBuffer.Position = 0;
|
||||
await responseBuffer.CopyToAsync(oldResponseStream);
|
||||
context.Response.Body = oldResponseStream;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.PlayerData;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
|
||||
|
||||
public class SetLastContactMiddleware : MiddlewareDBContext
|
||||
{
|
||||
public SetLastContactMiddleware(RequestDelegate next) : base(next)
|
||||
{ }
|
||||
|
||||
public override async Task InvokeAsync(HttpContext context, Database database)
|
||||
{
|
||||
#nullable enable
|
||||
// Log LastContact for LBP1. This is done on LBP2/3/V on a Match request.
|
||||
if (context.Request.Path.ToString().StartsWith("/LITTLEBIGPLANETPS3_XML"))
|
||||
{
|
||||
// We begin by grabbing a token from the request, if this is a LBPPS3_XML request of course.
|
||||
GameToken? gameToken = await database.GameTokenFromRequest(context.Request);
|
||||
|
||||
if (gameToken?.GameVersion == GameVersion.LittleBigPlanet1)
|
||||
// Ignore UserFromGameToken null because user must exist for a token to exist
|
||||
await LastContactHelper.SetLastContact
|
||||
(database, (await database.UserFromGameToken(gameToken))!, GameVersion.LittleBigPlanet1, gameToken.Platform);
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
await this.next(context);
|
||||
}
|
||||
}
|
|
@ -1,11 +1,9 @@
|
|||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Helpers;
|
||||
using LBPUnion.ProjectLighthouse.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.PlayerData;
|
||||
using LBPUnion.ProjectLighthouse.Serialization;
|
||||
using LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup;
|
||||
|
||||
|
@ -66,134 +64,9 @@ public class GameServerStartup
|
|||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseMiddleware<RequestLogMiddleware>();
|
||||
|
||||
// Digest check
|
||||
app.Use
|
||||
(
|
||||
async (context, next) =>
|
||||
{
|
||||
// Client digest check.
|
||||
if (!context.Request.Cookies.TryGetValue("MM_AUTH", out string? authCookie) || authCookie == null) authCookie = string.Empty;
|
||||
string digestPath = context.Request.Path;
|
||||
#if !DEBUG
|
||||
const string url = "/LITTLEBIGPLANETPS3_XML";
|
||||
string strippedPath = digestPath.Contains(url) ? digestPath[url.Length..] : "";
|
||||
#endif
|
||||
Stream body = context.Request.Body;
|
||||
|
||||
bool usedAlternateDigestKey = false;
|
||||
|
||||
if (computeDigests && digestPath.StartsWith("/LITTLEBIGPLANETPS3_XML"))
|
||||
{
|
||||
// The game sets X-Digest-B on a resource upload instead of X-Digest-A
|
||||
string digestHeaderKey = "X-Digest-A";
|
||||
bool excludeBodyFromDigest = false;
|
||||
if (digestPath.Contains("/upload/"))
|
||||
{
|
||||
digestHeaderKey = "X-Digest-B";
|
||||
excludeBodyFromDigest = true;
|
||||
}
|
||||
|
||||
string clientRequestDigest = await CryptoHelper.ComputeDigest
|
||||
(digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.PrimaryDigestKey, excludeBodyFromDigest);
|
||||
|
||||
// Check the digest we've just calculated against the digest header if the game set the header. They should match.
|
||||
if (context.Request.Headers.TryGetValue(digestHeaderKey, out StringValues sentDigest))
|
||||
{
|
||||
if (clientRequestDigest != sentDigest)
|
||||
{
|
||||
// If we got here, the normal ServerDigestKey failed to validate. Lets try again with the alternate digest key.
|
||||
usedAlternateDigestKey = true;
|
||||
|
||||
// Reset the body stream
|
||||
body.Position = 0;
|
||||
|
||||
clientRequestDigest = await CryptoHelper.ComputeDigest
|
||||
(digestPath, authCookie, body, ServerConfiguration.Instance.DigestKey.AlternateDigestKey, excludeBodyFromDigest);
|
||||
if (clientRequestDigest != sentDigest)
|
||||
{
|
||||
#if DEBUG
|
||||
Console.WriteLine("Digest failed");
|
||||
Console.WriteLine("digestKey: " + ServerConfiguration.Instance.DigestKey.PrimaryDigestKey);
|
||||
Console.WriteLine("altDigestKey: " + ServerConfiguration.Instance.DigestKey.AlternateDigestKey);
|
||||
Console.WriteLine("computed digest: " + clientRequestDigest);
|
||||
#endif
|
||||
// We still failed to validate. Abort the request.
|
||||
context.Response.StatusCode = 403;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !DEBUG
|
||||
// The game doesn't start sending digests until after the announcement so if it's not one of those requests
|
||||
// and it doesn't include a digest we need to reject the request
|
||||
else if (!ServerStatics.IsUnitTesting && !strippedPath.Equals("/login") && !strippedPath.Equals("/eula")
|
||||
&& !strippedPath.Equals("/announce") && !strippedPath.Equals("/status") && !strippedPath.Equals("/farc_hashes"))
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
context.Response.Headers.Add("X-Digest-B", clientRequestDigest);
|
||||
context.Request.Body.Position = 0;
|
||||
}
|
||||
|
||||
// This does the same as above, but for the response stream.
|
||||
await using MemoryStream responseBuffer = new();
|
||||
Stream oldResponseStream = context.Response.Body;
|
||||
context.Response.Body = responseBuffer;
|
||||
|
||||
await next(context); // Handle the request so we can get the server digest hash
|
||||
responseBuffer.Position = 0;
|
||||
|
||||
// Compute the server digest hash.
|
||||
if (computeDigests)
|
||||
{
|
||||
responseBuffer.Position = 0;
|
||||
|
||||
string digestKey = usedAlternateDigestKey
|
||||
? ServerConfiguration.Instance.DigestKey.AlternateDigestKey
|
||||
: ServerConfiguration.Instance.DigestKey.PrimaryDigestKey;
|
||||
|
||||
// Compute the digest for the response.
|
||||
string serverDigest = await CryptoHelper.ComputeDigest(context.Request.Path, authCookie, responseBuffer, digestKey);
|
||||
context.Response.Headers.Add("X-Digest-A", serverDigest);
|
||||
}
|
||||
|
||||
// Add a content-length header if it isn't present to disable response chunking
|
||||
if(!context.Response.Headers.ContainsKey("Content-Length"))
|
||||
context.Response.Headers.Add("Content-Length", responseBuffer.Length.ToString());
|
||||
|
||||
// Copy the buffered response to the actual response stream.
|
||||
responseBuffer.Position = 0;
|
||||
await responseBuffer.CopyToAsync(oldResponseStream);
|
||||
context.Response.Body = oldResponseStream;
|
||||
}
|
||||
);
|
||||
|
||||
app.Use
|
||||
(
|
||||
async (context, next) =>
|
||||
{
|
||||
#nullable enable
|
||||
// Log LastContact for LBP1. This is done on LBP2/3/V on a Match request.
|
||||
if (context.Request.Path.ToString().StartsWith("/LITTLEBIGPLANETPS3_XML"))
|
||||
{
|
||||
// We begin by grabbing a token from the request, if this is a LBPPS3_XML request of course.
|
||||
await using Database database = new(); // Gets nuked at the end of the scope
|
||||
GameToken? gameToken = await database.GameTokenFromRequest(context.Request);
|
||||
|
||||
if (gameToken != null && gameToken.GameVersion == GameVersion.LittleBigPlanet1)
|
||||
// Ignore UserFromGameToken null because user must exist for a token to exist
|
||||
await LastContactHelper.SetLastContact
|
||||
(database, (await database.UserFromGameToken(gameToken))!, GameVersion.LittleBigPlanet1, gameToken.Platform);
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
await next(context);
|
||||
}
|
||||
);
|
||||
app.UseMiddleware<DigestMiddleware>(computeDigests);
|
||||
app.UseMiddleware<SetLastContactMiddleware>();
|
||||
app.UseMiddleware<RateLimitMiddleware>();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<p>Failed to send email, please try again later</p>
|
||||
<p>Failed to send email, please try again later.</p>
|
||||
<p>If this issue persists please contact an Administrator</p>
|
||||
}
|
||||
|
||||
<a href="/login/sendVerificationEmail">
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
#nullable enable
|
||||
using System.Collections.Concurrent;
|
||||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Extensions;
|
||||
using LBPUnion.ProjectLighthouse.Helpers;
|
||||
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
|
||||
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
|
||||
|
@ -14,6 +16,9 @@ public class SendVerificationEmailPage : BaseLayout
|
|||
public SendVerificationEmailPage(Database database) : base(database)
|
||||
{}
|
||||
|
||||
// (User id, timestamp of last request + 30 seconds)
|
||||
private static readonly ConcurrentDictionary<int, long> recentlySentEmail = new();
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGet()
|
||||
|
@ -35,11 +40,24 @@ public class SendVerificationEmailPage : BaseLayout
|
|||
}
|
||||
#endif
|
||||
|
||||
EmailVerificationToken? verifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(v => v.UserId == user.UserId);
|
||||
// If user doesn't have a token or it is expired then regenerate
|
||||
if (verifyToken == null || DateTime.Now > verifyToken.ExpiresAt)
|
||||
// Remove expired entries
|
||||
for (int i = recentlySentEmail.Count - 1; i >= 0; i--)
|
||||
{
|
||||
verifyToken = new EmailVerificationToken
|
||||
KeyValuePair<int, long> entry = recentlySentEmail.ElementAt(i);
|
||||
if (TimeHelper.TimestampMillis > recentlySentEmail[user.UserId]) recentlySentEmail.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
if (recentlySentEmail.ContainsKey(user.UserId) && recentlySentEmail[user.UserId] > TimeHelper.TimestampMillis)
|
||||
{
|
||||
this.Success = true;
|
||||
return this.Page();
|
||||
}
|
||||
|
||||
string? existingToken = await this.Database.EmailVerificationTokens.Where(v => v.UserId == user.UserId).Select(v => v.EmailToken).FirstOrDefaultAsync();
|
||||
if(existingToken != null)
|
||||
this.Database.EmailVerificationTokens.RemoveWhere(t => t.EmailToken == existingToken);
|
||||
|
||||
EmailVerificationToken verifyToken = new()
|
||||
{
|
||||
UserId = user.UserId,
|
||||
User = user,
|
||||
|
@ -49,7 +67,6 @@ public class SendVerificationEmailPage : BaseLayout
|
|||
|
||||
this.Database.EmailVerificationTokens.Add(verifyToken);
|
||||
await this.Database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
string body = "Hello,\n\n" +
|
||||
$"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
|
||||
|
@ -58,6 +75,9 @@ public class SendVerificationEmailPage : BaseLayout
|
|||
|
||||
this.Success = SMTPHelper.SendEmail(user.EmailAddress, "Project Lighthouse Email Verification", body);
|
||||
|
||||
// Don't send another email for 30 seconds
|
||||
recentlySentEmail.TryAdd(user.UserId, TimeHelper.TimestampMillis + 30 * 1000);
|
||||
|
||||
return this.Page();
|
||||
}
|
||||
}
|
|
@ -81,6 +81,7 @@ public class WebsiteStartup
|
|||
app.UseMiddleware<HandlePageErrorMiddleware>();
|
||||
app.UseMiddleware<RequestLogMiddleware>();
|
||||
app.UseMiddleware<UserRequiredRedirectMiddleware>();
|
||||
app.UseMiddleware<RateLimitMiddleware>();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
|
|
|
@ -4,9 +4,6 @@ namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
|
|||
|
||||
public class AuthenticationConfiguration
|
||||
{
|
||||
[Obsolete("Obsolete. This feature has been removed.", true)]
|
||||
public bool BlockDeniedUsers { get; set; }
|
||||
|
||||
public bool RegistrationEnabled { get; set; } = true;
|
||||
public bool PrivateRegistration { get; set; } = false;
|
||||
public bool UseExternalAuth { get; set; }
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
|
||||
|
||||
public class RateLimitConfiguration
|
||||
{
|
||||
public RateLimitOptions GlobalOptions { get; set; } = new();
|
||||
|
||||
public Dictionary<string, RateLimitOptions> OverrideOptions { get; set; } = new()
|
||||
{
|
||||
{
|
||||
"/example/*/wildcard", new RateLimitOptions
|
||||
{
|
||||
RequestInterval = 5,
|
||||
RequestsPerInterval = 10,
|
||||
Enabled = true,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
8
ProjectLighthouse/Configuration/RateLimitOptions.cs
Normal file
8
ProjectLighthouse/Configuration/RateLimitOptions.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace LBPUnion.ProjectLighthouse.Configuration;
|
||||
|
||||
public class RateLimitOptions
|
||||
{
|
||||
public bool Enabled = false;
|
||||
public int RequestsPerInterval { get; set; } = 5;
|
||||
public int RequestInterval { get; set; } = 15;
|
||||
}
|
|
@ -23,7 +23,7 @@ public class ServerConfiguration
|
|||
// You can use an ObsoleteAttribute instead. Make sure you set it to error, though.
|
||||
//
|
||||
// Thanks for listening~
|
||||
public const int CurrentConfigVersion = 10;
|
||||
public const int CurrentConfigVersion = 11;
|
||||
|
||||
#region Meta
|
||||
|
||||
|
@ -39,7 +39,7 @@ public class ServerConfiguration
|
|||
|
||||
#region Setup
|
||||
|
||||
private static FileSystemWatcher fileWatcher;
|
||||
private static readonly FileSystemWatcher fileWatcher;
|
||||
|
||||
// ReSharper disable once NotNullMemberIsNotInitialized
|
||||
#pragma warning disable CS8618
|
||||
|
@ -99,8 +99,8 @@ public class ServerConfiguration
|
|||
}
|
||||
|
||||
// Set up reloading
|
||||
if (Instance.ConfigReloading)
|
||||
{
|
||||
if (!Instance.ConfigReloading) return;
|
||||
|
||||
Logger.Info("Setting up config reloading...", LogArea.Config);
|
||||
fileWatcher = new FileSystemWatcher
|
||||
{
|
||||
|
@ -113,11 +113,13 @@ public class ServerConfiguration
|
|||
|
||||
fileWatcher.EnableRaisingEvents = true; // begin watching
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS8618
|
||||
|
||||
private static void onConfigChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
fileWatcher.EnableRaisingEvents = false;
|
||||
Debug.Assert(e.Name == ConfigFileName);
|
||||
Logger.Info("Configuration file modified, reloading config...", LogArea.Config);
|
||||
Logger.Warn("Some changes may not apply; they will require a restart of Lighthouse.", LogArea.Config);
|
||||
|
@ -133,12 +135,17 @@ public class ServerConfiguration
|
|||
|
||||
Logger.Success("Successfully reloaded the configuration!", LogArea.Config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileWatcher.EnableRaisingEvents = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static INamingConvention namingConvention = CamelCaseNamingConvention.Instance;
|
||||
|
||||
private static ServerConfiguration? fromFile(string path)
|
||||
{
|
||||
IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).Build();
|
||||
IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).IgnoreUnmatchedProperties().Build();
|
||||
|
||||
string text;
|
||||
|
||||
|
@ -163,12 +170,6 @@ public class ServerConfiguration
|
|||
|
||||
#endregion
|
||||
|
||||
// TODO: Find a way to properly remove config options
|
||||
// YamlDotNet hates that and it's fucking annoying.
|
||||
// This seriously sucks. /rant
|
||||
[Obsolete("Obsolete. Use the Website/GameApi/Api listen URLS instead.")]
|
||||
public string ListenUrl { get; set; } = "http://localhost:10060";
|
||||
|
||||
public string WebsiteListenUrl { get; set; } = "http://localhost:10060";
|
||||
public string GameApiListenUrl { get; set; } = "http://localhost:10061";
|
||||
public string ApiListenUrl { get; set; } = "http://localhost:10062";
|
||||
|
@ -197,4 +198,5 @@ public class ServerConfiguration
|
|||
public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new();
|
||||
public WebsiteConfiguration WebsiteConfiguration { get; set; } = new();
|
||||
public CustomizationConfiguration Customization { get; set; } = new();
|
||||
public RateLimitConfiguration RateLimitConfiguration { get; set; } = new();
|
||||
}
|
|
@ -231,6 +231,15 @@ public static class FileHelper
|
|||
|
||||
public static bool ResourceExists(string hash) => File.Exists(GetResourcePath(hash));
|
||||
|
||||
public static void DeleteResource(string hash)
|
||||
{
|
||||
// sanity check so someone doesn't somehow delete the entire resource folder
|
||||
if (ResourceExists(hash) && (File.GetAttributes(hash) & FileAttributes.Directory) != FileAttributes.Directory)
|
||||
{
|
||||
File.Delete(GetResourcePath(hash));
|
||||
}
|
||||
}
|
||||
|
||||
public static int ResourceSize(string hash)
|
||||
{
|
||||
try
|
||||
|
|
|
@ -24,4 +24,5 @@ public enum LogArea
|
|||
Publish,
|
||||
Maintenance,
|
||||
Score,
|
||||
RateLimit,
|
||||
}
|
139
ProjectLighthouse/Middlewares/RateLimitMiddleware.cs
Normal file
139
ProjectLighthouse/Middlewares/RateLimitMiddleware.cs
Normal file
|
@ -0,0 +1,139 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Helpers;
|
||||
using LBPUnion.ProjectLighthouse.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Middlewares;
|
||||
|
||||
public class RateLimitMiddleware : MiddlewareDBContext
|
||||
{
|
||||
|
||||
// (userId, requestData)
|
||||
private static readonly ConcurrentDictionary<IPAddress, List<LighthouseRequest>> recentRequests = new();
|
||||
|
||||
public RateLimitMiddleware(RequestDelegate next) : base(next)
|
||||
{ }
|
||||
|
||||
public override async Task InvokeAsync(HttpContext ctx, Database database)
|
||||
{
|
||||
// We only want to rate limit POST requests
|
||||
if (ctx.Request.Method != "POST")
|
||||
{
|
||||
await this.next(ctx);
|
||||
return;
|
||||
}
|
||||
IPAddress? address = ctx.Connection.RemoteIpAddress;
|
||||
if (address == null)
|
||||
{
|
||||
await this.next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
PathString path = RemoveTrailingSlash(ctx.Request.Path.ToString());
|
||||
|
||||
RateLimitOptions? options = GetRateLimitOverride(path);
|
||||
|
||||
if (!IsRateLimitEnabled(options))
|
||||
{
|
||||
await this.next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveExpiredEntries();
|
||||
|
||||
if (GetNumRequestsForPath(address, path) >= GetMaxNumRequests(options))
|
||||
{
|
||||
Logger.Info($"Request limit reached for {address.ToString()} ({ctx.Request.Path})", LogArea.RateLimit);
|
||||
ctx.Response.Headers.Add("Retry-After", "" + Math.Ceiling((recentRequests[address][0].Expiration - TimeHelper.TimestampMillis) / 1000f));
|
||||
ctx.Response.StatusCode = 429;
|
||||
return;
|
||||
}
|
||||
|
||||
LogRequest(address, path, options);
|
||||
|
||||
// Handle request as normal
|
||||
await this.next(ctx);
|
||||
}
|
||||
|
||||
private static int GetMaxNumRequests(RateLimitOptions? options) => options?.RequestsPerInterval ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.RequestsPerInterval;
|
||||
|
||||
private static bool IsRateLimitEnabled(RateLimitOptions? options) => options?.Enabled ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.Enabled;
|
||||
|
||||
private static long GetRequestInterval(RateLimitOptions? options) => options?.RequestInterval ?? ServerConfiguration.Instance.RateLimitConfiguration.GlobalOptions.RequestInterval;
|
||||
|
||||
private static RateLimitOptions? GetRateLimitOverride(PathString path)
|
||||
{
|
||||
Dictionary<string, RateLimitOptions> overrides = ServerConfiguration.Instance.RateLimitConfiguration.OverrideOptions;
|
||||
List<string> matchingOptions = overrides.Keys.Where(s => new Regex("^" + s.Replace("/", @"\/").Replace("*", ".*") + "$").Match(path).Success).ToList();
|
||||
if (matchingOptions.Count == 0) return null;
|
||||
// return 0 for equal, -1 for a, and 1 for b
|
||||
matchingOptions.Sort((a, b) =>
|
||||
{
|
||||
int aWeight = 100;
|
||||
int bWeight = 100;
|
||||
if (a.Contains('*')) aWeight -= 20;
|
||||
if (b.Contains('*')) bWeight -= 20;
|
||||
|
||||
aWeight += a.Length;
|
||||
bWeight += b.Length;
|
||||
|
||||
if (aWeight > bWeight) return -1;
|
||||
|
||||
if (bWeight > aWeight) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
return overrides[matchingOptions.First()];
|
||||
}
|
||||
|
||||
private static void LogRequest(IPAddress address, PathString path, RateLimitOptions? options)
|
||||
{
|
||||
recentRequests.GetOrAdd(address, new List<LighthouseRequest>()).Add(LighthouseRequest.Create(path, GetRequestInterval(options) * 1000 + TimeHelper.TimestampMillis));
|
||||
}
|
||||
|
||||
private static void RemoveExpiredEntries()
|
||||
{
|
||||
for (int i = recentRequests.Count - 1; i >= 0; i--)
|
||||
{
|
||||
IPAddress address = recentRequests.ElementAt(i).Key;
|
||||
recentRequests[address].RemoveAll(r => TimeHelper.TimestampMillis > r.Expiration);
|
||||
// Remove empty entries
|
||||
if (recentRequests[address].Count == 0) recentRequests.TryRemove(address, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RemoveTrailingSlash(string s) => s.TrimEnd('/').TrimEnd('\\');
|
||||
|
||||
private static int GetNumRequestsForPath(IPAddress address, PathString path)
|
||||
{
|
||||
if (!recentRequests.ContainsKey(address)) return 0;
|
||||
|
||||
List<LighthouseRequest> requests = recentRequests[address];
|
||||
return requests.Count(r => r.Path == path);
|
||||
}
|
||||
|
||||
private class LighthouseRequest
|
||||
{
|
||||
public PathString Path { get; private init; } = "";
|
||||
public long Expiration { get; private init; }
|
||||
|
||||
public static LighthouseRequest Create(PathString path, long expiration)
|
||||
{
|
||||
LighthouseRequest request = new()
|
||||
{
|
||||
Path = path,
|
||||
Expiration = expiration,
|
||||
};
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -10,8 +10,8 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Pfim" Version="0.11.1"/>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3"/>
|
||||
<PackageReference Include="Pfim" Version="0.11.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="Discord.Net.Webhook" Version="3.8.1" />
|
||||
<PackageReference Include="InfluxDB.Client" Version="4.5.0" />
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue