fix: 增强svg图片格式校验
This commit is contained in:
1 parent
47cc6207c6
commit
bbe1b2786c
4 files changed
+72
-31
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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="javascript: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) {
|
||||
|
||||
Reference in new issue
Block a user