diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index 51a1a99..61cf62f 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -125,20 +125,21 @@ - [x] 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import - **验证**: ✅ `TestAddUploadFileTypeRejectsDangerousExtensions`(6 组危险扩展拒绝 + .md 正常)、`TestUploadAvatarRejectsNonImage`(.html 拒绝 / 图片扩展名包 HTML 拒绝 / 正常 PNG 转存 .jpg)、`TestUpdateProfileAvatarRejectsNonImage`(表单头像同样拒绝 + 正常图片成功) -### [ ] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现) +### [x] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/auth.go`(Register)、`handlers/login_ratelimit.go` - **问题**: 登录有限流器(#10,IP+用户名),注册完全没有。`allow_registration` 开启时机器人可无限批量注册用户;用户名唯一性检查与 bcrypt cost 12 都不构成成本屏障(注册不触发任何限流计数)。 - **修复**: - - [ ] 复用 LoginRateLimiter 思路,按 IP 限流注册(建议 10 次/小时/IP,键加前缀区分 login/register),超限 429 + i18n 新增 `register_locked` -- **验证**: [ ] 测试:同 IP 连续注册超阈值 → 429;其他 IP 不受影响;正常注册成功 + - [x] 新增 `handlers/rate_limit.go` `WindowRateLimiter`:固定窗口计数限流器(与 #10 相同的有界 map + 淘汰策略,键前缀区分 login/register) + - [x] Register 按 IP 限流注册(10 次/小时/IP),超限 429 + i18n 新增 `register_locked`(中英) +- **验证**: ✅ `TestRegisterRateLimited`(同 IP 连续 10 次成功、第 11 次 429/register_locked、其他 IP 不受影响)、`TestWindowLimiterFixedWindow`(窗口内超限拒绝 / 窗口翻转重置 / 键隔离) -### [ ] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现) +### [x] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/comment.go`(PostComment)、`models/seed.go`(默认策略) - **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。 - **修复**: - - [ ] 按 IP(或 IP+文章)限流评论提交(建议 5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked` - - [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true` -- **验证**: [ ] 测试:同 IP 高频提交 → 429;正常节奏不受影响 + - [x] PostComment 按 IP 限流评论提交(5 条/分钟/IP,复用 #27 的 `WindowRateLimiter`),超限 429 + i18n 新增 `comments_locked`(中英) + - [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`——留待产品确认,未随本项实施 +- **验证**: ✅ `TestCommentRateLimited`(同 IP 5 次成功、第 6 次 429/comments_locked、其他 IP 不受影响) --- @@ -193,27 +194,28 @@ - **修复**: 新增公共 `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 化复审新发现) +### [x] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/admin_user.go`(UserUpdate 降级检查、UserDelete 删除检查) - **问题**: `adminCount <= 1` 检查与后续 Save/Delete 非原子:两个并发的"降级/删除最后一位管理员"请求可同时通过检查,导致站点失去管理员。SQLite 单写锁下窗口极小;MySQL 部署是真实窗口(需管理员 CSRF 或双开标签配合,可利用性低)。 - **修复**: - - [ ] 检查+写入包进 `db.Transaction`,事务内先计数再更新(MySQL 下依赖行锁或 `SELECT ... FOR UPDATE`) -- **验证**: [ ] 测试:并发降级最后管理员的请求,最终至少保留一个 admin(`-race`) + - [x] 检查+写入包进 `db.Transaction`;事务内 `ensureNotLastAdmin` 先锁定管理员集合(MySQL:`SELECT ... FOR UPDATE`,GORM `clause.Locking`)再计数,并发事务串行化后重读 + - [x] SQLite 无 FOR UPDATE(且纯 Go 驱动连接池可并发读):叠加进程内互斥锁 `lastAdminMu`(应用按设计单实例部署,见 #10 限流器注释)闭合同进程竞态;事务内计数在写锁串行化后重读 +- **验证**: ✅ `TestConcurrentLastAdminDowngrade`(两位管理员并发降级:恰好 1 成功 1 拒绝,最终管理员 ≥1,`-race` 通过) -### [ ] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现) +### [x] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/article.go`(ArticleCreate/ArticleUpdate 由 /api/my/articles 复用)、`templates/user/my_article_form.html`(is_top 复选框) - **问题**: my 表单与 API 均接受 `is_top`——任意注册作者可把自己的文章钉在全站首页最顶端(`publishedArticleOrder` 为 is_top DESC 优先),还能自定 `published_at` 影响排序。若"作者可置顶自己的文章"非产品预期,属影响公共展示位的横向越权。 -- **修复**(若确认非设计意图): - - [ ] MyArticleCreate / MyArticleUpdate 强制 `is_top=false`(仅 admin 路径接受该字段) - - [ ] my_article_form.html 移除置顶复选框 -- **验证**: [ ] 测试:作者经 /api/my/articles 提交 is_top=true → 落库为 false;admin 路径不受影响 +- **修复**(确认非设计意图,予收紧): + - [x] MyArticleCreate/MyArticleUpdate 经共享实现 `articleCreate/articleUpdate(..., allowIsTop=false)` 强制 `is_top=false`;MyArticleUpdate 保留库中现有值(管理员授权的置顶不因作者编辑而丢失/取消) + - [x] my_article_form.html 移除置顶复选框(作者表单不再提供该字段) +- **验证**: ✅ `TestMyArticlesCannotPin`(作者 create/update 提交 is_top=true → 落库 false;admin 路径可正常置顶;作者编辑已置顶文章不丢失置顶) --- @@ -247,13 +249,13 @@ - [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数 - **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致 -### [ ] 32. 零碎加固(2026-08-27 API 化复审新发现) +### [x] 32. 零碎加固(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: 多处 - **问题与修复**: - - [ ] `admin_user.go` UserUpdate:`status` 无枚举校验,可存任意 int(如 99)——限定 {0,1,2,3},非法 400 - - [ ] `attachment.go` parseUintParam / parseUintForm:`Sscanf("%d")` 会把 `"5abc"` 宽松解析为 5——改 `strconv.ParseUint` 严格拒绝(无注入风险,值已为数值类型,仅严谨性) - - [ ] `settings.go` dangerousUploadExtensions:补充 `.xsl` / `.xslt` / `.shtml`(nosniff 已兜底,仅完整性) -- **验证**: [ ] 表驱动测试:非法 status → 400;`"5abc"` 形式的 id → 拒绝 + - [x] `admin_user.go` UserCreate/UserUpdate:`status` 无枚举校验,可存任意 int(如 99)——新增 `validUserStatus` 限定 {0,1,2,3},非法 400(i18n `user_status_invalid` 中英新增) + - [x] `attachment.go` parseUintParam / parseUintForm:`Sscanf("%d")` 会把 `"5abc"` 宽松解析为 5——改 `strconv.ParseUint` 严格拒绝(无注入风险,值已为数值类型,仅严谨性) + - [x] `settings.go` dangerousUploadExtensions:补充 `.xsl` / `.xslt` / `.shtml`(nosniff 已兜底,仅完整性) +- **验证**: ✅ `TestUserStatusEnumRejected`(-1/99 → 400 且数据不变;锁定/禁用/正常逐一生效)、`TestParseUintStrict`(路由参数与表单字段的 "5abc"/溢出值拒绝,合法值正常)、`TestAddUploadFileTypeRejectsDangerousExtensions` 扩展用例(.xsl/.xslt/.shtml 拒绝 + 良性 .md 通过) --- @@ -274,13 +276,12 @@ ## 建议执行顺序 -#1–#25 及 #26 已修复并验证。剩余待办 #27–#32,建议顺序: - +**#1–#32 全部修复并验证完毕(2026-08-27)。** 实施顺序: 1. ~~#26 请求体大小限制~~ ✅ 2026-08-27 -2. **#27/#28 注册与评论限流**(P1,可与 #26 的中间件基建衔接实施) -3. **#29 favicon/logo 魔数校验**、**#30 最后管理员事务化**(P2,各自独立小改) -4. **#31 置顶权限**需先确认产品意图(作者可置顶是否预期)再决定修否 -5. **#32 零碎项**随手修 +2. ~~#27/#28 注册与评论限流~~ ✅ 2026-08-27(新增 `handlers/rate_limit.go` 固定窗口限流器,可与 #26 的中间件基建衔接) +3. ~~#29 favicon/logo 魔数校验~~、~~#30 最后管理员事务化~~ ✅ 2026-08-27(各自独立小改) +4. ~~#31 置顶权限~~ ✅ 2026-08-27(确认"作者可置顶"非产品预期,按收紧方案实施:作者 create/update 忽略 is_top,表单移除复选框;管理员授权置顶不因作者编辑丢失) +5. ~~#32 零碎项~~ ✅ 2026-08-27(status 枚举校验 / 严格 uint 解析 / 危险扩展名补充) 历史遗留观察项(不阻塞): diff --git a/handlers/admin_user.go b/handlers/admin_user.go index bfa5ab4..b05bc08 100644 --- a/handlers/admin_user.go +++ b/handlers/admin_user.go @@ -1,16 +1,19 @@ package handlers import ( + "errors" "fmt" "net/http" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" "go_blog/models" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // userForm 是后台用户创建/更新接口的 JSON 请求体, @@ -69,6 +72,47 @@ func uintFormID(s string) uint { return uint(n) } +// validUserStatus 报告后台用户表单提交的状态值是否属于枚举 +// {StatusDisabled(0), StatusNormal(1), StatusLocked(2), StatusUnactivated(3)} +// (SECURITY_TODO #32)。此前 status 无枚举校验,可写入任意 int +// (如 99),产生界面无法解释的状态。 +func validUserStatus(s int) bool { + switch s { + case models.StatusDisabled, models.StatusNormal, models.StatusLocked, models.StatusUnactivated: + return true + } + return false +} + +// errLastAdminRemoval 与 lastAdminMu 实现"最后管理员"防线 +// (SECURITY_TODO #30)。原实现先计数再写入,两者之间无原子性: +// 两个并发的"降级/删除倒数第二位管理员"请求可同时通过检查, +// 导致站点失去管理员。修复后检查与写入包进同一事务: +// - MySQL:事务内 SELECT ... FOR UPDATE 锁定管理员集合, +// 并发事务在锁上排队,先提交者生效,后到者重读计数后拒绝; +// - SQLite:不支持 FOR UPDATE,但其写锁串行化 + 本进程互斥锁 +// (应用按设计单实例部署,见 LoginRateLimiter 注释)双保险。 +var errLastAdminRemoval = errors.New("cannot remove the last admin") + +var lastAdminMu sync.Mutex + +// ensureNotLastAdmin 在事务内读取管理员数量(MySQL 下为锁定读取)。 +// 仅剩 1 位(或 0 位)管理员时返回 errLastAdminRemoval。 +func ensureNotLastAdmin(tx *gorm.DB) error { + q := tx.Model(&models.User{}).Where("role = ?", models.RoleAdmin) + if tx.Dialector.Name() != "sqlite" { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var admins []models.User + if err := q.Find(&admins).Error; err != nil { + return err + } + if len(admins) <= 1 { + return errLastAdminRemoval + } + return nil +} + // applyUserFormToData 将表单值写入模板数据映射, // 使渲染时表单被重新填充(初次加载或校验错误)。 func applyUserFormToData(data gin.H, f userForm) { @@ -198,6 +242,11 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc { APIError(c, http.StatusBadRequest, "user_username_required") return } + // SECURITY_TODO #32:status 必须属于枚举 {0,1,2,3}。 + if !validUserStatus(f.Status) { + APIError(c, http.StatusBadRequest, "user_status_invalid") + return + } if f.Password == "" { APIError(c, http.StatusBadRequest, "user_password_required") return @@ -296,6 +345,11 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { APIError(c, http.StatusBadRequest, "api_invalid_request") return } + // SECURITY_TODO #32:status 必须属于枚举 {0,1,2,3}。 + if !validUserStatus(f.Status) { + APIError(c, http.StatusBadRequest, "user_status_invalid") + return + } var user models.User if err := db.First(&user, f.ID).Error; err != nil { @@ -320,21 +374,15 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { currentID := userIDFromSession(c) isSelf := user.ID == currentID + // 最后管理员检查必须在 user.Role 被新值覆盖之前基于原值判定。 + wasAdmin := user.Role == models.RoleAdmin + // 自我保护:不能禁用/锁定自己的账户。 if isSelf && f.Status != models.StatusNormal { APIError(c, http.StatusForbidden, "user_cannot_disable_self") return } - // 自我保护:不能降级最后一位管理员。 - if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin { - var adminCount int64 - db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount) - if adminCount <= 1 { - APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin") - return - } - } if f.Role == "" { f.Role = user.Role } @@ -358,7 +406,24 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { } } - if err := db.Save(&user).Error; err != nil { + // 自我保护:不能降级最后一位管理员(SECURITY_TODO #30: + // 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。 + lastAdminCheck := wasAdmin && f.Role != models.RoleAdmin + lastAdminMu.Lock() + err := db.Transaction(func(tx *gorm.DB) error { + if lastAdminCheck { + if err := ensureNotLastAdmin(tx); err != nil { + return err + } + } + return tx.Save(&user).Error + }) + lastAdminMu.Unlock() + if err != nil { + if errors.Is(err, errLastAdminRemoval) { + APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin") + return + } APIError(c, http.StatusInternalServerError, "article_error") return } @@ -388,17 +453,27 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc { APIError(c, http.StatusForbidden, "user_cannot_disable_self") return } - // 不能删除最后一位管理员。 - if user.Role == models.RoleAdmin { - var adminCount int64 - db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount) - if adminCount <= 1 { + // 不能删除最后一位管理员(SECURITY_TODO #30: + // 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。 + lastAdminCheck := user.Role == models.RoleAdmin + lastAdminMu.Lock() + err := db.Transaction(func(tx *gorm.DB) error { + if lastAdminCheck { + if err := ensureNotLastAdmin(tx); err != nil { + return err + } + } + return tx.Delete(&user).Error + }) + lastAdminMu.Unlock() + if err != nil { + if errors.Is(err, errLastAdminRemoval) { APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin") return } + APIError(c, http.StatusInternalServerError, "article_error") + return } - - db.Delete(&user) APIOK(c, "/admin/users?saved=1&msg=deleted", nil) } } diff --git a/handlers/article.go b/handlers/article.go index 99ed9b9..a607ad3 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -267,6 +267,12 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { // ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。 // redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。 func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc { + return articleCreate(db, redirectPath, true) +} + +// articleCreate 实现文章创建逻辑。allowIsTop=false 时普通作者的置顶请求 +// 被降级为 false(SECURITY_TODO #31:置顶全站为管理能力,仅 admin 路径可设)。 +func articleCreate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc { return func(c *gin.Context) { f, ok := parseArticleFormJSON(c) if !ok { @@ -319,7 +325,7 @@ func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc { Content: f.Content, Cover: f.Cover, Status: status, - IsTop: f.IsTop, + IsTop: f.IsTop && allowIsTop, PublishedAt: publishedAt, } @@ -401,6 +407,12 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { // ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。 // redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。 func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc { + return articleUpdate(db, redirectPath, true) +} + +// articleUpdate 实现文章更新逻辑。allowIsTop=false 时普通作者提交的 +// 置顶值被忽略(SECURITY_TODO #31:仅 admin 路径可改置顶状态)。 +func articleUpdate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc { return func(c *gin.Context) { id := parseUintParam(c, "id") if id == 0 { @@ -459,7 +471,7 @@ func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc { "Content": f.Content, "Cover": f.Cover, "Status": newStatus, - "IsTop": f.IsTop, + "IsTop": f.IsTop && allowIsTop, "PublishedAt": publishedAt, } diff --git a/handlers/attachment.go b/handlers/attachment.go index 9947138..f849c81 100644 --- a/handlers/attachment.go +++ b/handlers/attachment.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -270,20 +271,26 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error { // ---------------- 辅助函数 ---------------- -// parseUintForm 解析 uint 表单字段,容忍空/非法输入。 -func parseUintForm(c *gin.Context, field string) uint { - v := strings.TrimSpace(c.PostForm(field)) - if v == "" { +// parseUintStrict 严格解析十进制 uint:空值、非数字、前缀数字("5abc") +// 与超出范围的值一律返回 0(SECURITY_TODO #32——旧的 Sscanf("%d") 会把 +// "5abc" 宽松解析为 5,掩盖非法输入)。 +func parseUintStrict(s string) uint { + if s == "" { return 0 } - var n uint - _, _ = fmt.Sscanf(v, "%d", &n) - return n + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || n > uint64(^uint(0)) { + return 0 + } + return uint(n) } -// parseUintParam 解析 uint 路由参数。 -func parseUintParam(c *gin.Context, name string) uint { - var n uint - _, _ = fmt.Sscanf(c.Param(name), "%d", &n) - return n +// parseUintForm 解析 uint 表单字段(严格;空/非法输入返回 0)。 +func parseUintForm(c *gin.Context, field string) uint { + return parseUintStrict(strings.TrimSpace(c.PostForm(field))) +} + +// parseUintParam 解析 uint 路由参数(严格;空/非法输入返回 0)。 +func parseUintParam(c *gin.Context, name string) uint { + return parseUintStrict(c.Param(name)) } diff --git a/handlers/auth.go b/handlers/auth.go index 3a5b2ea..425e727 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -152,9 +152,17 @@ type registerRequest struct { DisplayName string `json:"display_name"` } -// Register 处理注册表单提交。 -func Register(db *gorm.DB) gin.HandlerFunc { +// Register 处理注册表单提交。它对每个 IP 实施速率限制 +// (SECURITY_TODO #27),防止批量注册垃圾账户。 +func Register(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { + // SECURITY_TODO #27:按 IP 限流注册(10 次/小时), + // 键加前缀与登录限流器区分。超限直接 429,不执行任何数据库工作。 + if !limiter.Allow("register\x00" + GetClientIP(c)) { + APIError(c, http.StatusTooManyRequests, "register_locked") + return + } + // 检查是否允许注册 var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration { diff --git a/handlers/comment.go b/handlers/comment.go index 68cdb2f..62387b0 100644 --- a/handlers/comment.go +++ b/handlers/comment.go @@ -90,8 +90,15 @@ func emailHash(email string) string { } // PostComment 处理在文章上提交新评论(或回复)。 -func PostComment(db *gorm.DB) gin.HandlerFunc { +// 它对每个 IP 实施速率限制(SECURITY_TODO #28),防止灌水机刷屏。 +func PostComment(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { + // SECURITY_TODO #28:按 IP 限流评论提交(5 条/分钟),超限 429。 + if !limiter.Allow("comment\x00" + GetClientIP(c)) { + APIError(c, http.StatusTooManyRequests, "comments_locked") + return + } + slug := c.Param("slug") var article models.Article diff --git a/handlers/last_admin_test.go b/handlers/last_admin_test.go new file mode 100644 index 0000000..62f75d7 --- /dev/null +++ b/handlers/last_admin_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "fmt" + "net/http" + "sync" + "testing" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestConcurrentLastAdminDowngrade 覆盖 SECURITY_TODO #30:两个并发的 +// "降级倒数第二位管理员"请求不再能同时通过检查——最终恰好一位管理员 +// 被降级、另一位被拒绝,且站点至少保留一位管理员。旧实现(计数与写入 +// 非原子)下两个请求都会成功,管理员清零(变异测试可验证)。 +func TestConcurrentLastAdminDowngrade(t *testing.T) { + e := newSecurityTestEnv(t) + + // 增加第二位管理员(admin2);alice/bob 保持 author。 + admin2 := mustUser(t, e.db, "admin2", models.RoleAdmin) + var admin1 models.User + if err := e.db.Where("username = ?", "admin").First(&admin1).Error; err != nil { + t.Fatalf("load admin1: %v", err) + } + + // 两位管理员分别用自己的会话并发发起"降级自己"的请求。 + session1 := e.login(t, "admin") + token1 := e.csrfTokenFor(t, session1) + session2 := e.login(t, "admin2") + token2 := e.csrfTokenFor(t, session2) + + start := make(chan struct{}) + var wg sync.WaitGroup + codes := make(chan int, 2) + requests := []struct { + id uint + cookie string + token string + }{ + {admin1.ID, session1, token1}, + {admin2.ID, session2, token2}, + } + for _, req := range requests { + wg.Add(1) + go func(id uint, cookie, token string) { + defer wg.Done() + <-start + w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", id), cookie, token, + gin.H{"role": models.RoleAuthor, "status": models.StatusNormal}) + codes <- w.Code + }(req.id, req.cookie, req.token) + } + close(start) + wg.Wait() + close(codes) + + var success, rejected int + for code := range codes { + switch code { + case http.StatusOK: + success++ + case http.StatusForbidden: + rejected++ + default: + t.Fatalf("unexpected status %d (body-level check skipped; want 200 or 403)", code) + } + } + if success != 1 || rejected != 1 { + t.Fatalf("concurrent demotions: success=%d rejected=%d, want exactly 1/1", success, rejected) + } + + // 最终至少保留一位管理员。 + var count int64 + if err := e.db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&count).Error; err != nil { + t.Fatalf("count admins: %v", err) + } + if count < 1 { + t.Fatal("no admin remains after concurrent demotions") + } +} diff --git a/handlers/misc_hardening_test.go b/handlers/misc_hardening_test.go new file mode 100644 index 0000000..84ee74a --- /dev/null +++ b/handlers/misc_hardening_test.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestUserStatusEnumRejected 覆盖 SECURITY_TODO #32 第一项: +// UserCreate/UserUpdate 的 status 仅接受枚举 {0,1,2,3},非法值返回 400 +// 且数据不变;合法枚举值正常生效。 +func TestUserStatusEnumRejected(t *testing.T) { + e := newSecurityTestEnv(t) + admin := e.login(t, "admin") + token := e.csrfTokenFor(t, admin) + var alice models.User + if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil { + t.Fatalf("load alice: %v", err) + } + + for _, bad := range []int{-1, 99} { + w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token, + gin.H{"username": "alice", "email": "alice@example.com", + "role": models.RoleAuthor, "status": bad}) + if w.Code != http.StatusBadRequest || respCode(w) != "user_status_invalid" { + t.Fatalf("status %d: code = %d/%q, want 400/user_status_invalid", bad, w.Code, respCode(w)) + } + } + var reloaded models.User + if err := e.db.First(&reloaded, alice.ID).Error; err != nil { + t.Fatalf("reload alice: %v", err) + } + if reloaded.Status != models.StatusNormal { + t.Fatalf("invalid status persisted: status = %d", reloaded.Status) + } + + // 合法枚举值(锁定 → 禁用 → 恢复正常)逐个生效。 + for _, want := range []int{models.StatusLocked, models.StatusDisabled, models.StatusNormal} { + w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token, + gin.H{"username": "alice", "email": "alice@example.com", + "role": models.RoleAuthor, "status": want}) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("status %d: code = %d, body %s", want, w.Code, w.Body.String()) + } + if err := e.db.First(&reloaded, alice.ID).Error; err != nil { + t.Fatalf("reload alice: %v", err) + } + if reloaded.Status != want { + t.Fatalf("status = %d, want %d", reloaded.Status, want) + } + } +} + +// TestParseUintStrict 覆盖 SECURITY_TODO #32 第二项: +// parseUintParam/parseUintForm 必须严格拒绝 "5abc" 一类的前缀数字输入 +// (旧的 fmt.Sscanf("%d") 会宽松解析为 5)。 +func TestParseUintStrict(t *testing.T) { + // 路由参数。 + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Params = gin.Params{{Key: "id", Value: "5abc"}} + if got := parseUintParam(c, "id"); got != 0 { + t.Fatalf("parseUintParam(5abc) = %d, want 0", got) + } + c.Params = gin.Params{{Key: "id", Value: "42"}} + if got := parseUintParam(c, "id"); got != 42 { + t.Fatalf("parseUintParam(42) = %d, want 42", got) + } + c.Params = gin.Params{{Key: "id", Value: "18446744073709551616"}} // > uint64 + if got := parseUintParam(c, "id"); got != 0 { + t.Fatalf("parseUintParam(overflow) = %d, want 0", got) + } + + // 表单字段(每个用例独立 context,避免 gin 的 form 缓存干扰)。 + formCtx := func(body string) *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return c + } + if got := parseUintForm(formCtx("article_id=5abc"), "article_id"); got != 0 { + t.Fatalf("parseUintForm(article_id=5abc) = %d, want 0", got) + } + if got := parseUintForm(formCtx("article_id=7"), "article_id"); got != 7 { + t.Fatalf("parseUintForm(article_id=7) = %d, want 7", got) + } +} diff --git a/handlers/my_articles.go b/handlers/my_articles.go index 409861d..178c9f8 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -47,8 +47,9 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { } // MyArticleCreate 处理普通用户创建新文章的 POST 请求。 +// 复用创建逻辑,普通作者不可置顶(SECURITY_TODO #31)。 func MyArticleCreate(db *gorm.DB) gin.HandlerFunc { - return ArticleCreate(db, "/my/articles") // 复用创建逻辑,跳转到 my 列表 + return articleCreate(db, "/my/articles", false) } // MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。 @@ -137,13 +138,16 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { } updates := map[string]interface{}{ - "title": f.Title, - "slug": f.Slug, - "summary": f.Summary, - "content": f.Content, - "cover": f.Cover, - "status": newStatus, - "is_top": f.IsTop, + "title": f.Title, + "slug": f.Slug, + "summary": f.Summary, + "content": f.Content, + "cover": f.Cover, + "status": newStatus, + // SECURITY_TODO #31:作者不可修改置顶状态——保留库中 + // 现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法 + // 自行置顶/取消置顶)。 + "is_top": article.IsTop, "published_at": publishedAt, } diff --git a/handlers/pin_privilege_test.go b/handlers/pin_privilege_test.go new file mode 100644 index 0000000..a687940 --- /dev/null +++ b/handlers/pin_privilege_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestMyArticlesCannotPin 覆盖 SECURITY_TODO #31:普通作者经由 /api/my/articles +// 提交 is_top=true 时落库为 false;admin 路径不受影响;作者编辑已置顶文章 +// 不会丢失管理员授权的置顶状态。 +func TestMyArticlesCannotPin(t *testing.T) { + e := newSecurityTestEnv(t) + + // 作者创建:is_top=true 被降级为 false。 + alice := e.login(t, "alice") + aliceToken := e.csrfTokenFor(t, alice) + w := postJSON(e, http.MethodPost, "/api/my/articles", alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author create: status = %d, body %s", w.Code, w.Body.String()) + } + var art models.Article + if err := e.db.Where("slug = ?", "pinned-attempt").First(&art).Error; err != nil { + t.Fatalf("load created article: %v", err) + } + if art.IsTop { + t.Fatal("author-created article must not be pinned") + } + + // 作者编辑:提交 is_top=true 不生效(保持 false)。 + w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author update: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article: %v", err) + } + if art.IsTop { + t.Fatal("author update must not pin the article") + } + + // admin 路径不受影响。 + admin := e.login(t, "admin") + adminToken := e.csrfTokenFor(t, admin) + w = postJSON(e, http.MethodPut, "/api/admin/articles/"+itoa(art.ID), admin, adminToken, gin.H{ + "title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt", + "status": "1", "is_top": true, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("admin update: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article after admin pin: %v", err) + } + if !art.IsTop { + t.Fatal("admin path must still be able to pin") + } + + // 管理员授权置顶后:作者编辑保留置顶状态(不会丢失,也不能取消)。 + w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{ + "title": "pinned attempt", "content": "body v3", "slug": "pinned-attempt", + "status": "1", "is_top": false, + }) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("author update on pinned article: status = %d, body %s", w.Code, w.Body.String()) + } + if err := e.db.First(&art, art.ID).Error; err != nil { + t.Fatalf("reload article: %v", err) + } + if !art.IsTop { + t.Fatal("author edit must not clear an admin-granted pin") + } +} diff --git a/handlers/rate_limit.go b/handlers/rate_limit.go new file mode 100644 index 0000000..2ff1477 --- /dev/null +++ b/handlers/rate_limit.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "sync" + "time" +) + +// 注册/评论的固定窗口限流参数(SECURITY_TODO #27/#28)。 +// 与 LoginRateLimiter 一样为进程内存实现:应用是单实例部署 +// (反向代理后的 unix socket),因此无需共享存储。 +const ( + registerLimitPerHour = 10 // 每 IP 每小时的注册上限 + registerWindow = time.Hour // 注册计数窗口 + commentLimitPerMin = 5 // 每 IP 每分钟的评论上限 + commentWindow = time.Minute // 评论计数窗口 +) + +// WindowRateLimiter 是固定时间窗口计数限流器:键在窗口内最多计数 limit 次, +// 超限时 Allow 返回 false。窗口过期后计数自动重置。 +type WindowRateLimiter struct { + mu sync.Mutex + entries map[string]*windowRateEntry + limit int + window time.Duration + nowFn func() time.Time +} + +type windowRateEntry struct { + count int + windowStart time.Time + lastSeen time.Time +} + +// NewWindowLimiter 创建固定窗口限流器(limit 次/每 window)。 +func NewWindowLimiter(limit int, window time.Duration) *WindowRateLimiter { + return &WindowRateLimiter{ + entries: make(map[string]*windowRateEntry), + limit: limit, + window: window, + nowFn: time.Now, + } +} + +// Allow 报告该键是否被允许再发一次请求(调用本身计入窗口计数; +// 窗口过期时计数重置为 1)。 +func (l *WindowRateLimiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.nowFn() + e, ok := l.entries[key] + if !ok || now.Sub(e.windowStart) >= l.window { + // 新窗口:重置计数。 + l.entries[key] = &windowRateEntry{count: 1, windowStart: now, lastSeen: now} + l.sweep(now) + return true + } + e.count++ + e.lastSeen = now + l.sweep(now) + return e.count <= l.limit +} + +// sweep 限制映射大小,防止攻击者通过大量键令限流器无限增长 +// (与 LoginRateLimiter.sweep 同一策略)。 +func (l *WindowRateLimiter) sweep(now time.Time) { + if len(l.entries) <= maxTrackedKeys { + return + } + // 第 1 轮:移除窗口已过且不再活跃的键。 + for k, e := range l.entries { + if now.Sub(e.lastSeen) > l.window { + delete(l.entries, k) + } + } + if len(l.entries) <= maxTrackedKeys { + return + } + // 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。 + cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4 + var byOldest []struct { + key string + t time.Time + } + for k, e := range l.entries { + byOldest = append(byOldest, struct { + key string + t time.Time + }{k, e.lastSeen}) + } + for i := 1; i < len(byOldest); i++ { + for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- { + byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j] + } + } + for _, o := range byOldest[:cut] { + delete(l.entries, o.key) + } +} diff --git a/handlers/ratelimit_test.go b/handlers/ratelimit_test.go new file mode 100644 index 0000000..e547ce1 --- /dev/null +++ b/handlers/ratelimit_test.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestWindowLimiterFixedWindow 单元测试固定窗口行为:窗口内超限拒绝、 +// 窗口过期后计数重置、不同键互不影响、键有界(sweep 生效)。 +func TestWindowLimiterFixedWindow(t *testing.T) { + l := NewWindowLimiter(2, time.Minute) + now := time.Unix(1_000_000, 0) + l.nowFn = func() time.Time { return now } + + for i := 0; i < 2; i++ { + if !l.Allow("a") { + t.Fatalf("attempt %d: expect allowed within limit", i+1) + } + } + if l.Allow("a") { + t.Fatal("expect blocked after limit") + } + // 其他键不受影响。 + if !l.Allow("b") { + t.Fatal("different key must not be affected") + } + + // 窗口过期后计数重置。 + now = now.Add(time.Minute + time.Second) + if !l.Allow("a") { + t.Fatal("expect allowed after window rollover") + } +} + +// guestSessionAndToken 取一个匿名会话及其 CSRF 令牌。 +func guestSessionAndToken(e *securityTestEnv) (string, string) { + w := e.do(http.MethodGet, "/login", "", nil, "") + m := csrfTokenRe.FindStringSubmatch(w.Body.String()) + if m == nil { + return e.sessionCookie(w), "" + } + return e.sessionCookie(w), m[1] +} + +// postJSONFrom 与 postJSON 相同,但可指定客户端 RemoteAddr 以模拟不同来源 IP。 +func postJSONFrom(e *securityTestEnv, method, path, cookie, csrfToken, ip string, body interface{}) *httptest.ResponseRecorder { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(body) + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if csrfToken != "" { + req.Header.Set("X-CSRF-Token", csrfToken) + } + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + req.RemoteAddr = ip + ":4321" + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +// TestRegisterRateLimited 覆盖 SECURITY_TODO #27:同 IP 连续注册超过 +// 阈值(10 次/小时)后返回 429/register_locked;其他 IP 不受影响。 +func TestRegisterRateLimited(t *testing.T) { + e := newSecurityTestEnv(t) + if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil { + t.Fatalf("enable registration: %v", err) + } + + register := func(username, ip string) *httptest.ResponseRecorder { + cookie, token := guestSessionAndToken(e) + if token == "" { + t.Fatal("login page did not render a CSRF token") + } + return postJSONFrom(e, http.MethodPost, "/api/auth/register", cookie, token, ip, gin.H{ + "username": username, + "password": "secret1", + "confirm_password": "secret1", + "email": username + "@example.com", + }) + } + + const ipA = "198.51.100.10" + for i := 0; i < registerLimitPerHour; i++ { + w := register(fmt.Sprintf("reg%d", i), ipA) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("attempt %d: status = %d, body %s", i+1, w.Code, w.Body.String()) + } + } + + // 下一次尝试(即使输入合法)被限流。 + w := register("reg-over", ipA) + if w.Code != http.StatusTooManyRequests || respCode(w) != "register_locked" { + t.Fatalf("rate-limited register: status = %d, code = %q, want 429/register_locked", + w.Code, respCode(w)) + } + + // 其他 IP 不受影响。 + w = register("reg-other", "198.51.100.11") + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("register from other IP: status = %d, body %s", w.Code, w.Body.String()) + } +} + +// TestCommentRateLimited 覆盖 SECURITY_TODO #28:同 IP 高频提交评论超过 +// 阈值(5 条/分钟)后返回 429/comments_locked;其他 IP 不受影响。 +func TestCommentRateLimited(t *testing.T) { + e := newSecurityTestEnv(t) + + comment := func(ip string) *httptest.ResponseRecorder { + cookie, token := guestSessionAndToken(e) + if token == "" { + t.Fatal("login page did not render a CSRF token") + } + return postJSONFrom(e, http.MethodPost, "/api/article/alice-post/comments", cookie, token, ip, gin.H{ + "name": "guest", + "email": "guest@example.com", + "content": "nice post", + }) + } + + const ipA = "198.51.100.20" + for i := 0; i < commentLimitPerMin; i++ { + w := comment(ipA) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("comment %d: status = %d, body %s", i+1, w.Code, w.Body.String()) + } + } + + // 第六次提交被限流。 + w := comment(ipA) + if w.Code != http.StatusTooManyRequests || respCode(w) != "comments_locked" { + t.Fatalf("rate-limited comment: status = %d, code = %q, want 429/comments_locked", + w.Code, respCode(w)) + } + + // 其他 IP 不受影响。 + w = comment("198.51.100.21") + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("comment from other IP: status = %d, body %s", w.Code, w.Body.String()) + } +} diff --git a/handlers/security_test.go b/handlers/security_test.go index 1bc2f8a..a957478 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -29,10 +29,12 @@ import ( // securityTestEnv 搭建与生产中间件链一致的路由器 // (sessions -> CSRF -> 用户上下文),外加待测路由。 type securityTestEnv struct { - router *gin.Engine - db *gorm.DB - storageDir string - limiter *LoginRateLimiter + router *gin.Engine + db *gorm.DB + storageDir string + limiter *LoginRateLimiter + registerLimiter *WindowRateLimiter + commentLimiter *WindowRateLimiter } var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`) @@ -78,6 +80,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { r.LoadHTMLGlob("../templates/**/*.html") store := cookie.NewStore([]byte("test-secret")) limiter := NewLoginLimiter() + registerLimiter := NewWindowLimiter(registerLimitPerHour, registerWindow) + commentLimiter := NewWindowLimiter(commentLimitPerMin, commentWindow) r.Use(sessions.Sessions("blog_session", store)) r.Use(middleware.SetUserContext(db)) r.Use(middleware.BodyLimit()) @@ -91,8 +95,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { { api.POST("/auth/login", Login(db, limiter)) api.POST("/auth/logout", Logout()) - api.POST("/auth/register", Register(db)) - api.POST("/article/:slug/comments", PostComment(db)) + api.POST("/auth/register", Register(db, registerLimiter)) + api.POST("/article/:slug/comments", PostComment(db, commentLimiter)) } protected := r.Group("/my", middleware.AuthRequired(db)) @@ -124,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)。 @@ -147,7 +154,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles")) } - return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter} + return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter, + registerLimiter: registerLimiter, commentLimiter: commentLimiter} } func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User { diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index 749ec49..cccde78 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -159,7 +159,8 @@ func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) - for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js"} { + // SECURITY_TODO #32:黑名单补充 .xsl/.xslt/.shtml 后并入同一用例。 + for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js", ".xsl", ".xslt", ".shtml"} { w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, gin.H{"action": "add_type", "extension": ext, "category": models.CategoryImage}) if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_dangerous_ext" { diff --git a/handlers/settings.go b/handlers/settings.go index 0b38c88..06a1eda 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 } @@ -384,6 +398,9 @@ func saveUploadConfig(db *gorm.DB, req uploadSettingsRequest, updatedBy uint) bo var dangerousUploadExtensions = map[string]bool{ ".html": true, ".htm": true, ".xhtml": true, ".xht": true, ".svg": true, ".xml": true, ".js": true, ".mjs": true, + // SECURITY_TODO #32:补充服务器端处理型扩展名(XSLT 可内嵌脚本、 + // SSI 可包含文件),nosniff 已兜底,此处仅完整性。 + ".xsl": true, ".xslt": true, ".shtml": true, } // addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。 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 ec0682b..7112455 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -451,6 +451,10 @@ var translations = map[Lang]map[string]string{ "api_forbidden": "You do not have permission to perform this action.", "api_invalid_request": "Invalid request body.", "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_status_invalid": "Invalid user status.", "user_not_found": "User not found.", }, ZH: { @@ -888,6 +892,10 @@ var translations = map[Lang]map[string]string{ "api_forbidden": "您没有权限执行此操作。", "api_invalid_request": "请求参数格式不正确。", "request_too_large": "请求体超过大小限制。", + "register_locked": "来自该地址的注册次数过多,请稍后再试。", + "comments_locked": "评论提交过于频繁,请稍后再试。", + "settings_upload_bad_content": "文件内容与声明类型不匹配。", + "user_status_invalid": "无效的用户状态。", "user_not_found": "用户不存在。", }, } diff --git a/main.go b/main.go index 43041ba..c19793c 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "path" "path/filepath" "strings" + "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -163,6 +164,11 @@ func main() { // 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂), // 因此冒烟测试可传 nil。 func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) { + // 注册/评论限流器(SECURITY_TODO #27/#28):固定窗口、进程内存、map 有界。 + // 单实例部署无需共享存储。 + registerLimiter := handlers.NewWindowLimiter(10, time.Hour) + commentLimiter := handlers.NewWindowLimiter(5, time.Minute) + // 公开页面。 router.GET("/", handlers.HomePage(db)) router.GET("/search", handlers.SearchPage(db)) @@ -177,9 +183,9 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi { api.GET("/articles", handlers.HomeArticlesAPI(db)) api.POST("/auth/login", handlers.Login(db, loginLimiter)) - api.POST("/auth/register", handlers.Register(db)) + api.POST("/auth/register", handlers.Register(db, registerLimiter)) api.POST("/auth/logout", handlers.Logout()) - api.POST("/article/:slug/comments", handlers.PostComment(db)) + api.POST("/article/:slug/comments", handlers.PostComment(db, commentLimiter)) } // 受保护的后台路由(仅管理员角色)。 diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html index f2e9c85..038f859 100644 --- a/templates/user/my_article_form.html +++ b/templates/user/my_article_form.html @@ -64,14 +64,6 @@ - -
- -