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:
2026-08-19 16:56:23 +08:00
parent 8cfeb43c6a
commit 8ea4a623a9
14 changed files with 394 additions and 53 deletions
+74
View File
@@ -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)
}
}
+19
View File
@@ -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 {
+12 -3
View File
@@ -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": "会话保存失败,请重试",
+51 -15
View File
@@ -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 // 原子预扣附件配额(单条 SQLused_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 #12Referer 开放重定向防护。
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)
}
}
}
+5 -2
View File
@@ -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"
}, },
+43
View File
@@ -0,0 +1,43 @@
package web
// P3 #14jsonify 模板函数在 <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)
}
}
+45
View File
@@ -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
View File
@@ -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)
+73
View File
@@ -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)
}
}
+1 -1
View File
@@ -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}}
+1 -1
View File
@@ -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 () {
+1 -1
View File
@@ -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
View File
@@ -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. ~~#1P0~~ 已完成 2026-08-19 1. ~~#1P0~~ 已完成 2026-08-19
2. ~~#2、#3、#4P1~~ 已完成 2026-08-19 2. ~~#2、#3、#4P1~~ 已完成 2026-08-19
3. ~~#5-#11P2~~ 已完成 2026-08-19 3. ~~#5-#11P2~~ 已完成 2026-08-19
4. 其余 P3 项随版本迭代 4. ~~#12-#16P3~~ 已完成 2026-08-19
**全部安全审计项已修复完成。** 剩余建议(非代码项):
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)