Redo the auth system and implement rate limits
This commit is contained in:
@@ -59,8 +59,11 @@ public class Processing
|
|||||||
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
|
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
|
||||||
{
|
{
|
||||||
var image = EmoteCache.GetEmote(emotePayload.Code);
|
var image = EmoteCache.GetEmote(emotePayload.Code);
|
||||||
|
|
||||||
|
// The emote name should be safe, it is checked against a list from BTTV.
|
||||||
|
// Still sanitizing it for the extra safety.
|
||||||
if (image is { Failed: false })
|
if (image is { Failed: false })
|
||||||
return $"<span style\"height: 1em\"><img src=\"/emote/{emotePayload.Code}\"></span>";
|
return $"<span style\"height: 1em\"><img src=\"/emote/{Sanitizer.Sanitize(emotePayload.Code)}\"></span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
var colour = text.Foreground;
|
var colour = text.Foreground;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using ChatTwo.Http.MessageProtocol;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Web;
|
||||||
|
using ChatTwo.Http.MessageProtocol;
|
||||||
|
using ChatTwo.Util;
|
||||||
using Lumina.Data.Files;
|
using Lumina.Data.Files;
|
||||||
using WatsonWebserver.Core;
|
using WatsonWebserver.Core;
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ public class RouteController
|
|||||||
private readonly string AuthTemplate;
|
private readonly string AuthTemplate;
|
||||||
private readonly string ChatBoxTemplate;
|
private readonly string ChatBoxTemplate;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, long> RateLimit = [];
|
||||||
|
internal readonly ConcurrentDictionary<string, bool> SessionTokens = [];
|
||||||
|
|
||||||
public RouteController(Plugin plugin, ServerCore core)
|
public RouteController(Plugin plugin, ServerCore core)
|
||||||
{
|
{
|
||||||
Plugin = plugin;
|
Plugin = plugin;
|
||||||
@@ -28,15 +34,16 @@ public class RouteController
|
|||||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
||||||
|
Core.HostContext.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
||||||
|
|
||||||
// Post Auth
|
// Post Auth
|
||||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/switch", ReceiveChannelSwitch, ExceptionRoute);
|
||||||
|
|
||||||
// Server-Sent Events Route
|
// Server-Sent Events Route
|
||||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewServerEvent, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewSSEConnection, ExceptionRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
||||||
@@ -99,28 +106,24 @@ public class RouteController
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region PreAuthRoutes
|
#region PreAuthRoutes
|
||||||
private async Task AuthenticateClient(HttpContextBase ctx)
|
private async Task<bool> AuthenticateClient(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
var receivedPassword = ctx.Request.DataAsString ?? "";
|
var currentTick = Environment.TickCount64;
|
||||||
if (!receivedPassword.StartsWith("authcode="))
|
if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp < currentTick)
|
||||||
{
|
return await Redirect(ctx, "/", "message", "Rate limit active.");
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.Send("Authentication content invalid.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedPassword = receivedPassword[9..];
|
// The next request will be rate limited for 10s
|
||||||
if (receivedPassword != Plugin.Config.WebinterfacePassword)
|
RateLimit[ctx.Request.Source.IpAddress] = currentTick + 10_000;
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = 401;
|
|
||||||
await ctx.Response.Send("Authentication failed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Response.Headers.Add("Set-Cookie", $"auth={Plugin.Config.WebinterfacePassword}");
|
var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode");
|
||||||
ctx.Response.Headers.Add("Location", "/chat");
|
if (authcode == null || authcode != Plugin.Config.WebinterfacePassword)
|
||||||
ctx.Response.StatusCode = 302;
|
return await Redirect(ctx, "/", "message", "Authentication failed.");
|
||||||
await ctx.Response.Send();
|
|
||||||
|
var token = WebinterfaceUtil.GenerateSimpleToken();
|
||||||
|
SessionTokens.TryAdd(token, true);
|
||||||
|
|
||||||
|
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
||||||
|
return await Redirect(ctx, "/chat");
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -154,7 +157,25 @@ public class RouteController
|
|||||||
await ctx.Response.Send("Message was send to the channel.");
|
await ctx.Response.Send("Message was send to the channel.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NewServerEvent(HttpContextBase ctx)
|
private async Task ReceiveChannelSwitch(HttpContextBase ctx)
|
||||||
|
{
|
||||||
|
var content = ctx.Request.DataAsString;
|
||||||
|
if (content.Length is > 500 or < 2)
|
||||||
|
{
|
||||||
|
await ctx.Response.Send("Invalid length for a chat message received.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Plugin.Framework.RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
Plugin.ChatLogWindow.Chat = content;
|
||||||
|
Plugin.ChatLogWindow.SendChatBox(Plugin.ChatLogWindow.CurrentTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.Response.Send("Message was send to the channel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NewSSEConnection(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -183,4 +204,17 @@ public class RouteController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region RedirectHelper
|
||||||
|
public static async Task<bool> Redirect(HttpContextBase ctx, string location, params string[] parameter)
|
||||||
|
{
|
||||||
|
var query = "?";
|
||||||
|
foreach (var (content, index) in parameter.WithIndex())
|
||||||
|
query += index % 2 == 0 ? $"{content}=" : Uri.EscapeDataString(content);
|
||||||
|
|
||||||
|
ctx.Response.Headers.Add("Location", $"{location}{query}");
|
||||||
|
ctx.Response.StatusCode = 302;
|
||||||
|
return await ctx.Response.Send();
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using ChatTwo.Code;
|
using ChatTwo.Http.MessageProtocol;
|
||||||
using ChatTwo.Http.MessageProtocol;
|
|
||||||
using WatsonWebserver.Core;
|
using WatsonWebserver.Core;
|
||||||
using WatsonWebserver.Lite;
|
using WatsonWebserver.Lite;
|
||||||
using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
|
using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
|
||||||
@@ -107,16 +106,24 @@ public class ServerCore : IAsyncDisposable
|
|||||||
|
|
||||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
var cookie = ctx.Request.Headers.Get("Cookie") ?? "";
|
if (RouteController.SessionTokens.IsEmpty)
|
||||||
if (!cookie.StartsWith("auth=") || cookie[5..] != Plugin.Config.WebinterfacePassword)
|
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 401;
|
await RouteController.Redirect(ctx, "/", "message", "Invalid session token.");
|
||||||
await ctx.Response.Send("Your session auth code was invalid");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
||||||
|
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !RouteController.SessionTokens.ContainsKey(token))
|
||||||
|
await RouteController.Redirect(ctx, "/", "message", "Invalid session token.");
|
||||||
|
|
||||||
// Do nothing to let auth pass
|
// Do nothing to let auth pass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void InvalidateSessions()
|
||||||
|
{
|
||||||
|
RouteController.SessionTokens.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
public bool GetStats()
|
public bool GetStats()
|
||||||
{
|
{
|
||||||
return HostContext.IsListening;
|
return HostContext.IsListening;
|
||||||
|
|||||||
+19
-23
@@ -7,31 +7,27 @@ public static class WebserverUtil
|
|||||||
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
|
return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DisposableWrapper : IDisposable {
|
// From: https://github.com/NancyFx/Nancy/blob/master/src/Nancy/Request.cs#L176
|
||||||
private readonly Action Down;
|
/// <summary>
|
||||||
private bool Disposed;
|
/// Gets the cookie data from the provided string if it exists
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cookieHeader">The string containing cookie data</param>
|
||||||
|
/// <returns>Cookies dictionary</returns>
|
||||||
|
public static Dictionary<string, string> GetCookieData(string cookieHeader)
|
||||||
|
{
|
||||||
|
var cookieDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (cookieHeader.Length == 0)
|
||||||
|
return cookieDictionary;
|
||||||
|
|
||||||
public DisposableWrapper(Action down) {
|
var values = cookieHeader.TrimEnd(';').Split(';');
|
||||||
Down = down;
|
foreach (var parts in values.Select(c => c.Split(['='], 2)))
|
||||||
|
{
|
||||||
|
var cookieName = parts[0].Trim();
|
||||||
|
var cookieValue = parts.Length == 1 ? string.Empty : parts[1]; //Cookie attribute
|
||||||
|
|
||||||
|
cookieDictionary[cookieName] = cookieValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
return cookieDictionary;
|
||||||
if (Disposed) return;
|
|
||||||
|
|
||||||
Down();
|
|
||||||
Disposed = true;
|
|
||||||
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class AsyncUtils {
|
|
||||||
public static async Task<IDisposable> UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) {
|
|
||||||
await semaphore.WaitAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new WebserverUtil.DisposableWrapper(() => {
|
|
||||||
semaphore.Release();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISett
|
|||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "Do Not:");
|
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "Do Not:");
|
||||||
using (ImRaii.PushIndent(15.0f))
|
using (ImRaii.PushIndent(15.0f))
|
||||||
{
|
{
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Forward the ports used (9000 and 9001)");
|
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Forward the port used (9000)");
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Share your authentication code with anyone else");
|
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Share your authentication code with anyone else");
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Expect multi-boxing to work with this (only first client is tracked and utilised)");
|
ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Expect multi-boxing to work with this (only first client is tracked and utilised)");
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,10 @@ internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISett
|
|||||||
ImGui.TextUnformatted(Mutable.WebinterfacePassword);
|
ImGui.TextUnformatted(Mutable.WebinterfacePassword);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
|
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
|
||||||
|
{
|
||||||
Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
|
||||||
|
Plugin.ServerCore.InvalidateSessions();
|
||||||
|
}
|
||||||
|
|
||||||
ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Webinterface_Status);
|
ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Webinterface_Status);
|
||||||
using (ImRaii.PushIndent(10.0f))
|
using (ImRaii.PushIndent(10.0f))
|
||||||
|
|||||||
@@ -8,4 +8,12 @@ public class WebinterfaceUtil
|
|||||||
{
|
{
|
||||||
return (100000 + Rng.Next() % 100000).ToString()[1..];
|
return (100000 + Rng.Next() % 100000).ToString()[1..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string GenerateSimpleToken()
|
||||||
|
{
|
||||||
|
var buffer = new byte[15];
|
||||||
|
Rng.NextBytes(buffer);
|
||||||
|
|
||||||
|
return Convert.ToHexString(buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,4 +8,7 @@ public static class WrapperUtil
|
|||||||
{
|
{
|
||||||
Plugin.Notification.AddNotification(new Notification { Content = content, Type = type, Minimized = minimized });
|
Plugin.Notification.AddNotification(new Notification { Content = content, Type = type, Minimized = minimized });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<(T Value, int Index)> WithIndex<T>(this IEnumerable<T> list)
|
||||||
|
=> list.Select((x, i) => (x, i));
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user