fix: 完成 SECURITY_TODO #27–#32 剩余安全修复(限流/魔数校验/最后管理员事务化/置顶收紧/零碎加固) #4

Merged
kevin merged 6 commits from dsh/go_blog:fix/security-todo-27-32 into main 2026-08-27 23:22:15 +08:00
6 Commits
Author SHA1 Message Date
dsh 9a5a7fa929 fix: SECURITY_TODO #32 零碎加固(status 枚举校验/严格 uint 解析/危险扩展名补充)
- admin_user.go: UserCreate/UserUpdate 的 status 无枚举校验,可存任意
  int(如 99)——新增 validUserStatus 限定 {0,1,2,3},非法 400
  (i18n user_status_invalid 中英新增)
- attachment.go: parseUintParam/parseUintForm 由 fmt.Sscanf("%d") 改为
  strconv.ParseUint 严格解析——"5abc" 不再被宽松解析为 5
- settings.go: dangerousUploadExtensions 补充 .xsl/.xslt/.shtml
  (XSLT 可内嵌脚本、SSI 可包含文件;nosniff 已兜底,仅完整性)
- 测试: TestUserStatusEnumRejected / TestParseUintStrict /
  TestAddUploadFileTypeRejectsDangerousExtensions 扩展用例
2026-08-27 21:48:27 +08:00
dsh 7e6dd0cc02 fix: SECURITY_TODO #31 普通作者不可置顶全站文章
任意注册作者可经 /api/my/articles 提交 is_top=true 把文章钉在全站
首页最顶端(publishedArticleOrder 为 is_top DESC 优先),属影响公共
展示位的横向越权(确认非设计意图,予收紧)。

- handlers/article.go: 拆出共享实现 articleCreate/articleUpdate,
  增加 allowIsTop 开关;admin 路径保持 true
- handlers/my_articles.go: 作者创建强制 is_top=false;编辑保留库中
  现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法自行取消)
- templates/user/my_article_form.html: 移除置顶复选框
- 测试: TestMyArticlesCannotPin(作者 create/update is_top=true →
  落库 false;admin 路径可置顶;作者编辑不丢置顶)
2026-08-27 21:46:27 +08:00
dsh f39f0f4320 fix: SECURITY_TODO #30 最后管理员防线事务化,消除 TOCTOU 竞态
UserUpdate/UserDelete 的 adminCount<=1 计数与 Save/Delete 非原子,两个
并发的'降级/删除倒数第二位管理员'请求可同时通过检查,站点失去管理员
(MySQL 下真实窗口;SQLite 单写锁下窗口极小)。

- handlers/admin_user.go: 检查+写入包进 db.Transaction;
  ensureNotLastAdmin 在事务内锁定管理员集合后再计数:
  MySQL 用 SELECT ... FOR UPDATE(gorm clause.Locking)串行化
  并发事务;SQLite 无 FOR UPDATE,叠加进程内互斥锁 lastAdminMu
  (应用按设计单实例部署),计数在写锁串行化后重读
- 顺带修正:原实现中 f.Role 覆盖 user.Role 后检查恒为 true 的语义
  保留(基于原角色判定),避免降级检查被新值掩盖
- 测试: TestConcurrentLastAdminDowngrade(两位管理员并发降级:
  恰好 1 成功 1 拒绝、最终至少保留一位管理员;-race 通过)
2026-08-27 21:45:03 +08:00
dsh de27e53287 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)
2026-08-27 21:41:02 +08:00
dsh 52ca450ddd fix: SECURITY_TODO #28 评论提交按 IP 限流(5 条/分钟)防灌水刷屏
评论端点未认证即可提交(默认 AllowGuest=true 且即时公开),配合默认
GuestRequireApproval=false 开箱即用状态可被灌水机刷屏。PostComment
复用 #27 的 WindowRateLimiter 按 IP 限流 5 条/分钟,超限 429 +
i18n comments_locked(中英)。

- handlers/comment.go: PostComment(db, limiter),键前缀区分
- main.go / security_test.go: 评论限流器接线
- 测试: TestCommentRateLimited(同 IP 5 次成功、第 6 次 429、
  其他 IP 不受影响)

注: 可选项“新部署默认 GuestRequireApproval=true”为产品决策,未随本项实施。
2026-08-27 21:39:28 +08:00
dsh 4c32267b47 fix: SECURITY_TODO #27 注册接口按 IP 限流(10 次/小时)防批量注册
register/api 未认证端点可被无限批量注册:bcrypt cost 12 与用户名唯一
检查不构成成本屏障。新增 WindowRateLimiter(固定窗口、有界 map、与
#10 相同的淘汰策略),Register 按 IP 限流 10 次/小时,超限 429 +
i18n register_locked(中英)。

- handlers/rate_limit.go: 通用固定窗口限流器(可注入时钟便于测试)
- handlers/auth.go: Register(db, limiter),键前缀与登录限流器区分
- main.go: 注册限流器接线;测试环境同步
- 测试: TestWindowLimiterFixedWindow / TestRegisterRateLimited
  (同 IP 10 次成功、第 11 次 429、其他 IP 不受影响)

注: #28 评论限流将复用同一限流器,在下一个 commit 实施。
2026-08-27 21:38:49 +08:00