修复帖内 CSS 污染整页:前后端禁止 style 并消毒 HTML。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-04 13:04:10 +08:00
parent 2a34699f64
commit 860dffd547
9 changed files with 129 additions and 5 deletions

View File

@@ -9,7 +9,9 @@ import (
var (
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
)
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
@@ -41,6 +43,7 @@ func StripHTMLForSearch(html string) string {
if html == "" {
return ""
}
html = styleOrScriptRe.ReplaceAllString(html, " ")
text := htmlTagRe.ReplaceAllString(html, " ")
text = strings.ReplaceAll(text, "&nbsp;", " ")
return strings.Join(strings.Fields(text), " ")

View File

@@ -332,7 +332,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags))
postType = normalizePostType(postType)
if title == "" || content == "" {
@@ -382,7 +382,7 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
return err
}
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags))
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return err

47
service/sanitize_html.go Normal file
View File

@@ -0,0 +1,47 @@
package service
import (
"sync"
"github.com/microcosm-cc/bluemonday"
)
var (
postHTMLPolicyOnce sync.Once
postHTMLPolicy *bluemonday.Policy
)
// postContentHTMLPolicy 帖子正文白名单:对齐前端编辑器产出,禁止 style/script 等泄漏或执行向量。
func postContentHTMLPolicy() *bluemonday.Policy {
postHTMLPolicyOnce.Do(func() {
p := bluemonday.UGCPolicy()
// TipTap / Markdown 转换会用到的结构
p.AllowElements("div", "span", "u", "s", "center", "members-only")
p.AllowAttrs("class").OnElements(
"p", "div", "span", "pre", "code", "img", "a",
"h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
"members-only",
)
p.AllowAttrs(
"data-locked", "data-length",
"data-code-copy", "data-lang", "data-full",
"data-image-group", "data-layout", "data-display",
"data-clear-float",
).Globally()
p.AllowAttrs("target", "rel").OnElements("a")
// bluemonday 默认会剥 style 标签与 style 属性;此处不再放行
postHTMLPolicy = p
})
return postHTMLPolicy
}
// SanitizePostHTML 清洗帖子 HTML防止 <style> 等污染整页或脚本注入。
func SanitizePostHTML(html string) string {
if html == "" {
return ""
}
return postContentHTMLPolicy().Sanitize(html)
}

View File

@@ -0,0 +1,57 @@
package service
import (
"strings"
"testing"
)
func TestSanitizePostHTML_StripsStyleLeak(t *testing.T) {
in := "<center>你好</center>\n<style>\n* {color:red}\n</style>"
out := SanitizePostHTML(in)
if strings.Contains(strings.ToLower(out), "<style") {
t.Fatalf("应剥离 style 标签,得到: %q", out)
}
if strings.Contains(out, "color:red") {
t.Fatalf("不应保留 CSS 文本,得到: %q", out)
}
if !strings.Contains(out, "你好") {
t.Fatalf("应保留正文,得到: %q", out)
}
}
func TestSanitizePostHTML_StripsInlineStyleAndScript(t *testing.T) {
in := `<p style="color:red">段落</p><script>alert(1)</script><img src="/uploads/posts/a.jpg" data-display="wide" class="article-img">`
out := SanitizePostHTML(in)
if strings.Contains(strings.ToLower(out), "style=") {
t.Fatalf("应剥离 style 属性,得到: %q", out)
}
if strings.Contains(strings.ToLower(out), "<script") {
t.Fatalf("应剥离 script得到: %q", out)
}
if !strings.Contains(out, "data-display") {
t.Fatalf("应保留 data-display得到: %q", out)
}
}
func TestSanitizePostHTML_KeepsMembersOnlyAndImageGroup(t *testing.T) {
in := `<members-only data-locked="false"><p>密</p></members-only>` +
`<div data-image-group data-layout="cols-2" class="image-group"><img src="/uploads/posts/a.jpg" alt="x"></div>` +
`<p data-clear-float class="article-clear-float">清浮动</p>`
out := SanitizePostHTML(in)
for _, want := range []string{"members-only", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
if !strings.Contains(out, want) {
t.Fatalf("缺少 %q得到: %q", want, out)
}
}
}
func TestStripHTMLForSearch_DropsStyleText(t *testing.T) {
in := "<center>你好</center><style>* {color:red}</style>"
out := StripHTMLForSearch(in)
if strings.Contains(out, "color") || strings.Contains(out, "red") {
t.Fatalf("摘要不应含 CSS得到: %q", out)
}
if out != "你好" {
t.Fatalf("期望 %q得到 %q", "你好", out)
}
}