修复帖内 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

@@ -3,7 +3,11 @@ import type { Config } from 'dompurify';
import { enhanceCodeBlocks } from './enhanceCodeBlocks'; import { enhanceCodeBlocks } from './enhanceCodeBlocks';
import { enhanceHeadingAnchors } from './postHeadings'; import { enhanceHeadingAnchors } from './postHeadings';
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */ /**
* DOMPurify 配置:允许会员专属自定义标签与链接 target。
* 注意DOMPurify 3.x 默认放行 <style> 与 style=(只做 XSS 向 CSS 消毒),
* 全局选择器仍会污染整页,故显式禁止。
*/
export const POST_CONTENT_PURIFY_CONFIG: Config = { export const POST_CONTENT_PURIFY_CONFIG: Config = {
ADD_TAGS: ['members-only'], ADD_TAGS: ['members-only'],
ADD_ATTR: [ ADD_ATTR: [
@@ -13,6 +17,8 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = {
'data-clear-float', 'data-clear-float',
'class', 'class',
], ],
FORBID_TAGS: ['style', 'link', 'meta', 'base', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
FORBID_ATTR: ['style'],
}; };
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`; const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;

3
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/kardianos/service v1.2.2 github.com/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98 github.com/minio/minio-go/v7 v7.0.98
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
golang.org/x/image v0.44.0 golang.org/x/image v0.44.0
@@ -16,6 +17,7 @@ require (
) )
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
@@ -30,6 +32,7 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
github.com/KarpelesLab/gowebp v0.1.1 h1:W11ZrRVx+Zk4ypW5NBEU31FQzghICXIrAbAbO5yd4M0= github.com/KarpelesLab/gowebp v0.1.1 h1:W11ZrRVx+Zk4ypW5NBEU31FQzghICXIrAbAbO5yd4M0=
github.com/KarpelesLab/gowebp v0.1.1/go.mod h1:Js8OXPQ94yl94HqaO/9XuUqk0wOPod6uycryhzmTgsU= github.com/KarpelesLab/gowebp v0.1.1/go.mod h1:Js8OXPQ94yl94HqaO/9XuUqk0wOPod6uycryhzmTgsU=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -44,6 +46,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -65,6 +69,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=

View File

@@ -873,6 +873,8 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
if c.Query("skip_view") != "1" && post.Status == model.ContentStatusPublished { if c.Query("skip_view") != "1" && post.Status == model.ContentStatusPublished {
h.Post.RecordView(uint(id)) h.Post.RecordView(uint(id))
} }
// 出口再消毒:兼容库内历史脏 HTML如 <style>),避免旧帖污染整页
post.Content = service.SanitizePostHTML(post.Content)
if uid == 0 { if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content) post.Content = service.RedactMembersOnlyHTML(post.Content)
} }

View File

@@ -107,7 +107,7 @@ func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.Sit
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string { func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords) meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
content := service.RedactMembersOnlyHTML(post.Content) content := service.SanitizePostHTML(service.RedactMembersOnlyHTML(post.Content))
author := service.DisplayName(&post.User) author := service.DisplayName(&post.User)
var body strings.Builder var body strings.Builder
body.WriteString("<article>") body.WriteString("<article>")

View File

@@ -9,7 +9,9 @@ import (
var ( var (
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`) 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 未登录时移除会员专属区块内的正文,保留长度提示供前端展示 // RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
@@ -41,6 +43,7 @@ func StripHTMLForSearch(html string) string {
if html == "" { if html == "" {
return "" return ""
} }
html = styleOrScriptRe.ReplaceAllString(html, " ")
text := htmlTagRe.ReplaceAllString(html, " ") text := htmlTagRe.ReplaceAllString(html, " ")
text = strings.ReplaceAll(text, "&nbsp;", " ") text = strings.ReplaceAll(text, "&nbsp;", " ")
return strings.Join(strings.Fields(text), " ") 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) { func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title)) 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)) tags = s.filter.Filter(strings.TrimSpace(tags))
postType = normalizePostType(postType) postType = normalizePostType(postType)
if title == "" || content == "" { if title == "" || content == "" {
@@ -382,7 +382,7 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
return err return err
} }
title = s.filter.Filter(strings.TrimSpace(title)) 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)) tags = s.filter.Filter(strings.TrimSpace(tags))
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil { if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return err 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)
}
}