fix: SECURITY_TODO #29 favicon/logo 上传增加魔数一致性校验

saveSiteImage 只查扩展名白名单 + Category=image 即落盘,管理员可将
HTML 字节存为 logos/logo.png(当前仅靠 nosniff + 按扩展名 Content-Type
兜底,纵深防御链条断裂)。现与头像/附件上传(#14/#21)口径一致:
读取字节后 contentMatchesType 校验,不匹配返回 400
(settings_upload_bad_content,i18n 中英新增)。

- handlers/settings.go: saveSiteImage 魔数校验后再写盘
- 测试: TestSiteImageUploadRejectsMismatchedContent
  (favicon/logo 各:PNG 扩展名 + HTML 字节 → 400;真实 PNG → 200)
This commit is contained in:
dsh
2026-08-27 21:41:02 +08:00
parent 52ca450ddd
commit de27e53287
5 files changed
+65 -4

No files matched your search

+3 -3
View File
@@ -194,12 +194,12 @@
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
- **验证**: ✅ `TestRegisterRejectsInvalidEmail``TestProfileEmailValidation``TestAdminUserPasswordAndEmailEnforcement``abc` 均拒绝、合法邮箱正常)
### [ ] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)
### [x] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)✅ 2026-08-27
- **位置**: `handlers/settings.go`saveSiteImage
- **问题**: 头像上传(profile.go)与附件上传(attachment.go)均调用 `contentMatchesType` 做魔数一致性校验(#14/#21),但 saveSiteImage 只查扩展名白名单 + Category=image 即 `io.Copy` 落盘——管理员可把 HTML 内容存为 `logos/logo.png`。当前由 `X-Content-Type-Options: nosniff` + 按扩展名的 Content-Type 兜底(浏览器不会执行),但纵深防御链条在此断裂。
- **修复**:
- [ ] saveSiteImage 读取字节后调用 `contentMatchesType(check.Type, content)`,不匹配返回 400(与头像上传口径一致)
- **验证**: [ ] 测试PNG 扩展名 + HTML 字节 → 400;正常 PNG → 200
- [x] saveSiteImage 读取字节后调用 `contentMatchesType(check.Type, content)`,不匹配返回 400`settings_upload_bad_content`i18n 中英新增;与头像上传口径一致)
- **验证**: `TestSiteImageUploadRejectsMismatchedContent`favicon/logo 各PNG 扩展名 + HTML 字节 → 400;正常 PNG → 200
### [ ] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)
- **位置**: `handlers/admin_user.go`UserUpdate 降级检查、UserDelete 删除检查)
+3
View File
@@ -128,6 +128,9 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
adminSettingsAPI.POST("/upload", UploadSettingsSave(db))
// 站点 favicon/logo 上传(#29 魔数校验覆盖)。
adminSettingsAPI.POST("/site/favicon", SiteFaviconUpload(db, storageDir))
adminSettingsAPI.POST("/site/logo", SiteLogoUpload(db, storageDir))
}
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
+15 -1
View File
@@ -201,6 +201,20 @@ func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.Handl
return
}
// SECURITY_TODO #29:与头像/附件上传一致,做魔数一致性校验——
// 管理员不得把 HTML 字节另存为 .png 等图片扩展名。当前靠
// X-Content-Type-Options: nosniff + 按扩展名的 Content-Type 兜底,
// 纵深防御链条在此补齐。
content, err := io.ReadAll(file)
if err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
if !contentMatchesType(check.Type, content) {
APIError(c, http.StatusBadRequest, "settings_upload_bad_content")
return
}
logoDir := filepath.Join(storagePath, "logos")
os.MkdirAll(logoDir, 0755)
// 删除之前的本地文件(跳过外部 URL)。
@@ -219,7 +233,7 @@ func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.Handl
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
if _, err := dst.Write(content); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
+42
View File
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"testing"
"go_blog/models"
)
// TestSiteImageUploadRejectsMismatchedContent 覆盖 SECURITY_TODO #29
// favicon/logo 上传必须通过魔数一致性校验——PNG 扩展名携带 HTML 字节
// 返回 400 且不落盘;真实 PNG 成功保存。
func TestSiteImageUploadRejectsMismatchedContent(t *testing.T) {
e := newSecurityTestEnv(t)
seedUploadType(t, e, ".png", models.CategoryImage)
// 配置图片 MIME 策略,使内容校验有据可依(种子行的策略为空值,宽容放行)。
e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".png").Update("mime_type", "image/png")
models.LoadConfigCache(e.db)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
for _, endpoint := range []struct{ path, field string }{
{"/api/admin/settings/site/favicon", "favicon"},
{"/api/admin/settings/site/logo", "logo"},
} {
// .png 扩展名 + HTML 字节 → 400(内容与声明类型不匹配)。
w := e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
"logo.png", []byte("<html><script>alert(1)</script></html>"), nil)
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_bad_content" {
t.Fatalf("%s mismatched content: status = %d, code = %q, want 400/settings_upload_bad_content",
endpoint.path, w.Code, respCode(w))
}
// 真实 PNG → 200。
w = e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
"logo.png", pngBytes(t), nil)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("%s valid png: status = %d, body %s", endpoint.path, w.Code, w.Body.String())
}
}
}
+2
View File
@@ -453,6 +453,7 @@ var translations = map[Lang]map[string]string{
"request_too_large": "The request body exceeds the size limit.",
"register_locked": "Too many registration attempts from your address. Please try again later.",
"comments_locked": "Too many comments from your address. Please wait a moment and try again.",
"settings_upload_bad_content": "File content does not match its declared type.",
"user_not_found": "User not found.",
},
ZH: {
@@ -892,6 +893,7 @@ var translations = map[Lang]map[string]string{
"request_too_large": "请求体超过大小限制。",
"register_locked": "来自该地址的注册次数过多,请稍后再试。",
"comments_locked": "评论提交过于频繁,请稍后再试。",
"settings_upload_bad_content": "文件内容与声明类型不匹配。",
"user_not_found": "用户不存在。",
},
}