修复帖内 CSS 污染整页:前后端禁止 style 并消毒 HTML。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
3
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
|||||||
@@ -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, " ", " ")
|
text = strings.ReplaceAll(text, " ", " ")
|
||||||
return strings.Join(strings.Fields(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) {
|
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
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