fix(security): 修复 P3 低危项(开放重定向/配额TOCTOU/safeJS/会话治理)
- Referer 开放重定向:safeRedirectPath 仅放行同站相对路径, 外部 URL/协议跳转一律回退 /inbox - 发信配额 TOCTOU:新增 TryReserveQuota 原子预扣 (UPDATE ... WHERE used_bytes + n <= quota_bytes),超配额即拒发; 附件保存失败按大小补偿回退 - 移除危险模板函数 safeHTML/safeJS:新增 jsonify(json.Marshal, < > & 转义为 \u003c 等,无法逃出 </script>),compose 页 quill.innerHTML 改用 jsonify;srcdoc 改回默认属性转义 - 会话治理:登录成功后 session.Clear() 清旧状态;记录 loginAt, 绝对过期 7 天 + 滑动续期(活跃会话 12h 写回刷新) - 确认 #15 Content-Disposition 编码随 P1 #4 已完成 - 新增 12 个测试:重定向路径矩阵、配额原子性(含超额不部分扣费)、 jsonify 逃逸防护、会话绝对过期/有效访问(签名会话构造) 至此 16 项安全审计项(P0-P3)全部修复完成。
This commit is contained in:
@@ -111,3 +111,77 @@ type addrMock string
|
||||
|
||||
func (a addrMock) Network() string { return "tcp" }
|
||||
func (a addrMock) String() string { return string(a) }
|
||||
|
||||
// P3 #13:配额原子预扣——并发/超额场景下不得绕过配额。
|
||||
func TestTryReserveQuota(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
|
||||
// 用户配额 1000
|
||||
user := &db.User{
|
||||
Username: "quota_user",
|
||||
PasswordHash: "x",
|
||||
DomainID: 0,
|
||||
QuotaBytes: 1000,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if user.ID == 0 {
|
||||
t.Fatal("user ID must be assigned")
|
||||
}
|
||||
|
||||
// 预扣 600 成功
|
||||
ok, err := s.Users.TryReserveQuota(user.ID, 600)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve 600: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 再扣 400 正好用完
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 400)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve 400: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 超出配额被拒且不改变 used_bytes
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve beyond quota: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("reserve beyond quota must fail")
|
||||
}
|
||||
got, _ := s.Users.GetByID(user.ID)
|
||||
if got.UsedBytes != 1000 {
|
||||
t.Fatalf("used_bytes = %d, want 1000 (no partial charge)", got.UsedBytes)
|
||||
}
|
||||
|
||||
// 释放后可以再次预扣
|
||||
if err := s.Users.UpdateUsedBytes(user.ID, -500); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
ok, err = s.Users.TryReserveQuota(user.ID, 500)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("reserve after release: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #13:非正 delta 不允许(防御)。
|
||||
func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
user := &db.User{Username: "u", PasswordHash: "x", QuotaBytes: 100}
|
||||
if err := s.Users.Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, delta := range []int64{0, -10} {
|
||||
ok, err := s.Users.TryReserveQuota(user.ID, delta)
|
||||
if err != nil {
|
||||
t.Fatalf("delta %d: %v", delta, err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("delta %d must not reserve", delta)
|
||||
}
|
||||
}
|
||||
got, _ := s.Users.GetByID(user.ID)
|
||||
if got.UsedBytes != 0 {
|
||||
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ type UserStore interface {
|
||||
ListAll(page, size int) ([]db.User, int64, error)
|
||||
UpdateUsedBytes(id uint, delta int64) error
|
||||
UpdatePassword(userID uint, hashedPassword string) error
|
||||
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
|
||||
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
|
||||
TryReserveQuota(userID uint, delta int64) (bool, error)
|
||||
}
|
||||
|
||||
// userStoreGorm implements UserStore using GORM.
|
||||
@@ -124,6 +127,22 @@ func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
|
||||
}
|
||||
|
||||
// TryReserveQuota atomically reserves delta bytes for a user within quota.
|
||||
// The reservation is applied (used_bytes incremented) only when it does not
|
||||
// exceed quota_bytes; otherwise no change is made and false is returned.
|
||||
func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error) {
|
||||
if delta <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
res := s.db.Model(&db.User{}).
|
||||
Where("id = ? AND used_bytes + ? <= quota_bytes", userID, delta).
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta))
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// UpdatePassword updates the password hash for a user and clears the
|
||||
// must-change-password flag (the user has now set their own password).
|
||||
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
||||
|
||||
Reference in New Issue
Block a user