fix(security): 会话密钥改为随机生成,修复硬编码密钥可伪造管理员会话
安全审计 P0 修复:旧版会话签名密钥硬编码在源码中(源码公开即泄露), 任何人可据此伪造 isAdmin 会话接管后台。 - config: 新增 [web].secret_key,首启/升级时用 crypto/rand 自动生成 32 字节随机密钥并持久化;旧硬编码值自动替换 - config: 支持 MAILGO_SECRET_KEY 环境变量覆盖(覆盖值不落盘) - config: 配置文件权限收紧为 0600(同时保护 relay_password 等敏感字段) - web: NewWebServer 校验密钥(空/旧默认值/短于16字节拒绝启动) - test: 伪造会话拒绝、密钥生命周期(生成/持久化/重启稳定/env不落盘)等 9 个测试 - docs: README 配置参考、security_todo.md 安全修复清单 部署注意:升级重启后所有用户需重新登录。
This commit is contained in:
+17
-5
@@ -5,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -166,17 +167,22 @@ func avatarStyle(s string) string {
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager) *WebServer {
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, storageCfg config.StorageConfig, authCfg config.AuthConfig, banCfg config.BanConfig, caddyCfg config.CaddyConfig, ob *outbound.Manager) (*WebServer, error) {
|
||||
if err := config.ValidateSecretKey(cfg.SecretKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// Session store (cookie-based)
|
||||
cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))
|
||||
// Session store (cookie-based). The signing key comes from the config
|
||||
// file (auto-generated random key) or the MAILGO_SECRET_KEY env var.
|
||||
cookieStore := cookie.NewStore([]byte(cfg.SecretKey))
|
||||
cookieStore.Options(sessions.Options{
|
||||
HttpOnly: true,
|
||||
SameSite: 3, // SameSiteLaxMode
|
||||
SameSite: 3, // SameSiteStrictMode(比 Lax 更严格)
|
||||
MaxAge: 86400,
|
||||
Path: "/",
|
||||
})
|
||||
@@ -201,7 +207,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
return ws
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
@@ -278,6 +284,12 @@ func (ws *WebServer) registerRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the underlying Gin engine as an http.Handler, useful for
|
||||
// integration tests and for embedding behind a reverse proxy.
|
||||
func (ws *WebServer) Handler() http.Handler {
|
||||
return ws.engine
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the configured address.
|
||||
// Supports both TCP (e.g. ":8080") and Unix socket (e.g. "/run/mail_go/web.sock").
|
||||
func (ws *WebServer) Start() error {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package web
|
||||
|
||||
// P0 回归测试:验证会话 cookie 由配置中的 secret_key 签名,
|
||||
// 且旧版硬编码密钥(源码公开,视为已泄露)无法再伪造有效会话。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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}
|
||||
|
||||
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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user