fix: 增强svg图片格式校验

This commit is contained in:
dujiao-next committed 2026-09-15 20:38:06 +08:00
1 parent 47cc6207c6
commit bbe1b2786c
4 files changed
+72 -31

No files matched your search

+2 -2
View File
@@ -99,14 +99,14 @@ upload:
- image/png
- image/gif
- image/webp
- image/svg+xml
# - image/svg+xml # SVG 可携带脚本,默认关闭;如需开启请同时放开下方 .svg 扩展名
allowed_extensions:
- .jpg
- .jpeg
- .png
- .gif
- .webp
- .svg
# - .svg
max_width: 4096
max_height: 4096
+10 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"net/http"
"path"
"sort"
"strings"
@@ -221,7 +222,15 @@ func SetupRouter(cfg *config.Config, c *container.Container) *gin.Engine {
r.Use(middleware.CallbackRouteMiddleware(c.SettingService, paymentCallbackHandler, paymentWebhookHandler, upstreamHandler))
// 静态文件服务(上传的图片)必须放在前面。
r.Static("/uploads", "./uploads")
// SVG 强制下载并禁止脚本:即使上传校验被绕过,直接打开 /uploads/x.svg 也不会在站点源下执行脚本;
// <img src> 引用不受 Content-Disposition 影响,正常显示。
r.Group("/uploads", func(c *gin.Context) {
if strings.EqualFold(path.Ext(c.Request.URL.Path), ".svg") {
c.Header("Content-Disposition", "attachment")
c.Header("Content-Security-Policy", "sandbox; script-src 'none'")
c.Header("X-Content-Type-Options", "nosniff")
}
}).Static("/", "./uploads")
// SEO 资源(动态生成)。
sitemaptransport.RegisterRoutes(r, sitemaptransport.NewHandler(c.SitemapService, sitemapbrand.New(c.SettingService)))
+50 -28
View File
@@ -1,7 +1,9 @@
package application
import (
"bytes"
"encoding/binary"
"encoding/xml"
"fmt"
"image"
"io"
@@ -230,37 +232,57 @@ func isSVGContent(buf []byte) bool {
strings.Contains(content, "<svg")
}
// validateSVGSafety 检查 SVG 内容安全性,禁止脚本和危险元素
// validateSVGSafety 用 XML 解析器逐 token 检查 SVG,禁止脚本、事件属性、危险元素与协议。
// 用解析器而非字符串匹配:`onload<Tab>=`、换行、实体编码等写法在浏览器里都会生效,正则黑名单无法穷举。
func validateSVGSafety(data []byte) error {
content := strings.ToLower(string(data))
// 禁止脚本标签
if strings.Contains(content, "<script") {
return fmt.Errorf("SVG 文件不允许包含 <script> 标签")
}
// 禁止事件处理属性(onclick, onload, onerror 等)
dangerousAttrs := []string{
"onload", "onclick", "onerror", "onmouseover", "onmouseout",
"onmousemove", "onfocus", "onblur", "onchange", "onsubmit",
"onanimationstart", "onanimationend", "onanimationiteration",
}
for _, attr := range dangerousAttrs {
if strings.Contains(content, attr+"=") || strings.Contains(content, attr+" =") {
return fmt.Errorf("SVG 文件不允许包含事件处理属性: %s", attr)
dec := xml.NewDecoder(bytes.NewReader(data))
dec.Strict = true // 浏览器把 SVG 当 XML 解析,格式错误同样拒绝,二者行为一致
for {
tok, err := dec.RawToken()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("SVG 文件解析失败: %v", err)
}
switch t := tok.(type) {
case xml.StartElement:
name := strings.ToLower(t.Name.Local)
if name == "script" {
return fmt.Errorf("SVG 文件不允许包含 <script> 标签")
}
if name == "foreignobject" {
return fmt.Errorf("SVG 文件不允许包含 <foreignObject> 元素")
}
for _, attr := range t.Attr {
aname := strings.ToLower(attr.Name.Local)
if strings.HasPrefix(aname, "on") {
return fmt.Errorf("SVG 文件不允许包含事件处理属性: %s", aname)
}
val := strings.ToLower(strings.TrimSpace(attr.Value))
val = strings.Map(func(r rune) rune { // 去掉控制/空白字符,浏览器解析 URL 时会忽略它们
if r <= ' ' {
return -1
}
return r
}, val)
if strings.HasPrefix(val, "javascript:") {
return fmt.Errorf("SVG 文件不允许包含 javascript: 协议")
}
if strings.HasPrefix(val, "data:text/html") || strings.HasPrefix(val, "data:application") || strings.HasPrefix(val, "data:image/svg") {
return fmt.Errorf("SVG 文件不允许包含危险的 data: URI")
}
}
case xml.ProcInst:
if strings.ToLower(t.Target) != "xml" {
return fmt.Errorf("SVG 文件不允许包含处理指令: %s", t.Target)
}
case xml.Directive:
if strings.Contains(strings.ToLower(string(t)), "<!entity") {
return fmt.Errorf("SVG 文件不允许声明实体")
}
}
}
// 禁止 javascript: 协议
if strings.Contains(content, "javascript:") {
return fmt.Errorf("SVG 文件不允许包含 javascript: 协议")
}
// 禁止 data: URI(可用于绕过 CSP)
if strings.Contains(content, "data:text/html") || strings.Contains(content, "data:application") {
return fmt.Errorf("SVG 文件不允许包含危险的 data: URI")
}
// 禁止 foreignObject(可嵌入 HTML)
if strings.Contains(content, "<foreignobject") {
return fmt.Errorf("SVG 文件不允许包含 <foreignObject> 元素")
}
return nil
}
func decodeWebPDimensions(src io.ReadSeeker) (int, int, error) {
@@ -185,6 +185,16 @@ func TestValidateSVGSafety(t *testing.T) {
{"javascript href", `<svg><a href="javascript:void(0)"></a></svg>`, true},
{"data uri html", `<svg><image href="data:text/html,<h1>hi</h1>"/></svg>`, true},
{"foreignObject", `<svg><foreignObject></foreignObject></svg>`, true},
{"onload tab before equals", "<svg xmlns=\"http://www.w3.org/2000/svg\" onload\t=\"alert(1)\"></svg>", true},
{"onload newline before equals", "<svg onload\n=\"alert(1)\"></svg>", true},
{"onload double space", `<svg onload ="alert(1)"></svg>`, true},
{"unlisted event attr onbegin", `<svg><set attributeName="x" onbegin="alert(1)"/></svg>`, true},
{"entity encoded javascript", `<svg><a href="&#106;avascript:alert(1)"></a></svg>`, true},
{"xlink href javascript", `<svg xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"></a></svg>`, true},
{"xml-stylesheet pi", `<?xml-stylesheet href="http://evil/x.css"?><svg></svg>`, true},
{"entity declaration", `<!DOCTYPE svg [<!ENTITY x "y">]><svg>&x;</svg>`, true},
{"malformed xml", `<svg onload="alert(1)"`, true},
{"safe with doctype and style", `<?xml version="1.0"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg"><style>.a{fill:red}</style><rect class="a" width="10" height="10"/></svg>`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {