修复帖内 CSS 污染整页:前后端禁止 style 并消毒 HTML。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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, " ", " ")
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
|
||||
@@ -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
47
service/sanitize_html.go
Normal 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)
|
||||
}
|
||||
57
service/sanitize_html_test.go
Normal file
57
service/sanitize_html_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user