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