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:
+98
-3
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user