fix: sanitize mail content in reply/forward to prevent XSS (#857)

* fix: sanitize mail content in reply/forward to prevent XSS

- Add DOMPurify to sanitize HTML email content (whitelist-based)
- Add escapeHtml for plain text content (escape &<>"')
- Guard mail.originalSource with fallback to empty string
- Add jsdom for vitest DOM environment (DOMPurify requires DOM)
- Add XSS regression tests (script tags, event handlers, HTML escape)
- Add contentType assertion for empty message fallback case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add XSS sanitization E2E screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove temporary screenshots from tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize escapeHtml input and add forward text escape test

- escapeHtml: convert input via String(str ?? '') to handle non-string values
- Add test for plain text forward with special chars (<, &, >)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bowl42
2026-03-04 23:30:43 +08:00
committed by GitHub
parent 372f7b4149
commit 0c337a1942
5 changed files with 125 additions and 6 deletions

View File

@@ -21,6 +21,7 @@
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
### Improvements

View File

@@ -21,6 +21,7 @@
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
### Improvements

View File

@@ -29,6 +29,7 @@
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.5",
"dompurify": "^3.3.1",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.2",
@@ -43,15 +44,16 @@
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.4",
"jsdom": "^28.1.0",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.1.0",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"vitest": "^3.1.0",
"wrangler": "^4.68.1"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"

View File

@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { buildReplyModel, buildForwardModel } from '../mail-actions'
@@ -29,6 +30,7 @@ describe('buildReplyModel', () => {
expect(result.content).toBe(
'<p><br></p><blockquote>Plain text fallback</blockquote><p><br></p>'
)
expect(result.contentType).toBe('rich')
})
it('falls back to plain text when message is null', () => {
@@ -95,6 +97,18 @@ describe('buildReplyModel', () => {
expect(result.toMail).toBe('plain@example.com')
})
it('defaults toMail to empty string when originalSource is null', () => {
const mail = {
source: 'plain@example.com',
originalSource: null,
subject: 'Test',
message: '',
text: 'body',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.toMail).toBe('')
})
it('formats subject with reply label', () => {
const mail = {
source: 'test@example.com',
@@ -130,6 +144,46 @@ describe('buildReplyModel', () => {
const result = buildReplyModel(mail, 'Reply')
expect(result.contentType).toBe('rich')
})
it('strips script tags from HTML reply content (XSS)', () => {
const mail = {
source: 'attacker@example.com',
originalSource: 'attacker@example.com',
subject: 'XSS',
message: '<p>Hello</p><script>alert("xss")</script><p>World</p>',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).not.toContain('<script>')
expect(result.content).toContain('<p>Hello</p>')
expect(result.content).toContain('<p>World</p>')
})
it('strips event handlers from HTML reply content (XSS)', () => {
const mail = {
source: 'attacker@example.com',
originalSource: 'attacker@example.com',
subject: 'XSS',
message: '<img src=x onerror="alert(1)"><p>Text</p>',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).not.toContain('onerror')
expect(result.content).toContain('<p>Text</p>')
})
it('escapes HTML chars in plain text reply content', () => {
const mail = {
source: 'user@example.com',
originalSource: 'user@example.com',
subject: 'Test',
message: '',
text: 'a < b & c > d',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toContain('a &lt; b &amp; c &gt; d')
expect(result.content).not.toContain('a < b')
})
})
describe('buildForwardModel', () => {
@@ -175,4 +229,37 @@ describe('buildForwardModel', () => {
const result = buildForwardModel(mail, 'Forward')
expect(result.subject).toBe('Forward: Original')
})
it('strips script tags from HTML forward content (XSS)', () => {
const mail = {
subject: 'XSS Test',
message: '<div>Safe</div><script>alert("xss")</script>',
text: '',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.content).not.toContain('<script>')
expect(result.content).toContain('<div>Safe</div>')
})
it('strips event handlers from HTML forward content (XSS)', () => {
const mail = {
subject: 'XSS Test',
message: '<img src=x onerror="alert(1)"><b>Bold</b>',
text: '',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.content).not.toContain('onerror')
expect(result.content).toContain('<b>Bold</b>')
})
it('escapes special chars in plain text forward content', () => {
const mail = {
subject: 'FW Text',
message: '',
text: 'a < b & c > d',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.contentType).toBe('text')
expect(result.content).toBe('a &lt; b &amp; c &gt; d')
})
})

View File

@@ -1,3 +1,31 @@
import DOMPurify from 'dompurify';
/**
* HTML-escape special characters for plain text content.
*/
function escapeHtml(str) {
const text = String(str ?? '');
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
/**
* Sanitize mail content: HTML-escape plain text, whitelist-sanitize HTML.
*/
function sanitizeContent(mail) {
if (mail.message) {
return DOMPurify.sanitize(mail.message);
}
if (mail.text) {
return escapeHtml(mail.text);
}
return '';
}
/**
* Build the send-mail model for replying to an email.
* @param {Object} mail - The mail object (curMail)
@@ -6,21 +34,21 @@
*/
export function buildReplyModel(mail, replyLabel) {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = mail.originalSource;
let toMail = mail.originalSource || '';
let toName = "";
const match = emailRegex.exec(mail.source);
if (match) {
toName = match[1];
toMail = match[2];
}
const bodyContent = mail.message || mail.text;
const safeContent = sanitizeContent(mail);
return {
toName,
toMail,
subject: `${replyLabel}: ${mail.subject}`,
contentType: mail.message ? 'html' : 'rich',
content: bodyContent
? `<p><br></p><blockquote>${bodyContent}</blockquote><p><br></p>`
content: safeContent
? `<p><br></p><blockquote>${safeContent}</blockquote><p><br></p>`
: '',
};
}
@@ -35,6 +63,6 @@ export function buildForwardModel(mail, forwardLabel) {
return {
subject: `${forwardLabel}: ${mail.subject}`,
contentType: mail.message ? 'html' : 'text',
content: mail.message || mail.text,
content: sanitizeContent(mail),
};
}