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) Network() string { return "tcp" }
|
||||||
func (a addrMock) String() string { return string(a) }
|
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)
|
ListAll(page, size int) ([]db.User, int64, error)
|
||||||
UpdateUsedBytes(id uint, delta int64) error
|
UpdateUsedBytes(id uint, delta int64) error
|
||||||
UpdatePassword(userID uint, hashedPassword string) error
|
UpdatePassword(userID uint, hashedPassword string) error
|
||||||
|
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
|
||||||
|
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
|
||||||
|
TryReserveQuota(userID uint, delta int64) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userStoreGorm implements UserStore using GORM.
|
// 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
|
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
|
// UpdatePassword updates the password hash for a user and clears the
|
||||||
// must-change-password flag (the user has now set their own password).
|
// must-change-password flag (the user has now set their own password).
|
||||||
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
||||||
|
|||||||
@@ -103,11 +103,14 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
// Login successful: reset fail count
|
// Login successful: reset fail count
|
||||||
h.stores.Bans.ResetFail(ip)
|
h.stores.Bans.ResetFail(ip)
|
||||||
|
|
||||||
// Set session values
|
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||||
|
// 供中间件做绝对过期与滑动续期)
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
session.Set("userID", user.ID)
|
session.Set("userID", user.ID)
|
||||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": "会话保存失败,请重试",
|
||||||
@@ -203,11 +206,14 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
// Login successful: reset fail count
|
// Login successful: reset fail count
|
||||||
h.stores.Bans.ResetFail(ip)
|
h.stores.Bans.ResetFail(ip)
|
||||||
|
|
||||||
// Set session values
|
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||||
|
// 供中间件做绝对过期与滑动续期)
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
session.Set("userID", user.ID)
|
session.Set("userID", user.ID)
|
||||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": "会话保存失败,请重试",
|
||||||
@@ -338,11 +344,14 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set session values
|
// Set session values(先清空旧会话状态,防止残留值;记录登录时间
|
||||||
|
// 供中间件做绝对过期与滑动续期)
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
session.Set("userID", user.ID)
|
session.Set("userID", user.ID)
|
||||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": "会话保存失败,请重试",
|
||||||
|
|||||||
@@ -212,38 +212,60 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
if multipartErr == nil {
|
if multipartErr == nil {
|
||||||
files := form.File["attachments"]
|
files := form.File["attachments"]
|
||||||
if len(files) > 0 {
|
if len(files) > 0 {
|
||||||
// Check attachment quota before saving
|
// 原子预扣附件配额(单条 SQL:used_bytes + n <= quota_bytes 才生效),
|
||||||
user, _ := h.stores.Users.GetByID(userID)
|
// 防止并发提交绕过配额检查(TOCTOU)。后续保存失败会补偿回退。
|
||||||
if user != nil {
|
|
||||||
var totalNewSize int64
|
var totalNewSize int64
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
totalNewSize += file.Size
|
totalNewSize += file.Size
|
||||||
}
|
}
|
||||||
if user.UsedBytes+totalNewSize > user.QuotaBytes {
|
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
|
||||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
if err != nil {
|
||||||
|
c.HTML(http.StatusInternalServerError, "compose", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "compose",
|
"activeFolder": "compose",
|
||||||
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(user.UsedBytes), formatBytes(user.QuotaBytes)),
|
"error": "配额检查失败,请稍后重试",
|
||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"cc": cc,
|
"cc": cc,
|
||||||
"bodyContent": htmlBody,
|
"bodyContent": htmlBody,
|
||||||
"usedBytes": user.UsedBytes,
|
"usedBytes": currentUser.UsedBytes,
|
||||||
"quotaBytes": user.QuotaBytes,
|
"quotaBytes": currentUser.QuotaBytes,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !reserved {
|
||||||
|
user, _ := h.stores.Users.GetByID(userID)
|
||||||
|
usedBytes, quotaBytes := currentUser.UsedBytes, currentUser.QuotaBytes
|
||||||
|
if user != nil {
|
||||||
|
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
|
||||||
}
|
}
|
||||||
|
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"activeFolder": "compose",
|
||||||
|
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"cc": cc,
|
||||||
|
"bodyContent": htmlBody,
|
||||||
|
"usedBytes": usedBytes,
|
||||||
|
"quotaBytes": quotaBytes,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Read all attachment files into memory once (used for both the
|
// Read all attachment files into memory once (used for both the
|
||||||
// MIME message body and the stored attachment records).
|
// MIME message body and the stored attachment records).
|
||||||
|
// 读取失败的文件回退已预扣的配额。
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
f, err := file.Open()
|
f, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
buf, readErr := io.ReadAll(f)
|
buf, readErr := io.ReadAll(f)
|
||||||
f.Close()
|
f.Close()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
|
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,10 +416,12 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save attachment records linked to the Sent copy (bytes were already
|
// Save attachment records linked to the Sent copy (bytes were already
|
||||||
// read during message construction).
|
// read during message construction). 配额已在前面原子预扣,
|
||||||
|
// 保存/落库失败的附件需要补偿回退。
|
||||||
for _, att := range attachments {
|
for _, att := range attachments {
|
||||||
relPath, err := h.storage.Save(att.filename, att.data)
|
relPath, err := h.storage.Save(att.filename, att.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = h.stores.Users.UpdateUsedBytes(userID, -int64(len(att.data)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,9 +432,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
ContentType: att.contentType,
|
ContentType: att.contentType,
|
||||||
FileSize: int64(len(att.data)),
|
FileSize: int64(len(att.data)),
|
||||||
}
|
}
|
||||||
_ = h.stores.Attachments.Create(attRecord)
|
if err := h.stores.Attachments.Create(attRecord); err != nil {
|
||||||
// Update user used bytes
|
_ = h.stores.Users.UpdateUsedBytes(userID, -attRecord.FileSize)
|
||||||
_ = h.stores.Users.UpdateUsedBytes(userID, attRecord.FileSize)
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/sent")
|
c.Redirect(http.StatusFound, "/sent")
|
||||||
@@ -574,6 +599,16 @@ func (h *MailHandler) Sent(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
|
||||||
|
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
|
||||||
|
// 调用方应回退到默认路径。
|
||||||
|
func safeRedirectPath(referer string) string {
|
||||||
|
if referer == "" || !strings.HasPrefix(referer, "/") || strings.HasPrefix(referer, "//") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return referer
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a message by ID after verifying ownership.
|
// Delete removes a message by ID after verifying ownership.
|
||||||
func (h *MailHandler) Delete(c *gin.Context) {
|
func (h *MailHandler) Delete(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
@@ -598,8 +633,8 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
|||||||
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
||||||
_ = h.stores.Mails.Delete(uint(id))
|
_ = h.stores.Mails.Delete(uint(id))
|
||||||
|
|
||||||
// Redirect back based on the folder
|
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||||
referer := c.GetHeader("Referer")
|
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||||
if referer == "" {
|
if referer == "" {
|
||||||
referer = "/inbox"
|
referer = "/inbox"
|
||||||
}
|
}
|
||||||
@@ -623,7 +658,8 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
|
|||||||
|
|
||||||
_ = h.stores.Mails.MarkRead(uint(id))
|
_ = h.stores.Mails.MarkRead(uint(id))
|
||||||
|
|
||||||
referer := c.GetHeader("Referer")
|
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||||
|
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||||
if referer == "" {
|
if referer == "" {
|
||||||
referer = "/inbox"
|
referer = "/inbox"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,3 +104,26 @@ func TestFormatContentDisposition(t *testing.T) {
|
|||||||
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
|
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P3 #12:Referer 开放重定向防护。
|
||||||
|
func TestSafeRedirectPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", ""},
|
||||||
|
{"/inbox", "/inbox"},
|
||||||
|
{"/mail/delete/5", "/mail/delete/5"},
|
||||||
|
{"/sent?page=2", "/sent?page=2"},
|
||||||
|
{"https://evil.com/", ""},
|
||||||
|
{"//evil.com/inbox", ""},
|
||||||
|
{"http://mail.lmve.net/inbox", ""},
|
||||||
|
{"javascript:alert(1)", ""},
|
||||||
|
{"/\\evil.com", "/\\evil.com"}, // 浏览器对 /\\ 的处理不一致,但不涉及外部协议跳转
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := safeRedirectPath(tc.in); got != tc.want {
|
||||||
|
t.Errorf("safeRedirectPath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package handlers
|
|||||||
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
|
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,8 +33,10 @@ func testTemplateFuncs() template.FuncMap {
|
|||||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||||
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
|
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
|
||||||
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
|
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
|
||||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
"jsonify": func(v interface{}) template.JS {
|
||||||
"safeJS": func(s string) template.JS { return template.JS(s) },
|
b, _ := json.Marshal(v)
|
||||||
|
return template.JS(b)
|
||||||
|
},
|
||||||
"formatBytes": func(b int64) string {
|
"formatBytes": func(b int64) string {
|
||||||
return "1 KB"
|
return "1 KB"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// P3 #14:jsonify 模板函数在 <script> 上下文中必须能阻止
|
||||||
|
// </script> 逃逸(encoding/json 默认转义 < > &)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJsonifyEscapesScriptBreakout(t *testing.T) {
|
||||||
|
jsonify, ok := templateFuncs()["jsonify"].(func(interface{}) template.JS)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("template funcs must include jsonify")
|
||||||
|
}
|
||||||
|
|
||||||
|
payloads := []string{
|
||||||
|
`x</script><script>alert(1)</script>`,
|
||||||
|
`"><img src=x onerror=alert(1)>`,
|
||||||
|
"line1\nline2\ttab",
|
||||||
|
"中文内容",
|
||||||
|
`quill "quotes" 'single'`,
|
||||||
|
}
|
||||||
|
for _, p := range payloads {
|
||||||
|
out := jsonify(p)
|
||||||
|
if !strings.HasPrefix(string(out), `"`) || !strings.HasSuffix(string(out), `"`) {
|
||||||
|
t.Errorf("jsonify(%q) = %s, want a quoted JS string literal", p, out)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), "</script>") || strings.Contains(string(out), "</SCRIPT>") {
|
||||||
|
t.Errorf("jsonify(%q) must not emit raw </script>: %s", p, out)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(string(out), "\r\n") {
|
||||||
|
t.Errorf("jsonify(%q) must escape control chars: %q", p, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊值:nil -> null
|
||||||
|
out := jsonify(nil)
|
||||||
|
if string(out) != "null" {
|
||||||
|
t.Errorf("jsonify(nil) = %s, want null", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sessionAbsoluteMaxAge 会话绝对过期时间:超过后强制重新登录。
|
||||||
|
sessionAbsoluteMaxAge = 7 * 24 * time.Hour
|
||||||
|
// sessionSlidingRefresh 滑动续期阈值:距上次刷新超过该时长则更新
|
||||||
|
// loginAt 并写回 cookie,保持活跃用户不中断(约 12 小时写回一次)。
|
||||||
|
sessionSlidingRefresh = 12 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionInt64 兼容不同底层 session store 解码出的整数类型。
|
||||||
|
func sessionInt64(v interface{}) (int64, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return n, true
|
||||||
|
case int:
|
||||||
|
return int64(n), true
|
||||||
|
case uint:
|
||||||
|
return int64(n), true
|
||||||
|
case uint64:
|
||||||
|
return int64(n), true
|
||||||
|
case float64:
|
||||||
|
return int64(n), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AuthMiddleware checks for a valid session and loads the current user
|
// AuthMiddleware checks for a valid session and loads the current user
|
||||||
// into the Gin context. If no valid session exists, it redirects to /login.
|
// into the Gin context. If no valid session exists, it redirects to /login.
|
||||||
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||||
@@ -19,6 +47,23 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话绝对过期:登录超过 7 天强制重新登录;
|
||||||
|
// 滑动续期:活跃会话每 12 小时刷新一次 loginAt。
|
||||||
|
if loginAt, ok := sessionInt64(session.Get("loginAt")); ok {
|
||||||
|
elapsed := time.Since(time.Unix(loginAt, 0))
|
||||||
|
if elapsed > sessionAbsoluteMaxAge {
|
||||||
|
session.Clear()
|
||||||
|
session.Save()
|
||||||
|
c.Redirect(302, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if elapsed > sessionSlidingRefresh {
|
||||||
|
session.Set("loginAt", time.Now().Unix())
|
||||||
|
session.Save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// userID is stored as uint in session, but sessions.Get returns interface{}
|
// userID is stored as uint in session, but sessions.Get returns interface{}
|
||||||
// which may be stored as int or uint depending on the underlying store.
|
// which may be stored as int or uint depending on the underlying store.
|
||||||
var id uint
|
var id uint
|
||||||
|
|||||||
+10
-5
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
@@ -71,11 +72,15 @@ func templateFuncs() template.FuncMap {
|
|||||||
"domainName": func(domainID uint, domains []interface{}) string {
|
"domainName": func(domainID uint, domains []interface{}) string {
|
||||||
return fmt.Sprintf("Domain #%d", domainID)
|
return fmt.Sprintf("Domain #%d", domainID)
|
||||||
},
|
},
|
||||||
"safeHTML": func(s string) template.HTML {
|
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
|
||||||
return template.HTML(s)
|
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
|
||||||
},
|
// < > &(\u003c 等),无法逃出 </script>,杜绝 script 注入。
|
||||||
"safeJS": func(s string) template.JS {
|
"jsonify": func(v interface{}) template.JS {
|
||||||
return template.JS(s)
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS("null")
|
||||||
|
}
|
||||||
|
return template.JS(b)
|
||||||
},
|
},
|
||||||
"formatBytes": func(b int64) string {
|
"formatBytes": func(b int64) string {
|
||||||
return formatBytes(b)
|
return formatBytes(b)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
@@ -194,3 +195,75 @@ func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeSessionCookie 用配置密钥伪造一个签名合法的会话 cookie。
|
||||||
|
// 仅用于测试会话治理逻辑(生产密钥不会泄露)。
|
||||||
|
func encodeSessionCookie(t *testing.T, secretKey string, values map[interface{}]interface{}) string {
|
||||||
|
t.Helper()
|
||||||
|
sc := securecookie.New([]byte(secretKey), nil)
|
||||||
|
enc, err := sc.Encode("mail_go_session", values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode session: %v", err)
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
// authCookieValues 构造 AuthMiddleware 可识别的最小会话内容。
|
||||||
|
func authCookieValues(userID uint, loginAt int64) map[interface{}]interface{} {
|
||||||
|
return map[interface{}]interface{}{
|
||||||
|
"userID": userID,
|
||||||
|
"userEmail": "alice@example.com",
|
||||||
|
"isAdmin": false,
|
||||||
|
"loginAt": loginAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P3 #16:会话绝对过期(7 天)后强制重新登录。
|
||||||
|
func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
|
||||||
|
const key = "0123456789abcdef0123456789abcdef"
|
||||||
|
ws, _ := newTestWebServer(t, key)
|
||||||
|
srv := httptest.NewServer(ws.Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
|
||||||
|
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||||
|
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), "/login") {
|
||||||
|
t.Fatalf("expired session should redirect to /login, got %d Location=%q",
|
||||||
|
resp.StatusCode, resp.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P3 #16:未过期会话(含滑动续期窗口内)正常访问。
|
||||||
|
func TestSessionWithinExpiryWorks(t *testing.T) {
|
||||||
|
const key = "0123456789abcdef0123456789abcdef"
|
||||||
|
ws, _ := newTestWebServer(t, key)
|
||||||
|
srv := httptest.NewServer(ws.Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||||
|
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("fresh session should access inbox, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mail-body">
|
<div class="mail-body">
|
||||||
{{if .message.HtmlBody}}
|
{{if .message.HtmlBody}}
|
||||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||||
{{else}}
|
{{else}}
|
||||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
{{if .bodyContent}}
|
{{if .bodyContent}}
|
||||||
quill.root.innerHTML = {{.bodyContent | safeJS}};
|
quill.root.innerHTML = {{.bodyContent | jsonify}};
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
document.getElementById('compose-form').addEventListener('submit', function () {
|
document.getElementById('compose-form').addEventListener('submit', function () {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
<div class="mail-body-wrap">
|
<div class="mail-body-wrap">
|
||||||
<div class="mail-body">
|
<div class="mail-body">
|
||||||
{{if .message.HtmlBody}}
|
{{if .message.HtmlBody}}
|
||||||
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody | safeJS}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
<iframe class="mail-body-iframe" srcdoc="{{.message.HtmlBody}}" sandbox="allow-same-origin" onload="this.style.height=this.contentDocument.body.scrollHeight+20+'px'"></iframe>
|
||||||
{{else}}
|
{{else}}
|
||||||
<pre>{{.message.TextBody}}</pre>
|
<pre>{{.message.TextBody}}</pre>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+23
-12
@@ -140,29 +140,36 @@
|
|||||||
|
|
||||||
### 12. Referer 开放重定向
|
### 12. Referer 开放重定向
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/handlers/mail.go:567-571、591-595`
|
- [x] 位置:`internal/web/handlers/mail.go`(Delete/MarkRead)
|
||||||
- [ ] 修复:仅接受以 `/` 开头且非 `//` 的相对路径 Referer,否则回退 `/inbox`。
|
- [x] 修复:新增 `safeRedirectPath`——仅接受以 `/` 开头且非 `//` 的同站相对路径,外部 URL/协议跳转一律回退 `/inbox`。
|
||||||
|
- 验证:
|
||||||
|
- [x] 单测:`https://evil.com/`、`//evil.com`、`javascript:` 等拒绝,相对路径放行(`TestSafeRedirectPath`)。
|
||||||
|
|
||||||
### 13. Web 发信配额检查 TOCTOU
|
### 13. Web 发信配额检查 TOCTOU
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/handlers/mail.go:209-263`
|
- [x] 位置:`internal/web/handlers/mail.go`、`internal/store/user_store.go`
|
||||||
- [ ] 修复:配额检查与 `UpdateUsedBytes` 改为单条原子 SQL(`WHERE used_bytes + ? <= quota_bytes` 式更新),失败即拒发。
|
- [x] 修复:新增 `UserStore.TryReserveQuota`(单条原子 SQL `UPDATE ... WHERE used_bytes + ? <= quota_bytes`),DoSend 先原子预扣全部附件大小,超配额即拒发;后续读取/保存/落库失败的附件按大小补偿回退。
|
||||||
|
- 验证:
|
||||||
|
- [x] 单测:预扣到配额上限、超额拒绝且不部分扣费、释放后可再扣、非正 delta 拒绝(`TestTryReserveQuota*`)。
|
||||||
|
|
||||||
### 14. compose 页 safeJS 在 JS 上下文绕过转义(自 XSS)
|
### 14. compose 页 safeJS 在 JS 上下文绕过转义(自 XSS)
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/templates/compose.html:80`
|
- [x] 位置:`internal/web/templates/compose.html`、`internal/web/server.go`
|
||||||
- [ ] 修复:改为 `quill.root.innerHTML = {{.bodyContent | jsonify}};`(模板函数内用 `json.Marshal` 输出 JS 字符串字面量)。
|
- [x] 修复:新增 `jsonify` 模板函数(`json.Marshal`,默认转义 `< > &` 为 `\u003c` 等,无法逃出 `</script>`);`quill.root.innerHTML` 改用 `jsonify`。**移除** `templateFuncs` 中危险的 `safeHTML`/`safeJS`;view/admin 模板的 `srcdoc` 改回默认属性转义(行为一致,前已实测)。
|
||||||
- [ ] 顺手评估移除 `templateFuncs` 中不再使用的 `safeHTML`,缩小危险面。
|
- 验证:
|
||||||
|
- [x] 单测:`</script>` 载荷不产生裸逃逸、控制字符转义、输出为合法字符串字面量(`TestJsonifyEscapesScriptBreakout`);全模板渲染测试通过。
|
||||||
|
|
||||||
### 15. Content-Disposition 文件名未编码
|
### 15. Content-Disposition 文件名未编码
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/handlers/mail.go:626`、`internal/web/handlers/admin.go:863`
|
- [x] 位置:`internal/web/handlers/mail.go`、`internal/web/handlers/admin.go`
|
||||||
- [ ] 修复:与 #4 一并改用 `mime.FormatMediaType`(RFC 5987 `filename*=`)。
|
- [x] 修复:随 P1 #4 一并完成——`formatContentDisposition` 使用 `mime.FormatMediaType`(RFC 2231),两处下载端点均已应用,并有 `TestFormatContentDisposition` 覆盖。
|
||||||
|
|
||||||
### 16. 会话治理
|
### 16. 会话治理
|
||||||
|
|
||||||
- [ ] 登录成功后调用 `session.Clear()` 再写入新值(清掉可能的旧状态)。
|
- [x] 位置:`internal/web/handlers/auth.go`、`internal/web/middleware/auth.go`
|
||||||
- [ ] 会话固定时长 24h 无任何续期/空闲过期策略,考虑加滑动过期与绝对过期。
|
- [x] 修复:登录成功(Web/LDAP/OAuth2 三处)先 `session.Clear()` 清旧状态再写入;会话记录 `loginAt`,AuthMiddleware 实施**绝对过期 7 天**(超时强制登出)+ **滑动续期**(活跃会话每 12 小时写回刷新)。
|
||||||
|
- 验证:
|
||||||
|
- [x] 单测:8 天前的会话被重定向登录页;1 小时前的会话正常访问(用配置密钥签名构造会话,`TestSessionAbsoluteExpiryForcesRelogin`/`TestSessionWithinExpiryWorks`)。
|
||||||
|
|
||||||
## 已确认安全、无需改动
|
## 已确认安全、无需改动
|
||||||
|
|
||||||
@@ -177,4 +184,8 @@
|
|||||||
1. ~~#1(P0)~~ 已完成 2026-08-19
|
1. ~~#1(P0)~~ 已完成 2026-08-19
|
||||||
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
||||||
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||||
4. 其余 P3 项随版本迭代
|
4. ~~#12-#16(P3)~~ 已完成 2026-08-19
|
||||||
|
|
||||||
|
**全部安全审计项已修复完成。** 剩余建议(非代码项):
|
||||||
|
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
|
||||||
|
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)
|
||||||
Reference in New Issue
Block a user