diff --git a/ChatTwo/Http/Processing.cs b/ChatTwo/Http/Processing.cs index ccc3bdb..183dc02 100644 --- a/ChatTwo/Http/Processing.cs +++ b/ChatTwo/Http/Processing.cs @@ -59,8 +59,11 @@ public class Processing if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes) { 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 }) - return $""; + return $""; } var colour = text.Foreground; diff --git a/ChatTwo/Http/RouteController.cs b/ChatTwo/Http/RouteController.cs index 8d52a0b..99e1226 100644 --- a/ChatTwo/Http/RouteController.cs +++ b/ChatTwo/Http/RouteController.cs @@ -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 WatsonWebserver.Core; @@ -14,6 +17,9 @@ public class RouteController private readonly string AuthTemplate; private readonly string ChatBoxTemplate; + private readonly ConcurrentDictionary RateLimit = []; + internal readonly ConcurrentDictionary SessionTokens = []; + public RouteController(Plugin plugin, ServerCore core) { 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/fonticon_ps5.tex", GetTexData, 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); // Post Auth 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.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute); + Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/switch", ReceiveChannelSwitch, ExceptionRoute); // 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 _) @@ -99,28 +106,24 @@ public class RouteController #endregion #region PreAuthRoutes - private async Task AuthenticateClient(HttpContextBase ctx) + private async Task AuthenticateClient(HttpContextBase ctx) { - var receivedPassword = ctx.Request.DataAsString ?? ""; - if (!receivedPassword.StartsWith("authcode=")) - { - ctx.Response.StatusCode = 400; - await ctx.Response.Send("Authentication content invalid."); - return; - } + var currentTick = Environment.TickCount64; + if (RateLimit.TryGetValue(ctx.Request.Source.IpAddress, out var timestamp) && timestamp < currentTick) + return await Redirect(ctx, "/", "message", "Rate limit active."); - receivedPassword = receivedPassword[9..]; - if (receivedPassword != Plugin.Config.WebinterfacePassword) - { - ctx.Response.StatusCode = 401; - await ctx.Response.Send("Authentication failed."); - return; - } + // The next request will be rate limited for 10s + RateLimit[ctx.Request.Source.IpAddress] = currentTick + 10_000; - ctx.Response.Headers.Add("Set-Cookie", $"auth={Plugin.Config.WebinterfacePassword}"); - ctx.Response.Headers.Add("Location", "/chat"); - ctx.Response.StatusCode = 302; - await ctx.Response.Send(); + var authcode = HttpUtility.ParseQueryString(ctx.Request.DataAsString ?? "").Get("authcode"); + if (authcode == null || authcode != Plugin.Config.WebinterfacePassword) + return await Redirect(ctx, "/", "message", "Authentication failed."); + + var token = WebinterfaceUtil.GenerateSimpleToken(); + SessionTokens.TryAdd(token, true); + + ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}"); + return await Redirect(ctx, "/chat"); } #endregion @@ -154,7 +157,25 @@ public class RouteController 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 { @@ -183,4 +204,17 @@ public class RouteController } } #endregion + + #region RedirectHelper + public static async Task 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 } \ No newline at end of file diff --git a/ChatTwo/Http/ServerCore.cs b/ChatTwo/Http/ServerCore.cs index 04438bc..84a3722 100644 --- a/ChatTwo/Http/ServerCore.cs +++ b/ChatTwo/Http/ServerCore.cs @@ -1,5 +1,4 @@ -using ChatTwo.Code; -using ChatTwo.Http.MessageProtocol; +using ChatTwo.Http.MessageProtocol; using WatsonWebserver.Core; using WatsonWebserver.Lite; using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs; @@ -107,16 +106,24 @@ public class ServerCore : IAsyncDisposable private async Task CheckAuthenticationCookie(HttpContextBase ctx) { - var cookie = ctx.Request.Headers.Get("Cookie") ?? ""; - if (!cookie.StartsWith("auth=") || cookie[5..] != Plugin.Config.WebinterfacePassword) + if (RouteController.SessionTokens.IsEmpty) { - ctx.Response.StatusCode = 401; - await ctx.Response.Send("Your session auth code was invalid"); + await RouteController.Redirect(ctx, "/", "message", "Invalid session token."); + 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 } + public void InvalidateSessions() + { + RouteController.SessionTokens.Clear(); + } + public bool GetStats() { return HostContext.IsListening; diff --git a/ChatTwo/Http/Util.cs b/ChatTwo/Http/Util.cs index 0161262..d512ce3 100644 --- a/ChatTwo/Http/Util.cs +++ b/ChatTwo/Http/Util.cs @@ -7,31 +7,27 @@ public static class WebserverUtil return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false); } - public class DisposableWrapper : IDisposable { - private readonly Action Down; - private bool Disposed; + // From: https://github.com/NancyFx/Nancy/blob/master/src/Nancy/Request.cs#L176 + /// + /// Gets the cookie data from the provided string if it exists + /// + /// The string containing cookie data + /// Cookies dictionary + public static Dictionary GetCookieData(string cookieHeader) + { + var cookieDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (cookieHeader.Length == 0) + return cookieDictionary; - public DisposableWrapper(Action down) { - Down = down; + var values = cookieHeader.TrimEnd(';').Split(';'); + 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() { - if (Disposed) return; - - Down(); - Disposed = true; - - GC.SuppressFinalize(this); - } - } -} - -public static class AsyncUtils { - public static async Task UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) { - await semaphore.WaitAsync(ct).ConfigureAwait(false); - - return new WebserverUtil.DisposableWrapper(() => { - semaphore.Release(); - }); + return cookieDictionary; } } \ No newline at end of file diff --git a/ChatTwo/Ui/SettingsTabs/Webinterface.cs b/ChatTwo/Ui/SettingsTabs/Webinterface.cs index badb3c5..536c4ba 100644 --- a/ChatTwo/Ui/SettingsTabs/Webinterface.cs +++ b/ChatTwo/Ui/SettingsTabs/Webinterface.cs @@ -26,7 +26,7 @@ internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISett ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "Do Not:"); 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, "- 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.SameLine(); if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip)) + { Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode(); + Plugin.ServerCore.InvalidateSessions(); + } ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Webinterface_Status); using (ImRaii.PushIndent(10.0f)) diff --git a/ChatTwo/Util/WebinterfaceUtil.cs b/ChatTwo/Util/WebinterfaceUtil.cs index 3ae93ca..43e1842 100644 --- a/ChatTwo/Util/WebinterfaceUtil.cs +++ b/ChatTwo/Util/WebinterfaceUtil.cs @@ -8,4 +8,12 @@ public class WebinterfaceUtil { return (100000 + Rng.Next() % 100000).ToString()[1..]; } + + public static string GenerateSimpleToken() + { + var buffer = new byte[15]; + Rng.NextBytes(buffer); + + return Convert.ToHexString(buffer); + } } \ No newline at end of file diff --git a/ChatTwo/Util/WrapperUtil.cs b/ChatTwo/Util/WrapperUtil.cs index 24d04a8..cb0c199 100644 --- a/ChatTwo/Util/WrapperUtil.cs +++ b/ChatTwo/Util/WrapperUtil.cs @@ -8,4 +8,7 @@ public static class WrapperUtil { Plugin.Notification.AddNotification(new Notification { Content = content, Type = type, Minimized = minimized }); } + + public static IEnumerable<(T Value, int Index)> WithIndex(this IEnumerable list) + => list.Select((x, i) => (x, i)); } \ No newline at end of file