Improve email viewer content rendering

This commit is contained in:
Leendert de Borst
2026-01-30 21:05:59 +01:00
parent 17db3dc341
commit 382e2e96fa
13 changed files with 421 additions and 24 deletions
+26
View File
@@ -10,8 +10,10 @@
"hasInstallScript": true,
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@types/dompurify": "^3.0.5",
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"dompurify": "^3.3.1",
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
@@ -2085,6 +2087,15 @@
"@types/har-format": "*"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/emscripten": {
"version": "1.40.1",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.1.tgz",
@@ -2213,6 +2224,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/webextension-polyfill": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.8.3.tgz",
@@ -4877,6 +4894,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+2
View File
@@ -30,8 +30,10 @@
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@types/dompurify": "^3.0.5",
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"dompurify": "^3.3.1",
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
@@ -256,9 +256,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
<div className="bg-white mt-4">
{email.messageHtml ? (
<iframe
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
srcDoc={ConversionUtility.sanitizeAndPrepareEmailHtml(email.messageHtml)}
className="w-full min-h-[500px] border-0"
title={t('emails.emailContent')}
sandbox="allow-popups allow-popups-to-escape-sandbox"
/>
) : (
<pre className="whitespace-pre-wrap text-gray-800 p-3">
@@ -1,9 +1,85 @@
import DOMPurify from 'dompurify';
/**
* DOMPurify configuration for email viewing.
* Allows safe HTML elements for email display while blocking XSS vectors.
*/
const EMAIL_SANITIZER_CONFIG = {
ALLOWED_TAGS: [
'div', 'span', 'p', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'a', 'img',
'b', 'i', 'u', 's', 'strike', 'strong', 'em', 'small', 'sub', 'sup',
'blockquote', 'pre', 'code',
'font', 'center'
],
ALLOWED_ATTR: [
'style', 'class', 'id',
'width', 'height', 'align', 'valign',
'bgcolor', 'color', 'border',
'cellpadding', 'cellspacing', 'colspan', 'rowspan',
'face', 'size',
'href', 'target', 'rel',
'src', 'alt', 'title'
],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
FORBID_TAGS: ['script', 'object', 'embed', 'iframe', 'frame', 'frameset',
'form', 'input', 'button', 'textarea', 'select', 'option',
'link', 'meta', 'base', 'applet'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout',
'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onkeydown',
'onkeyup', 'onkeypress', 'ondblclick', 'oncontextmenu', 'onmousedown',
'onmouseup', 'onmousemove', 'ondrag', 'ondrop']
};
/**
* Utility class for conversion operations.
* TODO: make this a shared utility class in root /core/ folder so we can reuse it between browser extension/mobile app
* and possibly WASM client.
*/
class ConversionUtility {
/**
* Sanitizes HTML content for safe display in email viewers.
* Removes all script tags, event handlers, and other XSS attack vectors.
* @param html The HTML content to sanitize.
* @returns Sanitized HTML safe for display.
*/
public sanitizeHtmlForEmailViewing(html: string): string {
if (!html || html.trim() === '') {
return html;
}
try {
return DOMPurify.sanitize(html, EMAIL_SANITIZER_CONFIG);
} catch (ex) {
console.error(`Error in sanitizeHtmlForEmailViewing: ${ex instanceof Error ? ex.message : String(ex)}`);
// Return empty string on error to prevent potential XSS
return '';
}
}
/**
* Sanitizes HTML content and converts anchor tags to open in a new tab.
* This is a convenience method that combines sanitization with anchor tag conversion.
* @param html The HTML content to process.
* @returns Sanitized HTML with anchor tags configured to open in new tabs.
*/
public sanitizeAndPrepareEmailHtml(html: string): string {
if (!html || html.trim() === '') {
return html;
}
// First sanitize to remove XSS vectors
const sanitizedHtml = this.sanitizeHtmlForEmailViewing(html);
// Then convert anchor tags to open in new tab
return this.convertAnchorTagsToOpenInNewTab(sanitizedHtml);
}
/**
* Convert all anchor tags to open in a new tab.
* @param html HTML input.
+5 -1
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { StyleSheet, View, ActivityIndicator, Share, useColorScheme, Linking, Text, TextInput, Platform } from 'react-native';
import { WebView } from 'react-native-webview';
import ConversionUtility from '@/utils/ConversionUtility';
import type { Item } from '@/utils/dist/core/models/vault';
import type { Email } from '@/utils/dist/core/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
@@ -481,11 +482,14 @@ export default function EmailDetailsScreen() : React.ReactNode {
let emailView = null;
if (isHtmlView && email.messageHtml) {
// Sanitize HTML
const sanitizedHtml = ConversionUtility.sanitizeHtmlForEmailViewing(email.messageHtml);
emailView = (
<WebView
style={styles.webView}
source={{ html: email.messageHtml }}
source={{ html: sanitizedHtml }}
scrollEnabled={true}
javaScriptEnabled={false}
onNavigationStateChange={(event) => {
if (event.url !== 'about:blank') {
// Open the URL in the browser
+72
View File
@@ -55,6 +55,7 @@
"react-native-svg-transformer": "^1.5.0",
"react-native-toast-message": "^2.2.1",
"react-native-webview": "13.13.5",
"sanitize-html": "^2.17.0",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -68,6 +69,7 @@
"@types/lodash": "^4.17.16",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^18.3.0",
"@types/sanitize-html": "^2.16.0",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"eslint": "^9.35.0",
@@ -4777,6 +4779,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
"integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/sql.js": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.9.tgz",
@@ -9872,6 +9884,37 @@
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -10475,6 +10518,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -13810,6 +13862,12 @@
"node": ">=10"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse-statements": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
@@ -15370,6 +15428,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
+2
View File
@@ -76,6 +76,7 @@
"react-native-svg-transformer": "^1.5.0",
"react-native-toast-message": "^2.2.1",
"react-native-webview": "13.13.5",
"sanitize-html": "^2.17.0",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -89,6 +90,7 @@
"@types/lodash": "^4.17.16",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^18.3.0",
"@types/sanitize-html": "^2.16.0",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"eslint": "^9.35.0",
@@ -1,9 +1,70 @@
import sanitizeHtml from 'sanitize-html';
/**
* sanitize-html configuration for email viewing.
* Allows safe HTML elements for email display while blocking XSS vectors.
* Note: Using sanitize-html instead of DOMPurify because React Native
* doesn't have a DOM environment that DOMPurify requires.
*/
const EMAIL_SANITIZER_CONFIG: sanitizeHtml.IOptions = {
// Disable style parsing as it requires PostCSS which doesn't work in React Native
// See: https://github.com/apostrophecms/sanitize-html/issues/547
parseStyleAttributes: false,
allowedTags: [
'div', 'span', 'p', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'a', 'img',
'b', 'i', 'u', 's', 'strike', 'strong', 'em', 'small', 'sub', 'sup',
'blockquote', 'pre', 'code',
'font', 'center'
],
allowedAttributes: {
'*': ['style', 'class', 'id'],
'table': ['width', 'height', 'align', 'valign', 'bgcolor', 'border', 'cellpadding', 'cellspacing'],
'tr': ['align', 'valign', 'bgcolor'],
'th': ['width', 'height', 'align', 'valign', 'bgcolor', 'colspan', 'rowspan'],
'td': ['width', 'height', 'align', 'valign', 'bgcolor', 'colspan', 'rowspan'],
'a': ['href', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'font': ['color', 'face', 'size']
},
allowedSchemes: ['http', 'https', 'mailto'],
// Disable data: URIs for security (can be used for XSS)
allowedSchemesByTag: {
img: ['http', 'https'],
a: ['http', 'https', 'mailto']
}
};
/**
* Utility class for conversion operations.
* TODO: make this a shared utility class in root /core/ folder so we can reuse it between
* browser extension/mobile app and possibly WASM client.
*/
class ConversionUtility {
/**
* Sanitizes HTML content for safe display in email viewers.
* Removes all script tags, event handlers, and other XSS attack vectors.
* @param html The HTML content to sanitize.
* @returns Sanitized HTML safe for display.
*/
public sanitizeHtmlForEmailViewing(html: string): string {
if (!html || html.trim() === '') {
return html;
}
try {
return sanitizeHtml(html, EMAIL_SANITIZER_CONFIG);
} catch (ex) {
console.error(`Error in sanitizeHtmlForEmailViewing: ${ex instanceof Error ? ex.message : String(ex)}`);
// Return empty string on error to prevent potential XSS
return '';
}
}
/**
* Normalize a username by converting it to lowercase and trimming whitespace.
* @param username The username to normalize.
@@ -51,7 +51,7 @@
<div class="flex-1 overflow-y-auto p-4">
<div class="text-gray-700 dark:text-gray-300">
<div>
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody">
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody" sandbox="allow-popups allow-popups-to-escape-sandbox">
</iframe>
</div>
</div>
@@ -248,8 +248,8 @@
// Check if there is HTML content, if not, then set default viewtype to plain
if (Email.MessageHtml is not null && !string.IsNullOrWhiteSpace(Email.MessageHtml))
{
// HTML is available
EmailBody = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(Email.MessageHtml);
// HTML is available, sanitize and prepare for display
EmailBody = ConversionUtility.SanitizeAndPrepareEmailHtml(Email.MessageHtml);
}
else if (Email.MessagePlain is not null)
{
@@ -64,7 +64,7 @@
<!-- Email Content - Takes remaining space -->
<div class="flex-1 overflow-y-auto p-4">
<div class="text-gray-700 dark:text-gray-300 h-full">
<iframe class="w-full h-full border-0" srcdoc="@EmailBody">
<iframe class="w-full h-full border-0" srcdoc="@EmailBody" sandbox="allow-popups allow-popups-to-escape-sandbox">
</iframe>
</div>
</div>
@@ -269,8 +269,8 @@
// Check if there is HTML content, if not, then set default viewtype to plain
if (Email.MessageHtml is not null && !string.IsNullOrWhiteSpace(Email.MessageHtml))
{
// HTML is available
EmailBody = ConversionUtility.ConvertAnchorTagsToOpenInNewTab(Email.MessageHtml);
// HTML is available, sanitize and prepare for display
EmailBody = ConversionUtility.SanitizeAndPrepareEmailHtml(Email.MessageHtml);
}
else if (Email.MessagePlain is not null)
{
@@ -162,8 +162,9 @@ generate_html_body() {
</style>
</head>
<body>
<script>alert(origin)</script>
<h1>$opening_text</h1>
<div class="section">
<p><strong>Random content:</strong> $content_suffix</p>
</div>
@@ -203,12 +204,12 @@ generate_headers() {
local subject="$2"
local content_type="$3"
local boundary="$4"
printf "From: sender@example.com\r\n"
printf "To: %s\r\n" "$recipient"
printf "Subject: %s\r\n" "$subject"
printf "MIME-Version: 1.0\r\n"
if [[ -n "$boundary" ]]; then
printf "Content-Type: multipart/mixed; boundary=%s\r\n" "$boundary"
else
@@ -229,7 +230,7 @@ send_email() {
local email_type="$2"
local smtp_port="$3"
local email_number="$4"
# Generate common random elements
local subject_suffix=$(generate_random_string 8)
local content_suffix=$(generate_random_content)
@@ -238,46 +239,46 @@ send_email() {
local random_unicode="Unicode test: 你好世界 🌍 测试文字 🚀"
local subject_unicode=$(generate_random_unicode_subject 6)
local chinese_text=$(generate_random_chinese 8)
# Determine email properties based on type
local with_attachment="false"
local is_html="false"
local content_type="text/plain"
case "$email_type" in
2) with_attachment="true" ;;
3) is_html="true"; content_type="text/html" ;;
4) with_attachment="true"; is_html="true"; content_type="text/html" ;;
esac
# Build subject line
local subject="Test Email #$email_number"
[[ "$with_attachment" == "true" ]] && subject="$subject with Attachment"
subject="$subject $subject_unicode - $subject_suffix"
# Handle emails with attachments
if [[ "$with_attachment" == "true" ]]; then
local boundary="boundary-$(generate_random_string 16)"
local attachment_content="This is a test attachment content - $(generate_random_string 32)"
local attachment_name="test_attachment_$(generate_random_string 8).txt"
{
generate_headers "$recipient" "$subject" "" "$boundary"
# Email body part
printf -- "--%s\r\n" "$boundary"
printf "Content-Type: %s; charset=utf-8\r\n" "$content_type"
printf "Content-Transfer-Encoding: 8bit\r\n"
printf "\r\n"
if [[ "$is_html" == "true" ]]; then
generate_html_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
else
generate_plain_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
fi
printf "\r\n"
# Attachment part
printf -- "--%s\r\n" "$boundary"
printf "Content-Type: application/octet-stream\r\n"
@@ -295,7 +296,7 @@ send_email() {
# Handle emails without attachments
{
generate_headers "$recipient" "$subject" "$content_type" ""
if [[ "$is_html" == "true" ]]; then
generate_html_body "$email_number" "$content_suffix" "$chinese_text" "$special_chars" "$emoji_text" "$random_unicode" "$with_attachment"
else
@@ -331,7 +332,7 @@ select_email_type() {
echo "3) HTML" >&2
echo "4) HTML with attachment" >&2
echo "" >&2
local email_type
while true; do
read -p "Enter your choice (1-4): " email_type
@@ -348,7 +349,7 @@ while true; do
if [[ -z "$recipient" ]]; then
read -p "Enter the recipient's email address: " recipient
fi
if [[ -z "$email_type" ]]; then
email_type=$(select_email_type)
fi
@@ -23,6 +23,7 @@
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.889" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -7,6 +7,7 @@
namespace AliasVault.Shared.Utilities;
using Ganss.Xss;
using HtmlAgilityPack;
/// <summary>
@@ -14,6 +15,156 @@ using HtmlAgilityPack;
/// </summary>
public static class ConversionUtility
{
/// <summary>
/// Lazy-initialized HTML sanitizer instance configured for safe email viewing.
/// Removes all script tags, event handlers, and other XSS vectors while preserving
/// safe HTML for email display.
/// </summary>
private static readonly Lazy<HtmlSanitizer> EmailSanitizer = new(() =>
{
var sanitizer = new HtmlSanitizer();
// Allow common email formatting elements
sanitizer.AllowedTags.Add("div");
sanitizer.AllowedTags.Add("span");
sanitizer.AllowedTags.Add("p");
sanitizer.AllowedTags.Add("br");
sanitizer.AllowedTags.Add("hr");
sanitizer.AllowedTags.Add("h1");
sanitizer.AllowedTags.Add("h2");
sanitizer.AllowedTags.Add("h3");
sanitizer.AllowedTags.Add("h4");
sanitizer.AllowedTags.Add("h5");
sanitizer.AllowedTags.Add("h6");
sanitizer.AllowedTags.Add("ul");
sanitizer.AllowedTags.Add("ol");
sanitizer.AllowedTags.Add("li");
sanitizer.AllowedTags.Add("table");
sanitizer.AllowedTags.Add("thead");
sanitizer.AllowedTags.Add("tbody");
sanitizer.AllowedTags.Add("tfoot");
sanitizer.AllowedTags.Add("tr");
sanitizer.AllowedTags.Add("th");
sanitizer.AllowedTags.Add("td");
sanitizer.AllowedTags.Add("a");
sanitizer.AllowedTags.Add("img");
sanitizer.AllowedTags.Add("b");
sanitizer.AllowedTags.Add("i");
sanitizer.AllowedTags.Add("u");
sanitizer.AllowedTags.Add("s");
sanitizer.AllowedTags.Add("strike");
sanitizer.AllowedTags.Add("strong");
sanitizer.AllowedTags.Add("em");
sanitizer.AllowedTags.Add("small");
sanitizer.AllowedTags.Add("sub");
sanitizer.AllowedTags.Add("sup");
sanitizer.AllowedTags.Add("blockquote");
sanitizer.AllowedTags.Add("pre");
sanitizer.AllowedTags.Add("code");
sanitizer.AllowedTags.Add("font");
sanitizer.AllowedTags.Add("center");
// Allow common styling attributes
sanitizer.AllowedAttributes.Add("style");
sanitizer.AllowedAttributes.Add("class");
sanitizer.AllowedAttributes.Add("id");
sanitizer.AllowedAttributes.Add("width");
sanitizer.AllowedAttributes.Add("height");
sanitizer.AllowedAttributes.Add("align");
sanitizer.AllowedAttributes.Add("valign");
sanitizer.AllowedAttributes.Add("bgcolor");
sanitizer.AllowedAttributes.Add("color");
sanitizer.AllowedAttributes.Add("border");
sanitizer.AllowedAttributes.Add("cellpadding");
sanitizer.AllowedAttributes.Add("cellspacing");
sanitizer.AllowedAttributes.Add("colspan");
sanitizer.AllowedAttributes.Add("rowspan");
sanitizer.AllowedAttributes.Add("face");
sanitizer.AllowedAttributes.Add("size");
// Allow href for links but sanitize URLs
sanitizer.AllowedAttributes.Add("href");
sanitizer.AllowedAttributes.Add("target");
sanitizer.AllowedAttributes.Add("rel");
// Allow src for images but sanitize URLs
sanitizer.AllowedAttributes.Add("src");
sanitizer.AllowedAttributes.Add("alt");
sanitizer.AllowedAttributes.Add("title");
// Explicitly remove dangerous elements (fallback)
sanitizer.AllowedTags.Remove("script");
sanitizer.AllowedTags.Remove("object");
sanitizer.AllowedTags.Remove("embed");
sanitizer.AllowedTags.Remove("iframe");
sanitizer.AllowedTags.Remove("frame");
sanitizer.AllowedTags.Remove("frameset");
sanitizer.AllowedTags.Remove("form");
sanitizer.AllowedTags.Remove("input");
sanitizer.AllowedTags.Remove("button");
sanitizer.AllowedTags.Remove("textarea");
sanitizer.AllowedTags.Remove("select");
sanitizer.AllowedTags.Remove("option");
sanitizer.AllowedTags.Remove("link");
sanitizer.AllowedTags.Remove("meta");
sanitizer.AllowedTags.Remove("base");
sanitizer.AllowedTags.Remove("applet");
return sanitizer;
});
/// <summary>
/// Sanitizes HTML content for safe display in email viewers.
/// Removes all script tags, event handlers, and other XSS attack vectors.
/// </summary>
/// <param name="html">The HTML content to sanitize.</param>
/// <returns>Sanitized HTML safe for display.</returns>
/// <remarks>
/// This method should be called before displaying any untrusted HTML content
/// (e.g., received emails) to prevent Cross-Site Scripting (XSS) attacks.
/// </remarks>
public static string SanitizeHtmlForEmailViewing(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return html;
}
try
{
return EmailSanitizer.Value.Sanitize(html);
}
catch (Exception ex)
{
// Log the exception
Console.WriteLine($"Error in SanitizeHtmlForEmailViewing: {ex.Message}");
// Return empty string on error to prevent potential XSS
// This is safer than returning the original HTML
return string.Empty;
}
}
/// <summary>
/// Sanitizes HTML content and converts anchor tags to open in a new tab.
/// This is a convenience method that combines sanitization with anchor tag conversion.
/// </summary>
/// <param name="html">The HTML content to process.</param>
/// <returns>Sanitized HTML with anchor tags configured to open in new tabs.</returns>
public static string SanitizeAndPrepareEmailHtml(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return html;
}
// First sanitize to remove XSS vectors
var sanitizedHtml = SanitizeHtmlForEmailViewing(html);
// Then convert anchor tags to open in new tab
return ConvertAnchorTagsToOpenInNewTab(sanitizedHtml);
}
/// <summary>
/// Convert all anchor tags to open in a new tab.
/// </summary>