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:
5 files changed
+65
-4
No files matched your search
+3
-3
@@ -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 删除检查)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user