diff --git a/README.md b/README.md index 755707a..596b65b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ attach_dir = "/srv/mail_go/attachments" # 附件存储目录 [web] addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket +secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成 + # 随机密钥并写入本文件(请妥善备份,泄露/丢失 + # 分别意味着会话可被伪造/所有登录态失效) [smtp] addr = ":25" # SMTP 明文端口 @@ -175,6 +178,16 @@ addr = "/run/mail_go/web.sock" 当 `addr` 以 `/` 开头时,Gin 自动以 Unix socket 方式监听。 +### 3. 指定会话密钥(容器/多实例部署) + +会话签名密钥可通过环境变量 `MAILGO_SECRET_KEY` 覆盖(优先于配置文件,且不会写入磁盘): + +```bash +MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go +``` + +要求:长度至少 16 字节;留空时由配置文件提供(首次启动自动生成)。更换密钥后所有已登录会话立即失效,用户需重新登录。 + Nginx 反向代理配置: ```nginx diff --git a/config/config.go b/config/config.go index 47ff421..5fa1fdd 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,10 @@ package config import ( + "crypto/rand" + "encoding/hex" "fmt" + "log" "os" "path/filepath" "runtime" @@ -25,8 +28,25 @@ type StorageConfig struct { // WebConfig holds web server settings. type WebConfig struct { Addr string `toml:"addr"` + // SecretKey 是 Web 会话 cookie 的签名密钥。留空时首次启动自动生成 + // 随机密钥并持久化到配置文件;也可通过环境变量 MAILGO_SECRET_KEY + // 覆盖(覆盖值不落盘,适合容器部署)。 + SecretKey string `toml:"secret_key"` } +// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。 +const SecretKeyEnvVar = "MAILGO_SECRET_KEY" + +// InsecureLegacySecretKey 是旧版本硬编码在源码中的会话签名密钥。 +// 源码公开意味着该密钥完全不可信,任何出现都必须替换。 +const InsecureLegacySecretKey = "mail-go-secret-key-change-in-production" + +// MinSecretKeyLen 是允许的最短会话密钥长度(字节)。 +const MinSecretKeyLen = 16 + +// secretKeyRandomBytes 是自动生成密钥的随机字节数(hex 编码后 64 字符)。 +const secretKeyRandomBytes = 32 + // SMTPConfig holds SMTP server settings. type SMTPConfig struct { Addr string `toml:"addr"` @@ -298,6 +318,63 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config { return cfg } +// generateSecretKey generates a cryptographically random session key, +// hex-encoded (64 characters for 32 random bytes). +func generateSecretKey() (string, error) { + buf := make([]byte, secretKeyRandomBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("生成随机会话密钥失败: %w", err) + } + return hex.EncodeToString(buf), nil +} + +// ensureSecretKey guarantees that cfg holds a trustworthy session key: +// an empty value or the known insecure legacy default is replaced with a +// freshly generated random key. The caller is responsible for persisting +// the updated config. +func ensureSecretKey(cfg *Config) error { + if cfg.Web.SecretKey != "" && cfg.Web.SecretKey != InsecureLegacySecretKey { + return nil + } + key, err := generateSecretKey() + if err != nil { + return err + } + if cfg.Web.SecretKey == InsecureLegacySecretKey { + log.Printf("检测到不安全的旧默认会话密钥,已自动更换为随机密钥(所有已登录会话将失效)") + } else { + log.Printf("已生成随机会话密钥并写入配置文件(Web 会话签名密钥,请妥善备份)") + } + cfg.Web.SecretKey = key + return nil +} + +// applySecretKeyEnv lets the MAILGO_SECRET_KEY environment variable +// override the key loaded from the config file. The override value is +// never persisted to disk. +func applySecretKeyEnv(cfg *Config) *Config { + if env := os.Getenv(SecretKeyEnvVar); env != "" { + cfg.Web.SecretKey = env + } + return cfg +} + +// ValidateSecretKey rejects session signing keys that are missing, too +// short, or the known insecure legacy default hardcoded in old versions. +func ValidateSecretKey(key string) error { + if key == "" { + return fmt.Errorf("Web 会话密钥为空,拒绝启动:请检查配置文件 [web].secret_key 或环境变量 %s", SecretKeyEnvVar) + } + if key == InsecureLegacySecretKey { + return fmt.Errorf("Web 会话密钥为已知不安全的旧默认值,拒绝启动:请删除配置文件 [web].secret_key 后重启以自动生成随机密钥") + } + if len(key) < MinSecretKeyLen { + return fmt.Errorf("Web 会话密钥过短(%d 字节,最少 %d):请检查 %s 或 [web].secret_key", + len(key), MinSecretKeyLen, SecretKeyEnvVar) + } + return nil +} + // writeConfig writes the configuration to the given file path. // It creates the parent directories if they don't exist. func writeConfig(path string, cfg *Config) error { @@ -316,6 +393,11 @@ func writeConfig(path string, cfg *Config) error { if err := enc.Encode(cfg); err != nil { return fmt.Errorf("写入配置文件失败: %w", err) } + + // 配置文件包含会话密钥、中继密码等敏感信息,收紧为仅属主可读写 + if err := os.Chmod(path, 0600); err != nil { + return fmt.Errorf("设置配置文件权限失败 %s: %w", path, err) + } return nil } @@ -323,15 +405,23 @@ func writeConfig(path string, cfg *Config) error { // If the configuration file does not exist, it creates one with default values. // If the file exists but has missing fields, they are filled with defaults and the file is updated. func LoadConfig() (*Config, error) { - path := configFilePath() + return loadConfigFrom(configFilePath()) +} + +// loadConfigFrom implements LoadConfig against an explicit file path so it +// can be unit-tested with temporary directories. +func loadConfigFrom(path string) (*Config, error) { defaults := defaultConfig() // If config file doesn't exist, create it with defaults if _, err := os.Stat(path); os.IsNotExist(err) { + if err := ensureSecretKey(defaults); err != nil { + return nil, err + } if mkErr := writeConfig(path, defaults); mkErr != nil { return nil, mkErr } - return defaults, nil + return applySecretKeyEnv(defaults), nil } // Read existing config file @@ -351,6 +441,11 @@ func LoadConfig() (*Config, error) { cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS } + // 会话密钥缺失或不安全时补发随机密钥(随下面的写回一并落盘) + if err := ensureSecretKey(cfg); err != nil { + return nil, err + } + // Merge defaults for any missing fields merged := mergeDefaults(cfg, defaults) @@ -360,5 +455,5 @@ func LoadConfig() (*Config, error) { return nil, writeErr } - return merged, nil + return applySecretKeyEnv(merged), nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..fb3d65a --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,191 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGenerateSecretKey(t *testing.T) { + key, err := generateSecretKey() + if err != nil { + t.Fatalf("generateSecretKey() error: %v", err) + } + // 32 随机字节 hex 编码 = 64 字符 + if len(key) != secretKeyRandomBytes*2 { + t.Fatalf("key length = %d, want %d", len(key), secretKeyRandomBytes*2) + } + + // 两次生成必须不同 + key2, err := generateSecretKey() + if err != nil { + t.Fatalf("generateSecretKey() error: %v", err) + } + if key == key2 { + t.Fatal("generated keys must be unique") + } + + if key == InsecureLegacySecretKey { + t.Fatal("generated key must never equal the legacy insecure default") + } +} + +func TestLoadConfigFirstBootGeneratesSecretKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "mail_go.toml") + + cfg, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("loadConfigFrom() error: %v", err) + } + if cfg.Web.SecretKey == "" { + t.Fatal("secret key should be generated on first boot") + } + + // 密钥必须落盘,保证重启后会话不失效 + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config file: %v", err) + } + if !strings.Contains(string(data), "secret_key = \""+cfg.Web.SecretKey+"\"") { + t.Fatalf("generated secret key should be persisted, file content:\n%s", data) + } + + // 第二次加载返回相同密钥(会话保持) + cfg2, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("second loadConfigFrom() error: %v", err) + } + if cfg2.Web.SecretKey != cfg.Web.SecretKey { + t.Fatalf("secret key must be stable across restarts: %q != %q", cfg2.Web.SecretKey, cfg.Web.SecretKey) + } + + // 配置文件包含敏感信息,权限必须为 0600(Windows 无 POSIX 权限,跳过) + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat config file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("config file mode = %o, want 0600", perm) + } + } +} + +func TestLoadConfigBackfillsSecretKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "mail_go.toml") + + // 模拟旧版本升级:配置文件中没有 secret_key 字段 + old := "[web]\naddr = \":9090\"\n\n[smtp]\ndomain = \"example.com\"\n" + if err := os.WriteFile(path, []byte(old), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("loadConfigFrom() error: %v", err) + } + if cfg.Web.SecretKey == "" { + t.Fatal("missing secret key should be backfilled") + } + // 原有字段保持不变 + if cfg.Web.Addr != ":9090" { + t.Fatalf("existing field overwritten: addr = %q", cfg.Web.Addr) + } + if cfg.SMTP.Domain != "example.com" { + t.Fatalf("existing field overwritten: domain = %q", cfg.SMTP.Domain) + } +} + +func TestLoadConfigReplacesLegacySecretKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "mail_go.toml") + + content := "[web]\naddr = \":8080\"\nsecret_key = \"" + InsecureLegacySecretKey + "\"\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("loadConfigFrom() error: %v", err) + } + if cfg.Web.SecretKey == InsecureLegacySecretKey { + t.Fatal("legacy insecure secret key must be replaced") + } + if cfg.Web.SecretKey == "" { + t.Fatal("replacement key must be non-empty") + } + + // 替换后的密钥必须落盘 + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), InsecureLegacySecretKey) { + t.Fatal("legacy key should be removed from the config file") + } + if !strings.Contains(string(data), cfg.Web.SecretKey) { + t.Fatal("replaced key should be persisted") + } +} + +func TestSecretKeyEnvOverride(t *testing.T) { + path := filepath.Join(t.TempDir(), "mail_go.toml") + + // 先正常生成一个落盘密钥 + cfg1, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("first loadConfigFrom() error: %v", err) + } + + // 环境变量覆盖运行时密钥 + envKey := "env-override-secret-key-0123456789abcdef" + t.Setenv(SecretKeyEnvVar, envKey) + + cfg2, err := loadConfigFrom(path) + if err != nil { + t.Fatalf("second loadConfigFrom() error: %v", err) + } + if cfg2.Web.SecretKey != envKey { + t.Fatalf("env var should override the file key: got %q", cfg2.Web.SecretKey) + } + + // 环境变量的值不能落盘 + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), envKey) { + t.Fatal("env-provided secret must not be persisted to disk") + } + // 落盘密钥保持原值 + if !strings.Contains(string(data), cfg1.Web.SecretKey) { + t.Fatal("file key should remain unchanged when env override is active") + } +} + +func TestValidateSecretKey(t *testing.T) { + cases := []struct { + name string + key string + wantErr bool + }{ + {"empty", "", true}, + {"legacy default", InsecureLegacySecretKey, true}, + {"too short", "short", true}, + {"valid hex key", "0123456789abcdef0123456789abcdef", false}, + {"valid env style", "env-override-secret-key-0123456789abcdef", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateSecretKey(tc.key) + if tc.wantErr && err == nil { + t.Fatalf("expected error for key %q", tc.key) + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error for key %q: %v", tc.key, err) + } + }) + } +} diff --git a/go.mod b/go.mod index ae02c23..0b4ee92 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-ldap/ldap/v3 v3.4.13 github.com/google/uuid v1.6.0 + github.com/gorilla/securecookie v1.1.2 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.35.0 @@ -38,7 +39,6 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gorilla/context v1.1.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/internal/web/server.go b/internal/web/server.go index c538f06..9bebd55 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 { diff --git a/internal/web/session_secret_test.go b/internal/web/session_secret_test.go new file mode 100644 index 0000000..4540e89 --- /dev/null +++ b/internal/web/session_secret_test.go @@ -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) + } + }) + } +} diff --git a/main.go b/main.go index bdfd6df..ccc0a52 100644 --- a/main.go +++ b/main.go @@ -291,7 +291,10 @@ func main() { } // 10. Start Web server - webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr) + webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr) + if err != nil { + log.Fatalf("Web 服务初始化失败: %v", err) + } fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr) go func() { if err := webServer.Start(); err != nil { diff --git a/security_todo.md b/security_todo.md new file mode 100644 index 0000000..25772dd --- /dev/null +++ b/security_todo.md @@ -0,0 +1,169 @@ +# 安全漏洞修复 TODO + +依据 2026-08-19 的安全审计结果(代码静态审计 + mail.lmve.net 线上验证)整理。 + +按优先级排列:P0 立即修复,P1 尽快修复,P2 排期修复,P3 加固项。 + +## P0 严重:可被完全接管 + +### 1. 会话签名密钥硬编码,可伪造任意管理员会话 + +- [x] 位置:`internal/web/server.go:176` +- 现状:`cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))`,密钥写死在源码中且无配置项可更换。源码公开 = 任何人可签发 `userID=1, isAdmin=true` 的合法 cookie,直接以管理员身份进入线上后台。 +- 修复方案: + - [x] `config.Config` 新增 `[web] secret_key` 字段。 + - [x] `LoadConfig()` 首次启动时用 `crypto/rand` 生成 32 字节随机密钥(hex 编码)写入配置文件;配置文件权限收紧为 0600(兼顾原有的中继密码等敏感字段)。 + - [x] 支持环境变量 `MAILGO_SECRET_KEY` 覆盖(覆盖值不落盘,便于容器部署)。 + - [x] 启动时校验:密钥为空 / 等于旧硬编码默认值 / 短于 16 字节时拒绝启动(`config.ValidateSecretKey` + `NewWebServer` 返回 error)。 +- 验证: + - [x] 重启后旧 cookie 全部失效(登录态被踢下线)。 + - [x] 用旧硬编码密钥手工签发的 cookie 无法通过认证(`TestLegacyHardcodedKeyCannotForgeSession`:伪造 `userID=1, isAdmin=true` 的 cookie 被拒,302 回登录页)。 + - [x] `config.LoadConfig()` 单测:首启生成、二次读取保持不变、旧配置补全、旧默认值替换、env 覆盖不落盘、文件权限 0600(`config/config_test.go`)。 +- 已完成(2026-08-19)。注:部署新版后所有用户需重新登录;配置文件权限由 0644 收紧为 0600。 + +## P1 高危 + +### 2. OAuth2 state 固定值且回调不校验(登录 CSRF / 授权码注入) + +- [ ] 位置:`internal/web/handlers/auth.go:230`(硬编码 `mailgo_oauth2_state`)、`OAuth2Callback` 未读取 `state` 参数 +- 现状:当前部署未启用 OAuth2,属休眠漏洞,启用前必须修复。 +- 修复方案: + - [ ] `OAuth2Start` 用 `crypto/rand` 生成 16+ 字节随机 state,存入 session(`session.Set("oauth2_state", ...)`)后再跳转。 + - [ ] `OAuth2Callback` 读取 `c.Query("state")` 与 session 中的值做 `subtle.ConstantTimeCompare` 比对,不匹配则拒绝。 + - [ ] 比对后立即从 session 中清除,保证一次性使用。 +- 验证: + - [ ] 单测:state 不匹配/缺失时回调返回 403。 + - [ ] 手工走完一次 OAuth2 流程(Google/GitHub)确认正常登录。 + +### 3. Gin 信任所有代理,`ClientIP()` 可伪造(封禁绕过 / 爆破) + +- [ ] 位置:`internal/web/server.go`(未调用 `SetTrustedProxies`) +- 现状:gin 默认信任 0.0.0.0/0,`X-Forwarded-For` 可任意伪造。线上 8080 端口当前被防火墙挡住,属纵深防御缺失;一旦 8080/socket 可达:伪造不同 IP 即可绕过登录失败封禁无限爆破,也可恶意封禁任意 IP 造成 DoS。 +- 修复方案: + - [ ] Web 监听为 unix socket 时:`engine.SetTrustedProxies(nil)`(Caddy 本机转发,无需信任任何代理头)。 + - [ ] Web 监听 TCP 时:仅信任 Caddy 所在网段(如 `127.0.0.1`),`engine.SetTrustedProxies([]string{"127.0.0.1"})`。 + - [ ] install.sh 文档注明:8080 端口必须保持仅本机可达(防火墙/绑定 127.0.0.1)。 +- 验证: + - [ ] 直接带伪造 `X-Forwarded-For` 请求 8080,日志中 ClientIP 为真实地址而非伪造值。 + - [ ] Caddy 反代路径下日志中 ClientIP 仍正确显示真实客户端 IP。 + +### 4. Web 写信 CRLF 邮件头注入 + +- [ ] 位置:`internal/web/handlers/mail.go:272-279`(`to`/`cc`/`subject` 直接拼头)、`:314-316`(附件文件名拼进 `Content-Disposition`/`Content-Type`) +- 现状:信封收件人经 `ParseAddress` 校验无法注入,但注入的头(如 `Reply-To`)会随邮件存储并外发,可被用于钓鱼。 +- 修复方案: + - [ ] 新增 `sanitizeHeader(s string) string`:strip `\r`、`\n`(及 NUL)。 + - [ ] `to`/`cc` 每个地址经 `mail.ParseAddress` 校验后使用其规范形式;解析失败的地址整封拒发。 + - [ ] `subject` 清洗 CRLF 后用 RFC 2047(`mime.QEncoding`)编码非 ASCII 内容。 + - [ ] 附件文件名清洗 CRLF,优先用 `mime.FormatMediaType("attachment", map[string]string{"filename": name})` 生成完整 `Content-Disposition`。 +- 验证: + - [ ] 单测:`to="a@b.com\r\nReply-To: x@evil.com"` 提交后存储的 RawData 中无注入头。 + - [ ] 含引号/换行的附件名正常收发且头格式合法。 + +## P2 中危 + +### 5. 会话 Cookie 缺 Secure 标志 + +- [ ] 位置:`internal/web/server.go:177-182` +- 修复方案: + - [ ] `sessions.Options` 增加 `Secure: true`。 + - [ ] 同时修正注释:当前 `SameSite: 3` 实为 Strict 而非注释所写的 Lax。 + - [ ] (可选)新增配置项允许本地 HTTP 调试时关闭 Secure。 +- 验证:线上登录后检查 `Set-Cookie` 包含 `Secure; HttpOnly; SameSite=Strict`。 + +### 6. SMTP/IMAP/POP3 认证无速率限制 + +- [ ] 位置:`internal/smtp_server/server.go`、`internal/imap_server/`、`internal/pop3_server/server.go` +- 修复方案: + - [ ] 认证失败计数复用 `BanStore`(按 `RemoteAddr` 提取 IP 记录 fail/ban)。 + - [ ] go-smtp 可通过 `AuthHandler` 包一层计数;IMAP/POP3 在各自登录入口计数。 + - [ ] 达到 `ban.max_fail_attempts` 后直接拒绝连接(SMTP 返回 421,IMAP/POP3 断开)。 +- 验证:连续输错 N 次密码后,后续 AUTH 尝试被拒绝且管理后台封禁列表出现对应记录。 + +### 7. 附件存储路径遍历防护无效 + +- [ ] 位置:`internal/storage/attachment.go:62-68` +- 现状:`filepath.Clean("../../x")` 仍以 `..` 开头,`TrimPrefix` 只剥离一层 `../`;`../../../etc/passwd` 清洗后仍可逃逸。路径来自 DB,需配合 SQL 写权限才可利用,属纵深防御缺陷。 +- 修复方案: + - [ ] 改为白名单校验:`cleanRel` 必须匹配 `^[a-f0-9-]{36}(\.[A-Za-z0-9.]+)?$`(uuid 命名格式),否则返回错误。 + - [ ] 兜底再校验 `strings.HasPrefix(fullPath, s.baseDir + string(os.PathSeparator))`。 +- 验证:单测覆盖 `../`、`..\`(Windows)、绝对路径、符号链接名等用例,均应拒绝。 + +### 8. 默认管理员 admin@example.com/admin + +- [ ] 位置:`main.go:308-358`(`ensureAdminUser`) +- 现状:线上实测默认凭据**未生效**(管理员已修改),但新装机仍存在默认口令窗口。 +- 修复方案: + - [ ] 首次启动生成 16 位随机密码,打印一次并要求首次登录强制修改(User 模型加 `MustChangePassword bool`)。 + - [ ] 或支持环境变量 `MAILGO_ADMIN_PASSWORD` 由部署者显式指定。 +- 验证:全新数据库启动后,用 admin/admin 无法登录。 + +### 9. Smarthost 中继 TLS 不验证证书(凭据可被 MITM 截获) + +- [ ] 位置:`internal/outbound/mailer.go:357-366`(`InsecureSkipVerify: true`) +- 修复方案: + - [ ] 区分两条路径:直投 MX 保持机会式 TLS(不验证,业界常规);relay 配置了用户名密码时默认验证证书(`ServerName` + 可选 `relay_tls_ca` pin 根证书),提供 `relay_tls_insecure` 开关逃生。 +- 验证:对自签证书 relay 测试:默认握手失败,开启开关后成功。 + +### 10. 缺安全响应头(点击劫持/降级风险) + +- [ ] 位置:Caddy 层或 `internal/web/server.go` 全局中间件 +- 修复方案(推荐 Caddy 统一加): + - [ ] `Strict-Transport-Security: max-age=31536000; includeSubDomains` + - [ ] `X-Frame-Options: DENY`(或 CSP `frame-ancestors 'none'`) + - [ ] `X-Content-Type-Options: nosniff` + - [ ] `Referrer-Policy: strict-origin-when-cross-origin` + - [ ] 基础 CSP(注意 Gmail/管理页内联脚本较多,先从 `default-src 'self'` + 按需放宽起步) +- 验证:`curl -sD - https://mail.lmve.net/login` 检查各头存在。 + +### 11. LDAP/OAuth 错误信息泄露与用户枚举 + +- [ ] 位置:`internal/web/handlers/auth.go:170,182,269` +- 修复方案: + - [ ] 错误提示统一为“认证失败”,内部细节只写日志,原始 `err` 不回显页面。 + - [ ] “用户 %s 在系统中不存在”改为与密码错误相同的提示。 +- 验证:LDAP/OAuth 登录失败时页面不含内部地址、DN、原始错误串。 + +## P3 低危 / 加固 + +### 12. Referer 开放重定向 + +- [ ] 位置:`internal/web/handlers/mail.go:567-571、591-595` +- [ ] 修复:仅接受以 `/` 开头且非 `//` 的相对路径 Referer,否则回退 `/inbox`。 + +### 13. Web 发信配额检查 TOCTOU + +- [ ] 位置:`internal/web/handlers/mail.go:209-263` +- [ ] 修复:配额检查与 `UpdateUsedBytes` 改为单条原子 SQL(`WHERE used_bytes + ? <= quota_bytes` 式更新),失败即拒发。 + +### 14. compose 页 safeJS 在 JS 上下文绕过转义(自 XSS) + +- [ ] 位置:`internal/web/templates/compose.html:80` +- [ ] 修复:改为 `quill.root.innerHTML = {{.bodyContent | jsonify}};`(模板函数内用 `json.Marshal` 输出 JS 字符串字面量)。 +- [ ] 顺手评估移除 `templateFuncs` 中不再使用的 `safeHTML`,缩小危险面。 + +### 15. Content-Disposition 文件名未编码 + +- [ ] 位置:`internal/web/handlers/mail.go:626`、`internal/web/handlers/admin.go:863` +- [ ] 修复:与 #4 一并改用 `mime.FormatMediaType`(RFC 5987 `filename*=`)。 + +### 16. 会话治理 + +- [ ] 登录成功后调用 `session.Clear()` 再写入新值(清掉可能的旧状态)。 +- [ ] 会话固定时长 24h 无任何续期/空闲过期策略,考虑加滑动过期与绝对过期。 + +## 已确认安全、无需改动 + +- bcrypt 密码哈希;GORM 全参数化查询(无 SQL 注入)。 +- SMTP 非开放中继、认证用户强制 From=登录身份。 +- LDAP 过滤器已 `EscapeFilter`。 +- 邮件 HTML 经 sandbox iframe(无 `allow-scripts`)渲染,`srcdoc` 属性转义经实测有效,无存储型 XSS。 +- Web 登录错误提示不区分用户是否存在(无枚举)。 + +## 修复顺序建议 + +1. ~~#1(P0,一个下午可完成,含单测)~~ 已完成 2026-08-19 +2. #3、#5、#10(部署层加固,改动小) +3. #4、#2(输入校验/流程修复) +4. #6、#7、#9(协议与存储层) +5. 其余 P3 项随版本迭代