Files
Wino-Mail/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs
T

443 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using MimeKit;
using MimeKit.Cryptography;
using MimeKit.Text;
using MimeKit.Tnef;
namespace Wino.Core.Domain.Models.MailItem;
/// <summary>
/// Visits a MimeMessage and generates HTML suitable to be rendered by a browser control.
/// </summary>
public class HtmlPreviewVisitor : MimeVisitor
{
private static readonly HashSet<string> BlockedTags = new(StringComparer.OrdinalIgnoreCase)
{
"script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form"
};
private static readonly HashSet<string> AllowedDataImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/bmp", "image/x-icon", "image/avif", "image/svg+xml"
};
private readonly List<MultipartRelated> stack = [];
private readonly List<MimeEntity> attachments = [];
readonly string tempDir;
public string Body { get; set; }
public Dictionary<IDigitalSignature, bool> Signatures = [];
/// <summary>
/// Creates a new HtmlPreviewVisitor.
/// </summary>
/// <param name="tempDirectory">A temporary directory used for storing image files.</param>
public HtmlPreviewVisitor(string tempDirectory)
{
tempDir = tempDirectory;
}
/// <summary>
/// The list of attachments that were in the MimeMessage.
/// </summary>
public IList<MimeEntity> Attachments
{
get { return attachments; }
}
/// <summary>
/// The HTML string that can be set on the BrowserControl.
/// </summary>
public string HtmlBody
{
get { return Body ?? string.Empty; }
}
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
{
// Prefer rich body alternatives first, and only fall back to calendar text if nothing else exists.
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
{
if (IsCalendarText(alternative[i]))
continue;
alternative[i].Accept(this);
}
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
{
if (!IsCalendarText(alternative[i]))
continue;
alternative[i].Accept(this);
}
}
protected override void VisitMultipartRelated(MultipartRelated related)
{
var root = related.Root;
// push this multipart/related onto our stack
stack.Add(related);
// visit the root document
root.Accept(this);
// pop this multipart/related off our stack
stack.RemoveAt(stack.Count - 1);
}
protected override void VisitMultipartSigned(MultipartSigned signed)
{
VerifySignatures(signed.Verify());
VisitMultipart(signed);
}
// look up the image based on the img src url within our multipart/related stack
bool TryGetImage(string url, out MimePart image)
{
image = null;
if (string.IsNullOrWhiteSpace(url))
return false;
UriKind kind;
int index;
Uri uri = null;
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
kind = UriKind.Absolute;
else if (Uri.IsWellFormedUriString(url, UriKind.Relative))
kind = UriKind.Relative;
else
kind = UriKind.RelativeOrAbsolute;
try
{
uri = new Uri(url, kind);
}
catch
{
// noop: we still attempt CID/content-id lookup below.
}
for (int i = stack.Count - 1; i >= 0; i--)
{
if (uri != null && (index = stack[i].IndexOf(uri)) != -1)
{
image = stack[i][index] as MimePart;
if (image != null)
return true;
}
var normalizedContentId = NormalizeContentId(url);
if (string.IsNullOrEmpty(normalizedContentId))
continue;
foreach (var relatedPart in stack[i])
{
if (relatedPart is not MimePart candidate || string.IsNullOrEmpty(candidate.ContentId))
continue;
if (string.Equals(candidate.ContentId.Trim('<', '>'), normalizedContentId, StringComparison.OrdinalIgnoreCase))
{
image = candidate;
return true;
}
}
}
return false;
}
private static string NormalizeContentId(string url)
{
var trimmed = url.Trim().Trim('\'', '"', '<', '>');
if (trimmed.StartsWith("cid:", StringComparison.OrdinalIgnoreCase))
trimmed = trimmed[4..];
return trimmed.Trim('<', '>');
}
// Save the image to our temp directory and return a "file://" url suitable for
// the browser control to load.
// Note: if you'd rather embed the image data into the HTML, you can construct a
// "data:" url instead.
string SaveImage(MimePart image)
{
using (var memory = new MemoryStream())
{
image.Content.DecodeTo(memory);
var buffer = memory.GetBuffer();
var length = (int)memory.Length;
var base64 = Convert.ToBase64String(buffer, 0, length);
return string.Format("data:{0};base64,{1}", image.ContentType.MimeType, base64);
}
}
// Replaces image references that refer to images embedded within the message with
// "data:" urls the browser control can load. Also sanitizes dangerous tags/attributes.
void HtmlTagCallback(HtmlTagContext ctx, HtmlWriter htmlWriter)
{
var tagName = ctx.TagName;
if (BlockedTags.Contains(tagName))
{
ctx.DeleteTag = true;
ctx.DeleteEndTag = true;
return;
}
if (ctx.IsEndTag)
{
ctx.WriteTag(htmlWriter, true);
return;
}
ctx.WriteTag(htmlWriter, false);
foreach (var attribute in ctx.Attributes)
{
var attributeName = attribute.Name;
if (ShouldDropAttribute(tagName, attributeName))
continue;
if (TryResolveImageAttribute(tagName, attributeName, attribute.Value, out var resolvedValue))
{
htmlWriter.WriteAttributeName(attributeName);
htmlWriter.WriteAttributeValue(resolvedValue);
continue;
}
if (IsUrlAttribute(attributeName))
{
if (!TrySanitizeUrlValue(attribute.Value, out var sanitizedUrl))
continue;
htmlWriter.WriteAttributeName(attributeName);
htmlWriter.WriteAttributeValue(sanitizedUrl);
continue;
}
htmlWriter.WriteAttribute(attribute);
}
if (ctx.TagId == HtmlTagId.Body)
htmlWriter.WriteAttribute("oncontextmenu", "return false;");
}
private bool TryResolveImageAttribute(string tagName, string attributeName, string value, out string resolvedValue)
{
resolvedValue = null;
if (string.IsNullOrWhiteSpace(value))
return false;
var lowerAttributeName = attributeName.ToLowerInvariant();
var isImageTag = string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase);
if (isImageTag && lowerAttributeName == "srcset")
{
resolvedValue = ResolveSrcSet(value);
return resolvedValue != value;
}
if (lowerAttributeName != "src" && lowerAttributeName != "background" && lowerAttributeName != "poster")
return false;
if (TryGetImage(value, out var image))
{
resolvedValue = SaveImage(image);
return true;
}
return false;
}
private string ResolveSrcSet(string srcSetValue)
{
var candidates = srcSetValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var updatedCandidates = new List<string>(candidates.Length);
foreach (var candidate in candidates)
{
var parts = candidate.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
continue;
var imageSource = parts[0];
if (TryGetImage(imageSource, out var image))
imageSource = SaveImage(image);
updatedCandidates.Add(parts.Length == 2 ? $"{imageSource} {parts[1]}" : imageSource);
}
return string.Join(", ", updatedCandidates);
}
private static bool ShouldDropAttribute(string tagName, string attributeName)
{
if (attributeName.StartsWith("on", StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(tagName, "body", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attributeName, "oncontextmenu", StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(attributeName, "srcdoc", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static bool IsUrlAttribute(string attributeName)
=> string.Equals(attributeName, "href", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attributeName, "src", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attributeName, "action", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attributeName, "xlink:href", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attributeName, "background", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attributeName, "poster", StringComparison.OrdinalIgnoreCase);
private static bool TrySanitizeUrlValue(string rawValue, out string sanitizedValue)
{
sanitizedValue = null;
if (string.IsNullOrWhiteSpace(rawValue))
return false;
var value = rawValue.Trim().Trim('"', '\'');
if (value.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase)
|| value.StartsWith("vbscript:", StringComparison.OrdinalIgnoreCase))
return false;
if (value.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && !IsAllowedImageDataUrl(value))
return false;
sanitizedValue = value;
return true;
}
private static bool IsAllowedImageDataUrl(string value)
{
const string dataPrefix = "data:";
if (!value.StartsWith(dataPrefix, StringComparison.OrdinalIgnoreCase))
return false;
var payloadStart = value.IndexOf(',', StringComparison.Ordinal);
if (payloadStart <= dataPrefix.Length)
return false;
var metadata = value[dataPrefix.Length..payloadStart];
var metadataParts = metadata.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (metadataParts.Length == 0)
return false;
return AllowedDataImageMimeTypes.Contains(metadataParts[0]);
}
protected override void VisitTextPart(TextPart entity)
{
TextConverter converter;
if (Body != null)
{
// since we've already found the body, treat this as an attachment
attachments.Add(entity);
return;
}
if (entity.IsHtml)
{
converter = new HtmlToHtml
{
HtmlTagCallback = HtmlTagCallback
};
}
else if (entity.IsFlowed)
{
var flowed = new FlowedToHtml();
string delsp;
if (entity.ContentType.Parameters.TryGetValue("delsp", out delsp))
flowed.DeleteSpace = delsp.ToLowerInvariant() == "yes";
converter = flowed;
}
else
{
converter = new TextToHtml();
}
Body = converter.Convert(entity.Text);
}
private static bool IsCalendarText(MimeEntity entity)
=> entity is TextPart textPart &&
textPart.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true;
protected override void VisitTnefPart(TnefPart entity)
{
// extract any attachments in the MS-TNEF part
attachments.AddRange(entity.ExtractAttachments());
}
protected override void VisitMessagePart(MessagePart entity)
{
// treat message/rfc822 parts as attachments
attachments.Add(entity);
}
protected override void VisitMimePart(MimePart entity)
{
if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.EnvelopedData } encrypted)
{
encrypted.Decrypt().Accept(this);
}
else if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.SignedData } signed)
{
MimeEntity extracted;
VerifySignatures(signed.Verify(out extracted));
extracted.Accept(this);
}
else
{
// realistically, if we've gotten this far, then we can treat this as an attachment
// even if the IsAttachment property is false.
attachments.Add(entity);
}
}
private void VerifySignatures(DigitalSignatureCollection signatures)
{
foreach (var signature in signatures)
{
try
{
bool valid = signature.Verify();
Signatures.Add(signature, valid);
}
catch (DigitalSignatureVerifyException)
{
// There was an error verifying the signature.
Signatures.Add(signature, false);
}
}
}
}