diff --git a/apps/browser-extension/package-lock.json b/apps/browser-extension/package-lock.json index c1aea72b9..c96691cf3 100644 --- a/apps/browser-extension/package-lock.json +++ b/apps/browser-extension/package-lock.json @@ -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", diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index 5e45a696d..b265df2a5 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -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", diff --git a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx index 4264895a9..e47726362 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx @@ -256,9 +256,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
{email.messageHtml ? (
@@ -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) { diff --git a/apps/server/AliasVault.Client/Main/Components/Email/EmailPreview.razor b/apps/server/AliasVault.Client/Main/Components/Email/EmailPreview.razor index 06466a4fa..ff50af047 100644 --- a/apps/server/AliasVault.Client/Main/Components/Email/EmailPreview.razor +++ b/apps/server/AliasVault.Client/Main/Components/Email/EmailPreview.razor @@ -64,7 +64,7 @@
-
@@ -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) { diff --git a/apps/server/Services/AliasVault.SmtpService/Scripts/sendEmailCLI.sh b/apps/server/Services/AliasVault.SmtpService/Scripts/sendEmailCLI.sh index b13875e8a..16f1c18df 100755 --- a/apps/server/Services/AliasVault.SmtpService/Scripts/sendEmailCLI.sh +++ b/apps/server/Services/AliasVault.SmtpService/Scripts/sendEmailCLI.sh @@ -162,8 +162,9 @@ generate_html_body() { +

$opening_text

- +

Random content: $content_suffix

@@ -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 diff --git a/apps/server/Shared/AliasVault.Shared/AliasVault.Shared.csproj b/apps/server/Shared/AliasVault.Shared/AliasVault.Shared.csproj index 6ce68f970..fc60c2c22 100644 --- a/apps/server/Shared/AliasVault.Shared/AliasVault.Shared.csproj +++ b/apps/server/Shared/AliasVault.Shared/AliasVault.Shared.csproj @@ -23,6 +23,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/apps/server/Shared/AliasVault.Shared/Utilities/ConversionUtility.cs b/apps/server/Shared/AliasVault.Shared/Utilities/ConversionUtility.cs index 09cce0686..5a9a5ef40 100644 --- a/apps/server/Shared/AliasVault.Shared/Utilities/ConversionUtility.cs +++ b/apps/server/Shared/AliasVault.Shared/Utilities/ConversionUtility.cs @@ -7,6 +7,7 @@ namespace AliasVault.Shared.Utilities; +using Ganss.Xss; using HtmlAgilityPack; /// @@ -14,6 +15,156 @@ using HtmlAgilityPack; /// public static class ConversionUtility { + /// + /// 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. + /// + private static readonly Lazy 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; + }); + + /// + /// Sanitizes HTML content for safe display in email viewers. + /// Removes all script tags, event handlers, and other XSS attack vectors. + /// + /// The HTML content to sanitize. + /// Sanitized HTML safe for display. + /// + /// This method should be called before displaying any untrusted HTML content + /// (e.g., received emails) to prevent Cross-Site Scripting (XSS) attacks. + /// + 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; + } + } + + /// + /// 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. + /// + /// The HTML content to process. + /// Sanitized HTML with anchor tags configured to open in new tabs. + 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); + } + /// /// Convert all anchor tags to open in a new tab. ///