MailSharp / MailSharp.MailClient / Services / ImageSanitizer.cs
Code · 83 lines · 3461 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83using System.Text.RegularExpressions;

namespace MailSharp.MailClient.Services;

// When external images are blocked for a sender, <img> src attributes are replaced with a
// harmless placeholder. When allowed, the src is rewritten to a local
// /cache/images/{emailAddress}/{messageId}/{01, 02, ...}.ext path - numbered in the order the
// images appear in the message, so it's obvious which cached files belong to which message - and
// the original URL is kept both in a data-url attribute and in a mapping so the image handler can
// download and cache it the first time it's actually requested.
public partial class ImageSanitizer(IMailCacheStore cacheStore)
{
	[GeneratedRegex(@"(<img\b[^>]*?\bsrc\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)]
	private static partial Regex ImgSrcRegex();

	[GeneratedRegex(@"(<a\b[^>]*?\bhref\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)]
	private static partial Regex AnchorHrefRegex();

	// Message HTML renders inside a sandboxed iframe (sandbox="allow-popups", no
	// allow-top-navigation) - clicking a link as-is would just be silently blocked by that
	// sandbox rather than doing anything the user would recognize as "not working" (and the
	// remote site refusing to be framed makes it look like their fault instead). Rewriting every
	// external href to our own interstitial (see MailController.ExternalLink) fixes both: it opens
	// in a real top-level tab (never framed, so no site can refuse it), and gives the user an "you
	// are leaving MailSharp" checkpoint before a sender-controlled link runs.
	public string ApplyLinkPolicy(string html)
	{
		if (string.IsNullOrEmpty(html)) return html;

		return AnchorHrefRegex().Replace(html, m =>
		{
			var prefix = m.Groups[1].Value;
			var quote = m.Groups[2].Value;
			var url = m.Groups[3].Value;
			var interstitialHref = "Mail/ExternalLink?url=" + Uri.EscapeDataString(url);
			return $"{prefix}{quote}{interstitialHref}{quote} target=\"_blank\" rel=\"noopener noreferrer\"";
		});
	}

	public (string Html, bool HasExternalImages) ApplyPolicy(string html, bool allowExternalImages, string emailAddress, string messageId)
	{
		if (string.IsNullOrEmpty(html)) return (html, false);

		var hasExternal = false;
		var index = 0;
		var result = ImgSrcRegex().Replace(html, m =>
		{
			hasExternal = true;
			var prefix = m.Groups[1].Value;
			var quote = m.Groups[2].Value;
			var url = m.Groups[3].Value;

			if (!allowExternalImages)
			{
				return $"{prefix}{quote}about:blank{quote} data-blocked-src={quote}{url}{quote}";
			}

			index++;
			var extension = ExtractExtension(url);
			var fileName = $"{index:D2}{extension}";
			cacheStore.SaveImageSourceUrl(emailAddress, messageId, fileName, url);

			var src = $"cache/images/{Uri.EscapeDataString(emailAddress)}/{Uri.EscapeDataString(messageId)}/{fileName}";
			return $"{prefix}{quote}{src}{quote} data-url={quote}{url}{quote}";
		});
		return (result, hasExternal);
	}

	private static string ExtractExtension(string url)
	{
		var qIndex = url.IndexOf('?');
		var basePart = qIndex >= 0 ? url[..qIndex] : url;

		var lastSlash = basePart.LastIndexOf('/');
		var lastSegment = lastSlash >= 0 ? basePart[(lastSlash + 1)..] : basePart;

		var dotIndex = lastSegment.LastIndexOf('.');
		if (dotIndex <= 0) return "";

		var sanitized = new string([.. lastSegment[(dotIndex + 1)..].Where(char.IsLetterOrDigit)]);
		return sanitized.Length > 0 ? "." + sanitized : "";
	}
}