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:
Josh 2022-09-24 17:18:28 -05:00 committed by GitHub
parent 110d81f117
commit 3ad211e5c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 451 additions and 206 deletions

View file

@ -59,29 +59,27 @@ public class UserEndpoints : ApiEndpointController
[HttpPost("user/inviteToken")] [HttpPost("user/inviteToken")]
public async Task<IActionResult> CreateUserInviteToken() public async Task<IActionResult> CreateUserInviteToken()
{ {
if (Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration || if (!Configuration.ServerConfiguration.Instance.Authentication.PrivateRegistration &&
Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled) !Configuration.ServerConfiguration.Instance.Authentication.RegistrationEnabled)
return this.NotFound();
string authHeader = this.Request.Headers["Authorization"];
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()
{ {
Created = DateTime.Now,
Token = CryptoHelper.GenerateAuthToken(),
};
string authHeader = this.Request.Headers["Authorization"]; this.database.RegistrationTokens.Add(token);
if (!string.IsNullOrWhiteSpace(authHeader)) await this.database.SaveChangesAsync();
{
string authToken = authHeader.Substring(authHeader.IndexOf(' ') + 1);
APIKey? apiKey = await this.database.APIKeys.FirstOrDefaultAsync(k => k.Key == authToken); return this.Ok(token.Token);
if (apiKey == null) return this.StatusCode(403, null);
RegistrationToken token = new();
token.Created = DateTime.Now;
token.Token = CryptoHelper.GenerateAuthToken();
this.database.RegistrationTokens.Add(token);
await this.database.SaveChangesAsync();
return Ok(token.Token);
}
}
return this.NotFound();
} }
} }

View file

@ -60,12 +60,12 @@ public class PublishController : ControllerBase
Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId); Slot? oldSlot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == slot.SlotId);
if (oldSlot == null) 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(); return this.NotFound();
} }
if (oldSlot.CreatorId != user.UserId) 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(); return this.BadRequest();
} }
} }
@ -87,7 +87,7 @@ public class PublishController : ControllerBase
/// Endpoint actually used to publish a level /// Endpoint actually used to publish a level
/// </summary> /// </summary>
[HttpPost("publish")] [HttpPost("publish")]
public async Task<IActionResult> Publish() public async Task<IActionResult> Publish([FromQuery] string? game)
{ {
(User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request); (User, GameToken)? userAndToken = await this.database.UserAndGameTokenFromRequest(this.Request);
@ -178,6 +178,22 @@ public class PublishController : ControllerBase
return this.BadRequest(); 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.X = slot.Location.X;
oldSlot.Location.Y = slot.Location.Y; oldSlot.Location.Y = slot.Location.Y;
@ -277,6 +293,19 @@ public class PublishController : ControllerBase
return this.Ok(); 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() private async Task<Slot?> getSlotFromBody()
{ {
this.Request.Body.Position = 0; this.Request.Body.Position = 0;

View file

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

View file

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

View file

@ -1,11 +1,9 @@
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Middlewares; using LBPUnion.ProjectLighthouse.Middlewares;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.Serialization; using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Primitives;
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Startup;
@ -66,134 +64,9 @@ public class GameServerStartup
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseMiddleware<RequestLogMiddleware>(); app.UseMiddleware<RequestLogMiddleware>();
app.UseMiddleware<DigestMiddleware>(computeDigests);
// Digest check app.UseMiddleware<SetLastContactMiddleware>();
app.Use app.UseMiddleware<RateLimitMiddleware>();
(
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.UseRouting(); app.UseRouting();

View file

@ -13,7 +13,8 @@
} }
else 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"> <a href="/login/sendVerificationEmail">

View file

@ -1,5 +1,7 @@
#nullable enable #nullable enable
using System.Collections.Concurrent;
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles; using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email; using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
@ -14,6 +16,9 @@ public class SendVerificationEmailPage : BaseLayout
public SendVerificationEmailPage(Database database) : base(database) 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 bool Success { get; set; }
public async Task<IActionResult> OnGet() public async Task<IActionResult> OnGet()
@ -35,22 +40,34 @@ public class SendVerificationEmailPage : BaseLayout
} }
#endif #endif
EmailVerificationToken? verifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(v => v.UserId == user.UserId); // Remove expired entries
// If user doesn't have a token or it is expired then regenerate for (int i = recentlySentEmail.Count - 1; i >= 0; i--)
if (verifyToken == null || DateTime.Now > verifyToken.ExpiresAt)
{ {
verifyToken = new EmailVerificationToken KeyValuePair<int, long> entry = recentlySentEmail.ElementAt(i);
{ if (TimeHelper.TimestampMillis > recentlySentEmail[user.UserId]) recentlySentEmail.TryRemove(entry.Key, out _);
UserId = user.UserId,
User = user,
EmailToken = CryptoHelper.GenerateAuthToken(),
ExpiresAt = DateTime.Now.AddHours(6),
};
this.Database.EmailVerificationTokens.Add(verifyToken);
await this.Database.SaveChangesAsync();
} }
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,
EmailToken = CryptoHelper.GenerateAuthToken(),
ExpiresAt = DateTime.Now.AddHours(6),
};
this.Database.EmailVerificationTokens.Add(verifyToken);
await this.Database.SaveChangesAsync();
string body = "Hello,\n\n" + 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" + $"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
$"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" + $"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" +
@ -58,6 +75,9 @@ public class SendVerificationEmailPage : BaseLayout
this.Success = SMTPHelper.SendEmail(user.EmailAddress, "Project Lighthouse Email Verification", body); 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(); return this.Page();
} }
} }

View file

@ -81,6 +81,7 @@ public class WebsiteStartup
app.UseMiddleware<HandlePageErrorMiddleware>(); app.UseMiddleware<HandlePageErrorMiddleware>();
app.UseMiddleware<RequestLogMiddleware>(); app.UseMiddleware<RequestLogMiddleware>();
app.UseMiddleware<UserRequiredRedirectMiddleware>(); app.UseMiddleware<UserRequiredRedirectMiddleware>();
app.UseMiddleware<RateLimitMiddleware>();
app.UseRouting(); app.UseRouting();

View file

@ -4,9 +4,6 @@ namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
public class AuthenticationConfiguration public class AuthenticationConfiguration
{ {
[Obsolete("Obsolete. This feature has been removed.", true)]
public bool BlockDeniedUsers { get; set; }
public bool RegistrationEnabled { get; set; } = true; public bool RegistrationEnabled { get; set; } = true;
public bool PrivateRegistration { get; set; } = false; public bool PrivateRegistration { get; set; } = false;
public bool UseExternalAuth { get; set; } public bool UseExternalAuth { get; set; }

View file

@ -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,
}
},
};
}

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

View file

@ -23,7 +23,7 @@ public class ServerConfiguration
// You can use an ObsoleteAttribute instead. Make sure you set it to error, though. // You can use an ObsoleteAttribute instead. Make sure you set it to error, though.
// //
// Thanks for listening~ // Thanks for listening~
public const int CurrentConfigVersion = 10; public const int CurrentConfigVersion = 11;
#region Meta #region Meta
@ -39,7 +39,7 @@ public class ServerConfiguration
#region Setup #region Setup
private static FileSystemWatcher fileWatcher; private static readonly FileSystemWatcher fileWatcher;
// ReSharper disable once NotNullMemberIsNotInitialized // ReSharper disable once NotNullMemberIsNotInitialized
#pragma warning disable CS8618 #pragma warning disable CS8618
@ -99,46 +99,53 @@ public class ServerConfiguration
} }
// Set up reloading // Set up reloading
if (Instance.ConfigReloading) if (!Instance.ConfigReloading) return;
Logger.Info("Setting up config reloading...", LogArea.Config);
fileWatcher = new FileSystemWatcher
{ {
Logger.Info("Setting up config reloading...", LogArea.Config); Path = Environment.CurrentDirectory,
fileWatcher = new FileSystemWatcher Filter = ConfigFileName,
{ NotifyFilter = NotifyFilters.LastWrite, // only watch for writes to config file
Path = Environment.CurrentDirectory, };
Filter = ConfigFileName,
NotifyFilter = NotifyFilters.LastWrite, // only watch for writes to config file
};
fileWatcher.Changed += onConfigChanged; // add event handler fileWatcher.Changed += onConfigChanged; // add event handler
fileWatcher.EnableRaisingEvents = true; // begin watching fileWatcher.EnableRaisingEvents = true; // begin watching
}
} }
#pragma warning restore CS8618 #pragma warning restore CS8618
private static void onConfigChanged(object sender, FileSystemEventArgs e) private static void onConfigChanged(object sender, FileSystemEventArgs e)
{ {
Debug.Assert(e.Name == ConfigFileName); try
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);
ServerConfiguration? configuration = fromFile(ConfigFileName);
if (configuration == null)
{ {
Logger.Warn("The new configuration was unable to be loaded for some reason. The old config has been kept.", LogArea.Config); fileWatcher.EnableRaisingEvents = false;
return; 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);
ServerConfiguration? configuration = fromFile(ConfigFileName);
if (configuration == null)
{
Logger.Warn("The new configuration was unable to be loaded for some reason. The old config has been kept.", LogArea.Config);
return;
}
Instance = configuration;
Logger.Success("Successfully reloaded the configuration!", LogArea.Config);
}
finally
{
fileWatcher.EnableRaisingEvents = true;
} }
Instance = configuration;
Logger.Success("Successfully reloaded the configuration!", LogArea.Config);
} }
private static INamingConvention namingConvention = CamelCaseNamingConvention.Instance; private static INamingConvention namingConvention = CamelCaseNamingConvention.Instance;
private static ServerConfiguration? fromFile(string path) private static ServerConfiguration? fromFile(string path)
{ {
IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).Build(); IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(namingConvention).IgnoreUnmatchedProperties().Build();
string text; string text;
@ -163,12 +170,6 @@ public class ServerConfiguration
#endregion #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 WebsiteListenUrl { get; set; } = "http://localhost:10060";
public string GameApiListenUrl { get; set; } = "http://localhost:10061"; public string GameApiListenUrl { get; set; } = "http://localhost:10061";
public string ApiListenUrl { get; set; } = "http://localhost:10062"; public string ApiListenUrl { get; set; } = "http://localhost:10062";
@ -197,4 +198,5 @@ public class ServerConfiguration
public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new(); public UserGeneratedContentLimitConfiguration UserGeneratedContentLimits { get; set; } = new();
public WebsiteConfiguration WebsiteConfiguration { get; set; } = new(); public WebsiteConfiguration WebsiteConfiguration { get; set; } = new();
public CustomizationConfiguration Customization { get; set; } = new(); public CustomizationConfiguration Customization { get; set; } = new();
public RateLimitConfiguration RateLimitConfiguration { get; set; } = new();
} }

View file

@ -231,6 +231,15 @@ public static class FileHelper
public static bool ResourceExists(string hash) => File.Exists(GetResourcePath(hash)); 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) public static int ResourceSize(string hash)
{ {
try try

View file

@ -24,4 +24,5 @@ public enum LogArea
Publish, Publish,
Maintenance, Maintenance,
Score, Score,
RateLimit,
} }

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

View file

@ -10,8 +10,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Pfim" Version="0.11.1"/> <PackageReference Include="Pfim" Version="0.11.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3"/> <PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="Discord.Net.Webhook" Version="3.8.1" /> <PackageReference Include="Discord.Net.Webhook" Version="3.8.1" />
<PackageReference Include="InfluxDB.Client" Version="4.5.0" /> <PackageReference Include="InfluxDB.Client" Version="4.5.0" />
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" /> <PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />