dsh/go_blog:fix/security-todo-27-32
main
对应 SECURITY_TODO.md 剩余的 6 项(#27–#32,2026-08-27 复审发现),每项一个 commit:
SECURITY_TODO.md
handlers/rate_limit.go
WindowRateLimiter
Register
register_locked
PostComment
comments_locked
saveSiteImage
contentMatchesType
settings_upload_bad_content
db.Transaction
SELECT ... FOR UPDATE
clause.Locking
lastAdminMu
f.Role
user.Role
is_top=false
UserCreate/UserUpdate
parseUintParam/parseUintForm
strconv.ParseUint
.xsl/.xslt/.shtml
go build ./...
go vet ./...
go test -race ./...
TestWindowLimiterFixedWindow
TestRegisterRateLimited
TestCommentRateLimited
TestSiteImageUploadRejectsMismatchedContent
TestConcurrentLastAdminDowngrade
TestMyArticlesCannotPin
TestUserStatusEnumRejected
TestParseUintStrict
TestAddUploadFileTypeRejectsDangerousExtensions
GuestRequireApproval=true
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 实施。
评论端点未认证即可提交(默认 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”为产品决策,未随本项实施。
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)
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 通过)
任意注册作者可经 /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 路径可置顶;作者编辑不丢置顶)
- 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 扩展用例
No dependencies set.
The note is not visible to the blocked user.
对应
SECURITY_TODO.md剩余的 6 项(#27–#32,2026-08-27 复审发现),每项一个 commit:handlers/rate_limit.goWindowRateLimiter(固定窗口、有界 map、与 #10 同款淘汰策略);Register按 IP 限流 10 次/小时,超限 429 + i18nregister_lockedPostComment按 IP 限流 5 条/分钟,超限 429 + i18ncomments_locked(复用 #27 限流器)saveSiteImage与头像/附件上传(#14/#21)口径一致,读取字节后contentMatchesType校验,不匹配 400(settings_upload_bad_content)db.Transaction;MySQL 下SELECT ... FOR UPDATE(clause.Locking)串行化并发事务;SQLite 无 FOR UPDATE,叠加进程内互斥锁lastAdminMu(单实例部署);顺带修复f.Role覆盖user.Role后检查恒真的旧隐患is_top=false、update 保留库中现有值(管理员授权置顶不因作者编辑丢失);作者表单移除置顶复选框;admin 路径不受影响UserCreate/UserUpdatestatus 枚举校验 {0,1,2,3}(非法 400);parseUintParam/parseUintForm改strconv.ParseUint严格解析("5abc" 不再宽松为 5);危险扩展名补充.xsl/.xslt/.shtml验证
go build ./...、go vet ./...通过go test -race ./...全绿,新增测试:TestWindowLimiterFixedWindow/TestRegisterRateLimited/TestCommentRateLimitedTestSiteImageUploadRejectsMismatchedContentTestConcurrentLastAdminDowngradeTestMyArticlesCannotPinTestUserStatusEnumRejected/TestParseUintStrict/TestAddUploadFileTypeRejectsDangerousExtensions(扩展用例)说明
GuestRequireApproval=true"为产品决策,未随本项实施,留待确认- 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 扩展用例