mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-05 23:47:50 +08:00
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:
co-authored by
Claude Opus 4.6
parent
372f7b4149
commit
0c337a1942
@@ -21,6 +21,7 @@
|
|||||||
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
|
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
|
||||||
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
|
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
|
||||||
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
||||||
|
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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: |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
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mail-parser-wasm": "^0.2.1",
|
"mail-parser-wasm": "^0.2.1",
|
||||||
"naive-ui": "^2.43.2",
|
"naive-ui": "^2.43.2",
|
||||||
@@ -43,15 +44,16 @@
|
|||||||
"@vicons/fa": "^0.13.0",
|
"@vicons/fa": "^0.13.0",
|
||||||
"@vicons/material": "^0.13.0",
|
"@vicons/material": "^0.13.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"unplugin-auto-import": "^20.3.0",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-top-level-await": "^1.6.0",
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
|
"vitest": "^3.1.0",
|
||||||
"workbox-build": "^7.4.0",
|
"workbox-build": "^7.4.0",
|
||||||
"workbox-window": "^7.4.0",
|
"workbox-window": "^7.4.0",
|
||||||
"vitest": "^3.1.0",
|
|
||||||
"wrangler": "^4.68.1"
|
"wrangler": "^4.68.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildReplyModel, buildForwardModel } from '../mail-actions'
|
import { buildReplyModel, buildForwardModel } from '../mail-actions'
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ describe('buildReplyModel', () => {
|
|||||||
expect(result.content).toBe(
|
expect(result.content).toBe(
|
||||||
'<p><br></p><blockquote>Plain text fallback</blockquote><p><br></p>'
|
'<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', () => {
|
it('falls back to plain text when message is null', () => {
|
||||||
@@ -95,6 +97,18 @@ describe('buildReplyModel', () => {
|
|||||||
expect(result.toMail).toBe('plain@example.com')
|
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', () => {
|
it('formats subject with reply label', () => {
|
||||||
const mail = {
|
const mail = {
|
||||||
source: 'test@example.com',
|
source: 'test@example.com',
|
||||||
@@ -130,6 +144,46 @@ describe('buildReplyModel', () => {
|
|||||||
const result = buildReplyModel(mail, 'Reply')
|
const result = buildReplyModel(mail, 'Reply')
|
||||||
expect(result.contentType).toBe('rich')
|
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 < b & c > d')
|
||||||
|
expect(result.content).not.toContain('a < b')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildForwardModel', () => {
|
describe('buildForwardModel', () => {
|
||||||
@@ -175,4 +229,37 @@ describe('buildForwardModel', () => {
|
|||||||
const result = buildForwardModel(mail, 'Forward')
|
const result = buildForwardModel(mail, 'Forward')
|
||||||
expect(result.subject).toBe('Forward: Original')
|
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 < b & c > d')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Build the send-mail model for replying to an email.
|
||||||
* @param {Object} mail - The mail object (curMail)
|
* @param {Object} mail - The mail object (curMail)
|
||||||
@@ -6,21 +34,21 @@
|
|||||||
*/
|
*/
|
||||||
export function buildReplyModel(mail, replyLabel) {
|
export function buildReplyModel(mail, replyLabel) {
|
||||||
const emailRegex = /(.+?) <(.+?)>/;
|
const emailRegex = /(.+?) <(.+?)>/;
|
||||||
let toMail = mail.originalSource;
|
let toMail = mail.originalSource || '';
|
||||||
let toName = "";
|
let toName = "";
|
||||||
const match = emailRegex.exec(mail.source);
|
const match = emailRegex.exec(mail.source);
|
||||||
if (match) {
|
if (match) {
|
||||||
toName = match[1];
|
toName = match[1];
|
||||||
toMail = match[2];
|
toMail = match[2];
|
||||||
}
|
}
|
||||||
const bodyContent = mail.message || mail.text;
|
const safeContent = sanitizeContent(mail);
|
||||||
return {
|
return {
|
||||||
toName,
|
toName,
|
||||||
toMail,
|
toMail,
|
||||||
subject: `${replyLabel}: ${mail.subject}`,
|
subject: `${replyLabel}: ${mail.subject}`,
|
||||||
contentType: mail.message ? 'html' : 'rich',
|
contentType: mail.message ? 'html' : 'rich',
|
||||||
content: bodyContent
|
content: safeContent
|
||||||
? `<p><br></p><blockquote>${bodyContent}</blockquote><p><br></p>`
|
? `<p><br></p><blockquote>${safeContent}</blockquote><p><br></p>`
|
||||||
: '',
|
: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -35,6 +63,6 @@ export function buildForwardModel(mail, forwardLabel) {
|
|||||||
return {
|
return {
|
||||||
subject: `${forwardLabel}: ${mail.subject}`,
|
subject: `${forwardLabel}: ${mail.subject}`,
|
||||||
contentType: mail.message ? 'html' : 'text',
|
contentType: mail.message ? 'html' : 'text',
|
||||||
content: mail.message || mail.text,
|
content: sanitizeContent(mail),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user