fix(mail): 优化邮件 HTML 与样式保留
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 支持整页邮件文档的清洗与渲染,保留 `<html>`、`<head>`、`<body>` 和 `<style>` 等结构。 - 提取并校验安全的邮件样式块,阻止包含外链、脚本式内容的 CSS。 - 补充 `dompurify` 类型声明以支持 `WHOLE_DOCUMENT` 配置。
This commit is contained in:
@@ -842,16 +842,21 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
|
func TestHTMLPolicyPreservesEmailLayoutStyles(t *testing.T) {
|
||||||
policy := NewHTMLPolicy()
|
policy := NewHTMLPolicy()
|
||||||
out := policy.Sanitize(`<div class="card" style="max-width:600px;margin:0 auto;background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 8px 24px rgba(0,0,0,.12);color:#fff" onclick="alert(1)">
|
out := policy.Sanitize(`<html><head><style type="text/css">.card{max-width:600px;margin:0 auto;background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 8px 24px rgba(0,0,0,.12);color:#fff}.content{text-align:center;padding:24px}</style></head><body>
|
||||||
|
<div class="card" style="max-width:600px;margin:0 auto;background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 8px 24px rgba(0,0,0,.12);color:#fff" onclick="alert(1)">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr><td align="center" style="padding:24px;text-align:center;background-color:#f8fafc">
|
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr><td align="center" style="padding:24px;text-align:center;background-color:#f8fafc">
|
||||||
<a href="javascript:alert(1)">bad</a><img src="x" onerror="alert(1)"><script>alert(1)</script>hello
|
<a href="javascript:alert(1)">bad</a><img src="x" onerror="alert(1)"><script>alert(1)</script>hello
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
</div>`)
|
</div></body></html>`)
|
||||||
for _, want := range []string{"class=\"card\"", "max-width: 600px", "margin: 0 auto", "background: linear-gradient", "box-shadow:", "cellpadding=\"0\"", "cellspacing=\"0\"", "align=\"center\"", "text-align: center"} {
|
for _, want := range []string{"<style type=\"text/css\">", ".card{", "class=\"card\"", "max-width: 600px", "margin: 0 auto", "background: linear-gradient", "box-shadow:", "cellpadding=\"0\"", "cellspacing=\"0\"", "align=\"center\"", "text-align: center"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("sanitized html missing %q: %s", want, out)
|
t.Fatalf("sanitized html missing %q: %s", want, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
blockedOut := policy.Sanitize(`<style>.bad{background:url(https://tracker.example/x);color:red}</style><p>ok</p>`)
|
||||||
|
if strings.Contains(blockedOut, "<style") {
|
||||||
|
t.Fatalf("unsafe css block should be removed: %s", blockedOut)
|
||||||
|
}
|
||||||
for _, blocked := range []string{"onclick", "onerror", "javascript:", "<script"} {
|
for _, blocked := range []string{"onclick", "onerror", "javascript:", "<script"} {
|
||||||
if strings.Contains(strings.ToLower(out), blocked) {
|
if strings.Contains(strings.ToLower(out), blocked) {
|
||||||
t.Fatalf("sanitized html kept unsafe %q: %s", blocked, out)
|
t.Fatalf("sanitized html kept unsafe %q: %s", blocked, out)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type HTMLPolicy struct{ policy *bluemonday.Policy }
|
|||||||
|
|
||||||
func NewHTMLPolicy() *HTMLPolicy {
|
func NewHTMLPolicy() *HTMLPolicy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
|
p.AllowElements("html", "head", "body", "center", "font")
|
||||||
p.AllowAttrs("style").Globally()
|
p.AllowAttrs("style").Globally()
|
||||||
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||||
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
|
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
|
||||||
@@ -43,7 +44,53 @@ func (p *HTMLPolicy) Sanitize(s string) string {
|
|||||||
if p == nil || p.policy == nil {
|
if p == nil || p.policy == nil {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return p.policy.Sanitize(s)
|
styles, withoutStyles := extractSafeEmailStyles(s)
|
||||||
|
clean := p.policy.Sanitize(withoutStyles)
|
||||||
|
if len(styles) == 0 {
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
return strings.Join(styles, "") + clean
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
|
||||||
|
|
||||||
|
func extractSafeEmailStyles(value string) ([]string, string) {
|
||||||
|
styles := []string{}
|
||||||
|
withoutStyles := emailStyleTagRe.ReplaceAllStringFunc(value, func(tag string) string {
|
||||||
|
match := emailStyleTagRe.FindStringSubmatch(tag)
|
||||||
|
if len(match) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
attrs, css := match[1], strings.TrimSpace(match[2])
|
||||||
|
if !safeEmailStyleAttrs(attrs) || !safeEmailCSSBlock(css) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
styles = append(styles, `<style type="text/css">`+css+`</style>`)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
return styles, withoutStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeEmailStyleAttrs(attrs string) bool {
|
||||||
|
attrs = strings.ToLower(strings.TrimSpace(attrs))
|
||||||
|
if attrs == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return regexp.MustCompile(`^\s*type\s*=\s*["']?text/css["']?\s*$`).MatchString(attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeEmailCSSBlock(value string) bool {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" || len(value) > 50000 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
|
||||||
|
for _, token := range unsafe {
|
||||||
|
if strings.Contains(value, token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeEmailCSSValue(value string) bool {
|
func safeEmailCSSValue(value string) bool {
|
||||||
|
|||||||
@@ -3114,17 +3114,31 @@ function escapeHtml(value: string) {
|
|||||||
}
|
}
|
||||||
function buildMailFrameSrcDoc(bodyHtml: string, bodyText: string) {
|
function buildMailFrameSrcDoc(bodyHtml: string, bodyText: string) {
|
||||||
const rawBody = bodyHtml.trim() ? bodyHtml : `<pre>${escapeHtml(bodyText || "")}</pre>`
|
const rawBody = bodyHtml.trim() ? bodyHtml : `<pre>${escapeHtml(bodyText || "")}</pre>`
|
||||||
const sanitizedBody = DOMPurify.sanitize(rawBody, {
|
const sanitized = DOMPurify.sanitize(rawBody, {
|
||||||
ADD_ATTR: ["style", "align", "valign", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"],
|
ADD_ATTR: ["style", "type", "align", "valign", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"],
|
||||||
ADD_TAGS: ["center"],
|
ADD_TAGS: ["html", "head", "body", "style", "center", "font"],
|
||||||
|
WHOLE_DOCUMENT: /<html[\s>]/i.test(rawBody) || /<body[\s>]/i.test(rawBody),
|
||||||
})
|
})
|
||||||
|
if (/<html[\s>]/i.test(sanitized) || /<body[\s>]/i.test(sanitized)) {
|
||||||
|
const hasHead = /<head[\s>]/i.test(sanitized)
|
||||||
|
const withBase = hasHead
|
||||||
|
? sanitized.replace(/<head([^>]*)>/i, `<head$1><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base target="_blank">${mailFrameBaseStyle()}`)
|
||||||
|
: sanitized.replace(/<html([^>]*)>/i, `<html$1><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><base target="_blank">${mailFrameBaseStyle()}</head>`)
|
||||||
|
return /<!doctype/i.test(withBase) ? withBase : `<!doctype html>${withBase}`
|
||||||
|
}
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<base target="_blank">
|
<base target="_blank">
|
||||||
<style>
|
${mailFrameBaseStyle()}
|
||||||
|
</head>
|
||||||
|
<body>${sanitized}</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
function mailFrameBaseStyle() {
|
||||||
|
return `<style>
|
||||||
html, body { margin: 0; padding: 0; background: #fff; color: #111827; }
|
html, body { margin: 0; padding: 0; background: #fff; color: #111827; }
|
||||||
body {
|
body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -3139,10 +3153,7 @@ function buildMailFrameSrcDoc(bodyHtml: string, bodyText: string) {
|
|||||||
table { max-width: 100%; }
|
table { max-width: 100%; }
|
||||||
pre { white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
pre { white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
a { color: #2563eb; }
|
a { color: #2563eb; }
|
||||||
</style>
|
</style>`
|
||||||
</head>
|
|
||||||
<body>${sanitizedBody}</body>
|
|
||||||
</html>`
|
|
||||||
}
|
}
|
||||||
function sanitizeComposerHtml(value: string) {
|
function sanitizeComposerHtml(value: string) {
|
||||||
return DOMPurify.sanitize(value || "")
|
return DOMPurify.sanitize(value || "")
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@ declare module "dompurify" {
|
|||||||
type SanitizeConfig = {
|
type SanitizeConfig = {
|
||||||
ADD_ATTR?: string[]
|
ADD_ATTR?: string[]
|
||||||
ADD_TAGS?: string[]
|
ADD_TAGS?: string[]
|
||||||
|
WHOLE_DOCUMENT?: boolean
|
||||||
}
|
}
|
||||||
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
|
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
|
||||||
export default DOMPurify
|
export default DOMPurify
|
||||||
|
|||||||
Reference in New Issue
Block a user