Files
mailgo/internal/web/session_secret_test.go
T
kevin 8ea4a623a9 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)全部修复完成。
2026-08-19 16:56:23 +08:00

270 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
// P0 回归测试:验证会话 cookie 由配置中的 secret_key 签名,
// 且旧版硬编码密钥(源码公开,视为已泄露)无法再伪造有效会话。
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"github.com/gorilla/securecookie"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func chdirRepoRoot(t *testing.T) {
t.Helper()
// NewWebServer 以相对路径加载 internal/web/templates/
// 测试进程的 CWD 是 internal/web,需要切到仓库根目录。
if err := os.Chdir(filepath.Join("..", "..")); err != nil {
t.Fatalf("chdir to repo root: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(filepath.Join("internal", "web")) })
}
func newTestStores(t *testing.T) *store.Stores {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.NewStores(gdb)
}
func newTestWebServer(t *testing.T, secretKey string) (*WebServer, *store.Stores) {
t.Helper()
chdirRepoRoot(t)
stores := newTestStores(t)
domain := &db.Domain{Name: "example.com", SmtpPort: 25, ImapPort: 143, Pop3Port: 110}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte("test-password-123"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if err := stores.Users.Create(&db.User{
Username: "alice",
PasswordHash: string(hash),
DomainID: domain.ID,
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
baseDir := t.TempDir()
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true}
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil)
if err != nil {
t.Fatalf("NewWebServer: %v", err)
}
return ws, stores
}
func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
ws, _ := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
// 登录成功 -> 返回会话 cookie(禁用自动重定向以获取原始 302 响应)
form := url.Values{"email": {"alice@example.com"}, "password": {"test-password-123"}}
loginReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(loginReq)
if err != nil {
t.Fatalf("login request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login status = %d, want 302", resp.StatusCode)
}
var sessionCookie string
for _, c := range resp.Cookies() {
if c.Name == "mail_go_session" {
sessionCookie = c.Value
if !c.HttpOnly {
t.Error("session cookie must be HttpOnly")
}
if !c.Secure {
t.Error("session cookie must be Secure")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("session cookie SameSite = %v, want Strict", c.SameSite)
}
}
}
if sessionCookie == "" {
t.Fatal("login should set mail_go_session cookie")
}
// 合法会话可以访问收件箱
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie})
resp2, err := client.Do(req)
if err != nil {
t.Fatalf("inbox request: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("inbox with valid session: status = %d, want 200", resp2.StatusCode)
}
}
func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) {
// 服务端使用随机生成的新密钥
ws, _ := newTestWebServer(t, "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0")
srv := httptest.NewServer(ws.Handler())
defer srv.Close()
// 攻击者用旧硬编码密钥(源码中公开)伪造管理员会话
forger := securecookie.New([]byte(config.InsecureLegacySecretKey), nil)
forged, err := forger.Encode("mail_go_session", map[interface{}]interface{}{
"userID": uint(1),
"userEmail": "admin@example.com",
"isAdmin": true,
})
if err != nil {
t.Fatalf("forge cookie: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged})
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 with forged cookie: %v", err)
}
defer resp.Body.Close()
// 签名校验失败 -> 未认证,必须被重定向到登录页
if resp.StatusCode != http.StatusFound {
t.Fatalf("forged legacy-key session must be rejected: status = %d, want 302 redirect to /login", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Fatalf("forged session should redirect to /login, got Location: %q", loc)
}
}
func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
chdirRepoRoot(t)
stores := newTestStores(t)
baseDir := t.TempDir()
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
cases := []struct {
name string
key string
}{
{"empty", ""},
{"legacy default", config.InsecureLegacySecretKey},
{"too short", "short-key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewWebServer(config.WebConfig{Addr: "127.0.0.1:0", SecretKey: tc.key},
stores, attStorage, config.StorageConfig{BaseDir: baseDir},
config.AuthConfig{}, config.BanConfig{}, config.CaddyConfig{}, nil)
if err == nil {
t.Fatalf("NewWebServer should reject secret key %q", tc.key)
}
})
}
}
// 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)
}
}