From 2ce30383d9e3ada440ad33235fc118d63fcd3eb9 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Sat, 2 May 2026 02:50:29 +0200 Subject: [PATCH] Refuse to write emote cache files outside the cache directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding H-1. Defense-in-depth fix for EmoteCache.LoadAsync, which interpolated the BetterTTV-supplied Id and ImageType straight into a Path.Join. HTTPS protects the wire today, but a compromised upstream that hands back Id values like "../foo" would land outside EmoteCacheV1, anywhere under pluginConfigs that the plugin can write. Resolve the candidate path with Path.GetFullPath, then assert it starts with the cache directory plus a directory separator (so "EmoteCacheV1Sibling" cannot match "EmoteCacheV1"). Throw InvalidOperationException on mismatch — the surrounding load already swallows exceptions and logs them, so a tampered entry becomes a visible error in the log instead of a silent miss. --- ChatTwo/EmoteCache.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs index 634927d..52ee38f 100644 --- a/ChatTwo/EmoteCache.cs +++ b/ChatTwo/EmoteCache.cs @@ -168,10 +168,19 @@ public static class EmoteCache internal async Task LoadAsync(Emote emote) { - var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1"); + // BetterTTV-supplied Id and ImageType are interpolated straight + // into the filename. HTTPS protects the wire, but a compromised + // upstream could still hand us "../foo" and write into the + // pluginConfigs root (or worse). Resolve the candidate path and + // refuse anything that escapes the cache directory. + var dir = Path.GetFullPath(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")); Directory.CreateDirectory(dir); - var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}"); + var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar; + var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}")); + if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal)) + throw new InvalidOperationException($"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}"); + if (File.Exists(filePath)) { RawData = await File.ReadAllBytesAsync(filePath);