Files
go_blog/handlers/last_admin_test.go
T
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

83 lines
2.3 KiB
Go

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")
}
}