Merge pull request 'fix: 完成 SECURITY_TODO #27–#32 剩余安全修复(限流/魔数校验/最后管理员事务化/置顶收紧/零碎加固)' (#4) from dsh/go_blog:fix/security-todo-27-32 into main
Reviewed-on: kevin/go_blog#4
This commit is contained in:
+30
-29
@@ -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 解析 / 危险扩展名补充)
|
||||
|
||||
历史遗留观察项(不阻塞):
|
||||
|
||||
|
||||
+92
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+19
-12
@@ -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))
|
||||
}
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
+18
-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
|
||||
}
|
||||
@@ -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 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
|
||||
@@ -64,14 +64,6 @@
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
|
||||
Reference in New Issue
Block a user