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:
2026-08-19 16:21:28 +08:00
parent 6484af7e63
commit 551ed981de
8 changed files with 680 additions and 10 deletions
+98 -3
View File
@@ -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
}
+191
View File
@@ -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)
}
})
}
}