diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index 07089f8..55e95e4 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -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 删除检查) diff --git a/handlers/security_test.go b/handlers/security_test.go index 7df2cc4..a957478 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -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)。 diff --git a/handlers/settings.go b/handlers/settings.go index 0b38c88..e21e051 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -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 } diff --git a/handlers/site_image_test.go b/handlers/site_image_test.go new file mode 100644 index 0000000..1011d4f --- /dev/null +++ b/handlers/site_image_test.go @@ -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(""), 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()) + } + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 2a8e320..c84618a 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -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": "用户不存在。", }, }