From c0023614ad34a37d461460f96e47294a46ab26b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 21 Apr 2026 22:21:59 +0200 Subject: [PATCH] Harden junk mail image tracking protection --- .../Models/MailItem/HtmlPreviewVisitor.cs | 2 +- .../Models/HtmlPreviewVisitorTests.cs | 2 + .../HtmlAgilityPackExtensionsTests.cs | 53 +++++++++ .../MailRenderingPageViewModel.cs | 2 +- .../Extensions/HtmlAgilityPackExtensions.cs | 104 +++++++++++++++++- 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 Wino.Core.Tests/Services/HtmlAgilityPackExtensionsTests.cs diff --git a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs index f12cab52..5e5458a0 100644 --- a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs +++ b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs @@ -15,7 +15,7 @@ public class HtmlPreviewVisitor : MimeVisitor { private static readonly HashSet BlockedTags = new(StringComparer.OrdinalIgnoreCase) { - "script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form" + "script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form", "link" }; private static readonly HashSet AllowedDataImageMimeTypes = new(StringComparer.OrdinalIgnoreCase) diff --git a/Wino.Core.Tests/Models/HtmlPreviewVisitorTests.cs b/Wino.Core.Tests/Models/HtmlPreviewVisitorTests.cs index 7fe2b080..72848160 100644 --- a/Wino.Core.Tests/Models/HtmlPreviewVisitorTests.cs +++ b/Wino.Core.Tests/Models/HtmlPreviewVisitorTests.cs @@ -15,6 +15,7 @@ public class HtmlPreviewVisitorTests

hello

+ @@ -34,6 +35,7 @@ public class HtmlPreviewVisitorTests // Assert output.Should().NotContain(" + + + + + + + +
hello
+ + + + + + + + """); + + // Act + document.ClearImages(); + var output = document.DocumentNode.OuterHtml; + + // Assert + output.Should().Contain("id=\"embedded\" src=\"data:image/png;base64,AAAA\"", "embedded inline images should still render"); + output.Should().NotContain("id=\"remote\" src=", "remote img sources should be removed"); + output.Should().NotContain("background=\"https://tracker.example/body.png\"", "background attributes can be used as trackers"); + output.Should().NotContain("srcset=", "responsive image candidates should be removed because they may fetch remote trackers"); + output.Should().NotContain("https://tracker.example/inline.png", "inline CSS should not be allowed to fetch remote images"); + output.Should().Contain("color:blue", "non-image inline styling should be preserved"); + output.Should().NotContain("https://tracker.example/bg.png", "style blocks should not be allowed to fetch remote images"); + output.Should().Contain("color: red", "safe CSS declarations should remain"); + output.Should().NotContain("id=\"vml\" src=", "VML image references should be removed"); + output.Should().NotContain("id=\"svg-remote\" href=", "SVG image references should not fetch remote content"); + output.Should().Contain("id=\"svg-local\" href=\"#icon\"", "local fragment references should remain"); + } +} diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index 39e0dc21..f562bdbe 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -499,7 +499,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, // Use the received date from MailCopy if available, otherwise fall back to the sent date from MIME message CreationDate = initializedMailItemViewModel?.MailCopy.CreationDate ?? message.Date.DateTime; - // Automatically disable images for Junk folder to prevent pixel tracking. + // Automatically block remote image loading for Junk folder to reduce pixel tracking. // This can only work for selected mail item rendering, not for EML file rendering. if (initializedMailItemViewModel != null && initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk) diff --git a/Wino.Services/Extensions/HtmlAgilityPackExtensions.cs b/Wino.Services/Extensions/HtmlAgilityPackExtensions.cs index 786244f0..ef3affe3 100644 --- a/Wino.Services/Extensions/HtmlAgilityPackExtensions.cs +++ b/Wino.Services/Extensions/HtmlAgilityPackExtensions.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using HtmlAgilityPack; namespace Wino.Services.Extensions; @@ -8,16 +9,64 @@ namespace Wino.Services.Extensions; public static class HtmlAgilityPackExtensions { /// - /// Clears out the src attribute for all `img` and `v:fill` tags. + /// Clears passive remote image-loading hooks while preserving already-embedded inline images. /// - /// public static void ClearImages(this HtmlDocument document) { - if (document.DocumentNode.InnerHtml.Contains(" node.Name.Equals("image", StringComparison.OrdinalIgnoreCase) + || node.Name.Equals("feImage", StringComparison.OrdinalIgnoreCase) + || node.Name.Equals("use", StringComparison.OrdinalIgnoreCase); + + private static string SanitizeCss(string css) + { + if (string.IsNullOrWhiteSpace(css)) + { + return string.Empty; + } + + var sanitizedCss = Regex.Replace(css, @"(?is)url\s*\([^)]*\)", "none"); + sanitizedCss = Regex.Replace(sanitizedCss, @"(?is)image-set\s*\([^)]*\)", "none"); + sanitizedCss = Regex.Replace(sanitizedCss, @"(?is)@import\s+[^;]+;?", string.Empty); + + return sanitizedCss.Trim(); + } }