Author SHA1 Message Date
wuwenfengmi1998 8cfeb43c6a docs: 新增移动端界面截图(响应式布局) 2026-08-19 16:48:26 +08:00
kevin 3f28ec20f4 fix(security): 修复 P2 中危项(cookie/协议限速/路径遍历/默认口令/中继TLS/安全头/信息泄露)
- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置
  (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理)
- SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web
  共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP,
  已封禁 IP 拒绝认证,堵住协议层暴力破解
- 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式)
  + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化
- 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或
  随机生成并打印一次;新增 MustChangePassword 首登强制改密
  (管理员重置密码同样触发)
- 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX
  保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false)
- 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、
  Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持,
  connect-src/form-action 'self' 防数据外泄)
- LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志,
  不再回显邮箱/内部细节(防用户枚举与信息泄露)
- 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、
  中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案

部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供
服务需显式配置 cookie_secure = false。
2026-08-19 16:45:21 +08:00
kevin c725d0b91e fix(security): 修复 P1 高危项(OAuth2 state / 伪造客户端IP / CRLF 邮件头注入)
- OAuth2: state 改为 crypto/rand 随机值并写入独立短期 cookie
  (主会话为 SameSite=Strict,跨站回调不携带,不能放主会话);
  回调用 ConstantTimeCompare 校验 state,缺失/不匹配返回 403,
  校验后立即清除保证一次性使用。原硬编码 mailgo_oauth2_state
  可被利用做授权码注入/登录 CSRF。
- 代理信任: engine.SetTrustedProxies 仅信任 127.0.0.1/::1。
  外部直连时 X-Forwarded-For 完全不可信,防止伪造客户端 IP
  绕过登录封禁或恶意封禁他人;本机 Caddy/Nginx 转发不受影响。
- CRLF 注入: Web 写信的 To/Cc/Subject 及附件文件名不再原样拼入
  MIME 头。新增 sanitizeHeaderField(strip CR/LF/NUL)、
  subject 按 RFC 2047 编码、附件名用 mime.FormatMediaType
  (RFC 2231);附件下载的 Content-Disposition 同步修复。
  消息构建抽为 buildOutgoingMessage 纯函数便于测试。
- test: 新增 13 个回归测试(trustedproxy / mail_injection /
  oauth2_state),覆盖伪造 XFF、注入载荷、state 校验全部分支。
2026-08-19 16:31:25 +08:00
kevin 551ed981de 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 安全修复清单

部署注意:升级重启后所有用户需重新登录。
2026-08-19 16:21:28 +08:00
kevin 6484af7e63 Merge pull request 'fix: 返回列表后未读邮件状态不更新(BFCache 静默同步)' (#9) from dsh/mailgo:fix/unread-status-bfcache into main
Reviewed-on: kevin/mailgo#9
2026-08-17 23:18:30 -04:00
dsh bf283ba5fc fix: 返回列表后未读邮件状态不更新(BFCache 静默同步)
问题:打开未读邮件(阅读页已自动标记已读)后返回列表,浏览器从 BFCache
恢复旧 DOM,列表仍显示未读,需手动刷新。

修复:
- 三个列表页的交互脚本提取为公共模板 listjs(base.html),行为一致
- pageshow(persisted=true) 时静默 fetch 最新列表并局部更新行状态
  (未读类/圆点/已删除行/未读角标/总数),不整页刷新、保留滚动
- visibilitychange 兜底:标签页重新可见时也同步一次(离线失败静默忽略,
  仅 BFCache 场景失败才整页刷新)
- 验证:合成 pageshow 事件驱动同步,未读->已读局部更新 PASS;
  桌面/移动/平板布局回归无异常
2026-08-17 23:08:15 -04:00
kevin e3514246b0 Merge pull request 'feat: 前端移动端响应式适配' (#8) from dsh/mailgo:feature/mobile-responsive into main
Reviewed-on: kevin/mailgo#8
2026-08-17 22:48:48 -04:00
40 changed files with 2106 additions and 327 deletions
+18
View File
@@ -82,6 +82,11 @@ attach_dir = "/srv/mail_go/attachments" # 附件存储目录
[web]
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
# 分别意味着会话可被伪造/所有登录态失效)
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
# 仅本地 HTTP 调试时才改为 false
[smtp]
addr = ":25" # SMTP 明文端口
@@ -140,6 +145,9 @@ relay_port = 587 # 465 = 隐式 TLS,其他端口按需
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 非 465 端口是否使用 STARTTLS
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
# 默认验证证书(保护中继凭据),仅自签证书
# 内网中继且明确知晓风险时才改为 true
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
```
@@ -175,6 +183,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
+114 -6
View File
@@ -1,7 +1,10 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
@@ -25,8 +28,29 @@ 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"`
// CookieSecure 控制会话 cookie 是否仅通过 HTTPS 传输(Secure 标志)。
// 默认 true;仅当应用直接以 HTTP 提供服务(本地调试、内网明文)时
// 才应改为 false。
CookieSecure bool `toml:"cookie_secure"`
}
// 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"`
@@ -107,6 +131,10 @@ type OutboundConfig struct {
RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN
RelayPassword string `toml:"relay_password"` // 中继认证密码
RelayStartTLS bool `toml:"relay_starttls"` // 非 465 端口是否使用 STARTTLS
// RelayTLSInsecure 是否跳过中继服务器的 TLS 证书验证。
// 默认 false(验证证书),避免凭据被中间人截获;仅当使用自签证书的
// 内网中继且明确知晓风险时才设为 true。
RelayTLSInsecure bool `toml:"relay_tls_insecure"`
// IP family and source address binding for outbound connections.
IPFamily string `toml:"ip_family"` // ipv4(默认,PTR/SPF 最可靠)| ipv6 | auto
@@ -169,7 +197,8 @@ func defaultConfig() *Config {
AttachDir: filepath.Join(bd, "attachments"),
},
Web: WebConfig{
Addr: DefaultWebPort,
Addr: DefaultWebPort,
CookieSecure: true,
},
SMTP: SMTPConfig{
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
@@ -298,6 +327,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 +402,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 +414,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
@@ -345,11 +444,20 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
// relay_starttls defaults to true for safety; the raw file is checked
// because TOML decoding cannot distinguish an absent bool from false.
// relay_starttls 与 web.cookie_secure 默认值为 true for safety;
// the raw file is checked because TOML decoding cannot distinguish
// an absent bool from false.
if !strings.Contains(string(data), "relay_starttls") {
cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS
}
if !strings.Contains(string(data), "cookie_secure") {
cfg.Web.CookieSecure = defaults.Web.CookieSecure
}
// 会话密钥缺失或不安全时补发随机密钥(随下面的写回一并落盘)
if err := ensureSecretKey(cfg); err != nil {
return nil, err
}
// Merge defaults for any missing fields
merged := mergeDefaults(cfg, defaults)
@@ -360,5 +468,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)
}
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+1 -1
View File
@@ -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
+5 -2
View File
@@ -15,8 +15,11 @@ type User struct {
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
// 用户)在首次登录后必须修改密码。
MustChangePassword bool `gorm:"default:false" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for User.
+11
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/mailutil"
"mail_go/internal/store"
@@ -26,12 +27,22 @@ import (
// imapBackend implements backend.Backend.
type imapBackend struct {
stores *store.Stores
banCfg config.BanConfig
}
// Login authenticates a user by email and password.
func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
clientIP := store.ClientIPFromAddr(connInfo.RemoteAddr)
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
if banned, _ := b.stores.Bans.IsBanned(clientIP); banned {
return nil, backend.ErrInvalidCredentials
}
user, err := b.stores.Users.Authenticate(username, password)
if err != nil {
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin)
return nil, fmt.Errorf("invalid credentials: %w", err)
}
+4 -2
View File
@@ -17,15 +17,17 @@ import (
type IMAPServer struct {
stores *store.Stores
cfg config.IMAPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
}
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader) *IMAPServer {
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *IMAPServer {
return &IMAPServer{
stores: stores,
cfg: cfg,
banCfg: banCfg,
tlsLoader: tlsLoader,
}
}
@@ -40,7 +42,7 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
// newServer creates a configured imapserver.Server with the given address.
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
be := &imapBackend{stores: s.stores}
be := &imapBackend{stores: s.stores, banCfg: s.banCfg}
srv := imapserver.New(be)
srv.Addr = addr
srv.TLSConfig = tlsConfig
+25 -13
View File
@@ -49,11 +49,12 @@ func newPermError(format string, args ...interface{}) *DeliveryError {
// RelayConfig describes a smarthost through which all external mail is sent.
type RelayConfig struct {
Host string
Port int // 465 = implicit TLS; other ports may use STARTTLS
Username string // AUTH PLAIN credentials (empty = no authentication)
Password string
StartTLS bool // use STARTTLS on non-465 ports
Host string
Port int // 465 = implicit TLS; other ports may use STARTTLS
Username string // AUTH PLAIN credentials (empty = no authentication)
Password string
StartTLS bool // use STARTTLS on non-465 ports
TLSInsecure bool // skip certificate verification (test-only, credentials leak risk)
}
// Mailer performs direct MX delivery (or smarthost relay) of a single message.
@@ -131,6 +132,8 @@ func (m *Mailer) Deliver(from, to string, data []byte) (string, error) {
}
// deliverViaRelay sends the message through the configured smarthost.
// The relay carries AUTH credentials, so its TLS certificate is verified
// unless RelayTLSInsecure is explicitly enabled.
func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
port := m.Relay.Port
if port == 0 {
@@ -139,7 +142,8 @@ func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
implicitTLS := port == 465
return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
m.Relay.StartTLS && !implicitTLS,
m.Relay.Username, m.Relay.Password, from, to, data)
m.Relay.Username, m.Relay.Password, from, to, data,
m.Relay.TLSInsecure)
}
// smtpClient wraps a textproto connection to a remote SMTP server.
@@ -240,13 +244,17 @@ func (c *smtpClient) authPlain(username, password string) error {
}
// deliverToHost performs a full SMTP transaction with a single MX host.
// Direct MX delivery is opportunistic TLS: certificates are not verified
// because most MX certificates cannot be validated over a cold connection.
func (m *Mailer) deliverToHost(host, from, to string, data []byte) (string, error) {
return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data)
return m.smtpTransaction(host, m.port(), false, false, "", "", from, to, data, true)
}
// smtpTransaction performs one complete SMTP session: connect, greeting,
// optional implicit TLS / STARTTLS, optional AUTH PLAIN, MAIL/RCPT/DATA/QUIT.
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte) (string, error) {
// tlsInsecure 控制 TLS 握手时是否跳过证书验证:直投 MX 用 true(机会式
// TLS),relay 用配置值(默认 false,保护中继凭据)。
func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bool, username, password, from, to string, data []byte, tlsInsecure bool) (string, error) {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
@@ -267,11 +275,12 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
tlsServerName := host
if ip := net.ParseIP(host); ip != nil {
tlsServerName = "" // no SNI for IP literals
// IP literal 同样作为 ServerName:证书验证模式下校验其 IP SAN
tlsServerName = ip.String()
}
if implicitTLS {
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
if err != nil {
return "", err
}
@@ -294,7 +303,7 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
return "", err
}
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
if err != nil {
return "", err
}
@@ -354,10 +363,13 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
}
// tlsClientHandshake upgrades a plain connection to TLS.
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string) (net.Conn, error) {
// Direct MX delivery passes insecure=true (opportunistic TLS: remote MX
// certificates often cannot be verified). Relays with AUTH credentials must
// pass insecure=false so the connection cannot be MITM'd.
func tlsClientHandshake(ctx context.Context, conn net.Conn, serverName, host string, insecure bool) (net.Conn, error) {
tlsConn := tls.Client(conn, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
InsecureSkipVerify: insecure,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
return nil, newTempError("TLS handshake with %s failed: %v", host, err)
+177
View File
@@ -2,8 +2,16 @@ package outbound
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"math/big"
"net"
"strconv"
"strings"
"testing"
"time"
@@ -340,3 +348,172 @@ func TestMailerSmarthostRelay(t *testing.T) {
t.Fatalf("relay data mismatch.\ngot: %q\nwant: %q", res.gotData, input)
}
}
// startTLSSMTPServer 起一个支持 STARTTLS 的 SMTP 服务器(自签证书),
// 供 relay TLS 验证测试使用:未升级 TLS 时广告 STARTTLS 能力,
// 收到 STARTTLS 后升级为 TLS 并重新 EHLO。
func startTLSSMTPServer(t *testing.T) (addr string, cleanup func()) {
t.Helper()
cert, err := tls.X509KeyPair(makeSelfSignedCertPEM(t))
if err != nil {
t.Fatalf("load self-signed cert: %v", err)
}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
_, _ = w.WriteString("220 relay.test ESMTP ready\r\n")
_ = w.Flush()
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
up := strings.ToUpper(strings.TrimRight(line, "\r\n"))
switch {
case strings.HasPrefix(up, "STARTTLS"):
_, _ = w.WriteString("220 2.0.0 ready to start TLS\r\n")
_ = w.Flush()
tlsConn := tls.Server(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
r = bufio.NewReader(conn)
w = bufio.NewWriter(conn)
case strings.HasPrefix(up, "EHLO"), strings.HasPrefix(up, "HELO"):
_, _ = w.WriteString("250-relay.test\r\n250-STARTTLS\r\n250 8BITMIME\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "AUTH PLAIN"):
_, _ = w.WriteString("235 2.0.0 ok\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "DATA"):
_, _ = w.WriteString("354 go ahead\r\n")
_ = w.Flush()
for {
dl, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
}
_, _ = w.WriteString("250 2.0.0 queued\r\n")
_ = w.Flush()
case strings.HasPrefix(up, "QUIT"):
_, _ = w.WriteString("221 bye\r\n")
_ = w.Flush()
return
default:
_, _ = w.WriteString("250 ok\r\n")
_ = w.Flush()
}
}
}()
}
}()
addr = ln.Addr().String()
return addr, func() { ln.Close() }
}
// makeSelfSignedCertPEM 生成一对自签证书(CN=relay.test)。
func makeSelfSignedCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "relay.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"relay.test"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return certPEM, keyPEM
}
// TestMailerRelayRejectsUntrustedCert 中继使用自签证书时默认必须拒绝
// (证书验证开启,防止凭据被中间人截获)。
func TestMailerRelayRejectsUntrustedCert(t *testing.T) {
addr, cleanup := startTLSSMTPServer(t)
defer cleanup()
m := NewMailer("mail.lmve.net", 5*time.Second)
m.Relay = &RelayConfig{
Host: "127.0.0.1",
Port: mustPort(t, addr),
Username: "relay-user",
Password: "relay-pass",
TLSInsecure: false,
}
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
_, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
if err == nil {
t.Fatal("relay with untrusted self-signed cert should be rejected")
}
if !strings.Contains(err.Error(), "certificate") {
t.Fatalf("expected certificate verification error, got: %v", err)
}
}
// TestMailerRelayInsecureSkipsVerification 显式开启 relay_tls_insecure
// 后,自签证书的中继可以完成 TLS 握手并进入 SMTP 会话。
func TestMailerRelayInsecureSkipsVerification(t *testing.T) {
addr, cleanup := startTLSSMTPServer(t)
defer cleanup()
m := NewMailer("mail.lmve.net", 5*time.Second)
m.Relay = &RelayConfig{
Host: "127.0.0.1",
Port: mustPort(t, addr),
Username: "relay-user",
Password: "relay-pass",
TLSInsecure: true,
}
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
resp, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
if err != nil {
t.Fatalf("relay with TLSInsecure should proceed: %v", err)
}
if !strings.HasPrefix(resp, "250") {
t.Fatalf("unexpected response: %q", resp)
}
}
func mustPort(t *testing.T, addr string) int {
t.Helper()
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split %q: %v", addr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("port %q: %v", portStr, err)
}
return port
}
+6 -5
View File
@@ -64,11 +64,12 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
if cfg.RelayHost != "" {
m.mailer.Relay = &RelayConfig{
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPassword,
StartTLS: cfg.RelayStartTLS,
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPassword,
StartTLS: cfg.RelayStartTLS,
TLSInsecure: cfg.RelayTLSInsecure,
}
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
}
+15 -2
View File
@@ -22,14 +22,15 @@ type POP3Server struct {
listener net.Listener
stores *store.Stores
cfg config.POP3Config
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
wg sync.WaitGroup
}
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
// when TLS is not configured.
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg, tlsLoader: tlsLoader}
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
}
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
@@ -107,6 +108,14 @@ func (s *POP3Server) handleConn(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Minute))
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
// 已封禁 IP 直接拒绝(防协议层暴力破解)
if banned, _ := s.stores.Bans.IsBanned(clientIP); banned {
sendResponse(conn, "-ERR access denied")
return
}
reader := bufio.NewReader(conn)
var user *db.User
var messages []pop3Message
@@ -272,8 +281,12 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
return nil, nil, nil
}
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
authUser, err := s.stores.Users.Authenticate(user.Username, password)
if err != nil {
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin)
sendResponse(conn, "-ERR authentication failed")
return nil, nil, nil
}
+19 -5
View File
@@ -36,13 +36,14 @@ type SMTPServer struct {
storage *storage.AttachmentStorage
outbound *outbound.Manager
cfg config.SMTPConfig
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
}
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, tlsLoader: tlsLoader}
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *SMTPServer {
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
}
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
@@ -108,9 +109,10 @@ type smtpBackend struct {
// NewSession creates a new SMTP session for the incoming connection.
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
return &smtpSession{
backend: be,
mode: be.mode,
rcpts: make([]string, 0),
backend: be,
mode: be.mode,
rcpts: make([]string, 0),
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
}, nil
}
@@ -126,6 +128,7 @@ type smtpSession struct {
userID uint
email string
user *db.User
clientIP string
}
// AuthMechanisms returns supported SMTP AUTH mechanisms.
@@ -139,8 +142,19 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return nil, smtp.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
// 已封禁 IP 一律拒绝认证(防协议层暴力破解)
if banned, _ := s.backend.server.stores.Bans.IsBanned(s.clientIP); banned {
return smtp.ErrAuthFailed
}
user, err := s.backend.server.stores.Users.Authenticate(username, password)
if err != nil {
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries
s.backend.server.stores.RecordAuthFailure(
s.clientIP,
s.backend.server.banCfg.MaxFailAttempts,
s.backend.server.banCfg.BanDurationMin,
)
return smtp.ErrAuthFailed
}
+47 -11
View File
@@ -4,11 +4,16 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
)
// savedFileRe 匹配 Save 生成的文件名:UUID(小写十六进制)+ 可选白名单扩展名。
// 只允许这种格式的路径进入文件系统,杜绝路径遍历(../)、绝对路径等。
var savedFileRe = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(\.[A-Za-z0-9._-]{1,32})?$`)
// AttachmentStorage handles file operations for email attachments on disk.
type AttachmentStorage struct {
baseDir string // cfg.Storage.AttachDir
@@ -19,6 +24,24 @@ func NewAttachmentStorage(baseDir string) *AttachmentStorage {
return &AttachmentStorage{baseDir: baseDir}
}
// safeExt 提取并白名单化文件扩展名:只保留字母数字与 ._-,最长 32 字符。
// 非法字符(含 CR/LF、路径分隔符)直接丢弃扩展名。
func safeExt(filename string) string {
ext := filepath.Ext(filename)
if len(ext) > 33 {
return ""
}
for _, r := range ext {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.', r == '_', r == '-':
default:
return ""
}
}
return ext
}
// Save writes attachment data to disk and returns the relative file path.
// The filename is generated as {uuid}{ext} to avoid collisions.
func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
@@ -27,8 +50,8 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
return "", fmt.Errorf("创建附件目录失败: %w", err)
}
// Generate a unique filename with the original extension
ext := filepath.Ext(filename)
// Generate a unique filename with a sanitized extension
ext := safeExt(filename)
uniqueName := uuid.New().String() + ext
fullPath := filepath.Join(s.baseDir, uniqueName)
@@ -41,7 +64,10 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
// Read reads attachment data from disk given a relative path.
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("读取附件文件失败: %w", err)
@@ -51,19 +77,29 @@ func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
// Delete removes an attachment file from disk given a relative path.
func (s *AttachmentStorage) Delete(relPath string) error {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return err
}
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除附件文件失败: %w", err)
}
return nil
}
// FullPath returns the absolute path for a given relative path.
func (s *AttachmentStorage) FullPath(relPath string) string {
// Prevent directory traversal attacks
cleanRel := filepath.Clean(relPath)
if strings.HasPrefix(cleanRel, "..") {
cleanRel = strings.TrimPrefix(cleanRel, "../")
// FullPath returns the absolute path for a relative path produced by Save.
// Paths that do not match the saved-file format (traversal attempts,
// absolute paths, unrelated names) are rejected with an error so they can
// never escape baseDir.
func (s *AttachmentStorage) FullPath(relPath string) (string, error) {
if !savedFileRe.MatchString(relPath) {
return "", fmt.Errorf("非法的附件路径: %q", relPath)
}
return filepath.Join(s.baseDir, cleanRel)
// 兜底校验:解析后的路径必须仍在 baseDir 内
fullPath := filepath.Join(s.baseDir, relPath)
if !strings.HasPrefix(fullPath, filepath.Clean(s.baseDir)+string(os.PathSeparator)) {
return "", fmt.Errorf("附件路径越界: %q", relPath)
}
return fullPath, nil
}
+97
View File
@@ -0,0 +1,97 @@
package storage
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
)
// TestFullPathRejectsTraversal 验证路径遍历/绝对路径等恶意输入被拒绝。
func TestFullPathRejectsTraversal(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
valid := uuid.New().String() + ".pdf"
bad := []string{
"../secret.txt",
"../../etc/passwd",
"..",
"....//x",
"/etc/passwd",
"a/../b.txt",
"sub/file.png",
"",
".", "..\\..\\x", // windows style
"00000000-0000-0000-0000-000000000000.exe\r\nBcc: x@y.com",
"garbage",
"00000000-0000-0000-0000-000000000000.%2e%2e",
}
for _, p := range bad {
if _, err := s.FullPath(p); err == nil {
t.Errorf("FullPath(%q) should be rejected", p)
}
}
// 合法文件名必须通过
full, err := s.FullPath(valid)
if err != nil {
t.Fatalf("FullPath(%q) rejected: %v", valid, err)
}
if !strings.HasPrefix(full, s.baseDir+string(os.PathSeparator)) {
t.Fatalf("FullPath(%q) = %q escapes baseDir", valid, full)
}
}
// TestSaveSanitizesExtension 验证恶意扩展名不会进入文件名。
func TestSaveSanitizesExtension(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
// 换行/路径分隔符等非法字符的扩展名应被丢弃
rel, err := s.Save("evil.pdf\r\nBcc: x@y.com", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if strings.ContainsAny(rel, "\r\n/\\") {
t.Fatalf("saved name contains dangerous chars: %q", rel)
}
if !savedFileRe.MatchString(rel) {
t.Fatalf("saved name %q does not match allowed pattern", rel)
}
// 后续 Read 应能按返回的路径读取
if _, err := s.Read(rel); err != nil {
t.Fatalf("Read after Save: %v", err)
}
// 正常扩展名保留
rel2, err := s.Save("report.pdf", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if !strings.HasSuffix(rel2, ".pdf") {
t.Fatalf("extension lost: %q", rel2)
}
}
// TestReadDeleteRoundTrip 正常读写删流程。
func TestReadDeleteRoundTrip(t *testing.T) {
dir := t.TempDir()
s := NewAttachmentStorage(filepath.Join(dir, "attachments"))
rel, err := s.Save("a.txt", []byte("hello"))
if err != nil {
t.Fatalf("Save: %v", err)
}
data, err := s.Read(rel)
if err != nil || string(data) != "hello" {
t.Fatalf("Read = %q, %v", data, err)
}
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete: %v", err)
}
// 删除后路径仍然合法(删除不存在文件不算错误)
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete again: %v", err)
}
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"fmt"
"net"
"time"
"mail_go/internal/db"
)
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
// 解析失败返回空字符串,调用方应据此跳过封禁逻辑(不误封)。
func ClientIPFromAddr(addr net.Addr) string {
if addr == nil {
return ""
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
// RecordAuthFailure 记录一次协议层(SMTP/IMAP/POP3)认证失败:
// 失败计数累加,达到 maxFail 阈值时封禁该 IP(封禁时长 minutes 分钟)。
// 返回 (是否触发封禁, 当前失败计数)。Web 登录的封禁逻辑在
// handlers.AuthHandler 中,与这里独立。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, minutes int) (banned bool, failCount int) {
if ip == "" || maxFail <= 0 {
return false, 0
}
failCount, _ = s.Bans.IncrementFail(ip)
if failCount >= maxFail {
_ = s.Bans.Create(&db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("邮件协议认证失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(time.Duration(minutes) * time.Minute),
})
return true, failCount
}
return false, failCount
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"net"
"path/filepath"
"testing"
"time"
"mail_go/internal/db"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newTestStores(t *testing.T) *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 NewStores(gdb)
}
// TestRecordAuthFailureBansAfterThreshold 验证连续认证失败达到阈值后封禁。
func TestRecordAuthFailureBansAfterThreshold(t *testing.T) {
s := newTestStores(t)
const ip = "203.0.113.10"
const maxFail = 3
// 前两次失败不封禁
for i := 1; i < maxFail; i++ {
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
if banned {
t.Fatalf("attempt %d should not be banned yet", i)
}
if count != i {
t.Fatalf("attempt %d: fail count = %d, want %d", i, count, i)
}
}
// 第三次失败触发封禁
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
if !banned {
t.Fatal("attempt reaching threshold should ban the IP")
}
if count != maxFail {
t.Fatalf("fail count = %d, want %d", count, maxFail)
}
// IP 现在处于封禁状态
banned, entry := s.Bans.IsBanned(ip)
if !banned {
t.Fatal("IP should be banned")
}
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
t.Fatalf("ban expiry too short: %v", entry.ExpiresAt)
}
}
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
s := newTestStores(t)
banned, count := s.RecordAuthFailure("", 3, 30)
if banned || count != 0 {
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
}
if _, err := s.Bans.GetByIP(""); err == nil {
t.Fatal("empty IP should not be recorded")
}
}
// TestRecordAuthFailureWebAndProtocolShared 协议层与 Web 层共用封禁记录。
func TestRecordAuthFailureWebAndProtocolShared(t *testing.T) {
s := newTestStores(t)
const ip = "198.51.100.20"
// Web 层已封禁(直接建记录模拟),协议层认证必须被拒绝
s.Bans.Create(&db.BanEntry{
IPAddress: ip,
Reason: "web login failures",
FailCount: 5,
ExpiresAt: time.Now().Add(30 * time.Minute),
})
if banned, _ := s.Bans.IsBanned(ip); !banned {
t.Fatal("IP should be banned for both web and protocol auth")
}
}
func TestClientIPFromAddr(t *testing.T) {
cases := []struct {
addr net.Addr
want string
}{
{nil, ""},
{addrMock("203.0.113.5:12345"), "203.0.113.5"},
{addrMock("[2001:db8::1]:993"), "2001:db8::1"},
{addrMock("bad-format"), "bad-format"},
}
for _, tc := range cases {
if got := ClientIPFromAddr(tc.addr); got != tc.want {
t.Errorf("ClientIPFromAddr(%v) = %q, want %q", tc.addr, got, tc.want)
}
}
}
// addrMock 实现 net.Addr 的最小桩。
type addrMock string
func (a addrMock) Network() string { return "tcp" }
func (a addrMock) String() string { return string(a) }
+7 -2
View File
@@ -124,9 +124,14 @@ func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
}
// UpdatePassword updates the password hash for a user.
// UpdatePassword updates the password hash for a user and clears the
// must-change-password flag (the user has now set their own password).
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
return s.db.Model(&db.User{}).Where("id = ?", userID).Update("password_hash", hashedPassword).Error
return s.db.Model(&db.User{}).Where("id = ?", userID).
Updates(map[string]interface{}{
"password_hash": hashedPassword,
"must_change_password": false,
}).Error
}
// ListAll retrieves a paginated list of all users across all domains.
+3 -1
View File
@@ -696,6 +696,8 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
return
}
user.PasswordHash = string(hashedPassword)
// 管理员重置的密码必须由用户本人修改后才能正常使用
user.MustChangePassword = true
}
if err := h.stores.Users.Update(user); err != nil {
@@ -860,7 +862,7 @@ func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
c.Data(http.StatusOK, att.ContentType, data)
}
+60 -6
View File
@@ -1,6 +1,9 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
@@ -167,7 +170,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -179,7 +182,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
"error": "LDAP 账号未接入本系统,请联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -218,6 +221,36 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
c.Redirect(302, "/inbox")
}
// OAuth2 state cookie 配置。state 用于防止登录 CSRF / 授权码注入:
// 发起授权时下发随机值,回调时必须原样带回。
//
// 注意 state 不能放进主会话 cookie:主会话是 SameSite=Strict
// OAuth2 回调是从 IdP 发起的跨站顶级导航,浏览器不会携带 Strict
// cookie,因此使用独立的短期 SameSite=Lax cookie。
const (
oauth2StateCookie = "mail_go_oauth2_state"
oauth2StateMaxAge = 600 // 秒,10 分钟内完成授权流程
oauth2StateRandLen = 16 // 随机字节数(hex 编码后 32 字符)
)
// randomOAuth2State generates a hex-encoded cryptographically random state.
func randomOAuth2State() (string, error) {
buf := make([]byte, oauth2StateRandLen)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成 OAuth2 state 失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
// oauth2LoginVars 是登录模板所需的公共变量。
func (h *AuthHandler) oauth2LoginVars() gin.H {
return gin.H{
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
}
}
// OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
@@ -226,8 +259,13 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
}
provider := auth.NewOAuth2Provider(h.authCfg)
// Use a simple state for CSRF protection (in production, use a random token)
state := "mailgo_oauth2_state"
state, err := randomOAuth2State()
if err != nil {
log.Printf("生成 OAuth2 state 失败: %v", err)
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试")
return
}
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
}
@@ -238,6 +276,22 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
return
}
// 校验 state:必须与发起授权时下发的随机值一致(常量时间比较)。
// 缺失或不匹配视为登录 CSRF / 授权码注入,直接拒绝。
cookieState, cookieErr := c.Cookie(oauth2StateCookie)
reqState := c.Query("state")
if cookieErr != nil || reqState == "" ||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
c.HTML(http.StatusForbidden, "login", func() gin.H {
v := h.oauth2LoginVars()
v["error"] = "OAuth2 state 校验失败,请重新发起登录"
return v
}())
return
}
// state 一次性使用:无论后续成败都立即失效
c.SetCookie(oauth2StateCookie, "", -1, "/auth/oauth2", "", true, true)
code := c.Query("code")
if code == "" {
c.HTML(200, "login", gin.H{
@@ -254,7 +308,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if err != nil {
log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
"error": "OAuth2 认证失败,请重试或联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
@@ -266,7 +320,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
"error": "OAuth2 账号未接入本系统,请联系管理员",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
+95 -59
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
@@ -264,62 +265,8 @@ func (h *MailHandler) DoSend(c *gin.Context) {
// Build the email content
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
messageID, rawMessage := buildOutgoingMessage(fromAddr, to, cc, subject, body, htmlBody, attachments)
now := time.Now()
messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String())
// Construct the raw email message
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
sb.WriteString(fmt.Sprintf("To: %s\r\n", to))
if cc != "" {
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc))
}
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
sb.WriteString("MIME-Version: 1.0\r\n")
// Attachments are wrapped in an outer multipart/mixed container.
outerBoundary := ""
hasAttachments := len(attachments) > 0
if hasAttachments {
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
}
// Build message body with multipart/alternative if HTML is present
if htmlBody != "" {
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
sb.WriteString(body)
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
sb.WriteString(htmlBody)
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
} else {
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(body)
sb.WriteString("\r\n")
}
// Append attachment parts to the multipart/mixed container.
for _, att := range attachments {
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
sb.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", att.contentType, att.filename))
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
sb.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", att.filename))
sb.WriteString(base64LineWrap(att.data))
sb.WriteString("\r\n")
}
if hasAttachments {
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
}
allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...)
localUsers := make([]*db.User, 0, len(allRecipients))
@@ -367,7 +314,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(sb.String())); err != nil {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
@@ -395,7 +342,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: sb.String(),
RawData: rawMessage,
Date: now,
IsRead: false,
}
@@ -426,7 +373,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
Subject: subject,
TextBody: body,
HtmlBody: htmlBody,
RawData: sb.String(),
RawData: rawMessage,
Date: now,
IsRead: true,
}
@@ -483,6 +430,94 @@ func parseAddressInput(input string) []string {
return addresses
}
// sanitizeHeaderField removes CR/LF/NUL from a value destined for an RFC 5322
// message header, preventing header injection (e.g. smuggling a Bcc or
// Reply-To header via a crafted subject or address list).
func sanitizeHeaderField(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\x00", "")
return s
}
// encodeSubject prepares a subject for safe inclusion as a message header:
// header injection characters are stripped and non-ASCII content is encoded
// per RFC 2047.
func encodeSubject(s string) string {
return mime.QEncoding.Encode("utf-8", sanitizeHeaderField(s))
}
// formatContentDisposition builds a Content-Disposition header value for the
// given filename, quoting/encoding it per RFC 2183/2231 (also neutralizes
// CR/LF injection through crafted filenames).
func formatContentDisposition(filename string) string {
return mime.FormatMediaType("attachment", map[string]string{"filename": filename})
}
// buildOutgoingMessage constructs the raw RFC 5322 message for the web
// compose form and returns its Message-ID. All header values derived from
// user input are sanitized to prevent CRLF header injection.
func buildOutgoingMessage(from, to, cc, subject, body, htmlBody string, attachments []pendingAttachment) (messageID, raw string) {
now := time.Now()
messageID = fmt.Sprintf("<%s@mail_go>", uuid.New().String())
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderField(from)))
sb.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderField(to)))
if cc != "" {
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", sanitizeHeaderField(cc)))
}
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeSubject(subject)))
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
sb.WriteString("MIME-Version: 1.0\r\n")
// Attachments are wrapped in an outer multipart/mixed container.
outerBoundary := ""
hasAttachments := len(attachments) > 0
if hasAttachments {
outerBoundary = fmt.Sprintf("----=_Mixed_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", outerBoundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
}
// Build message body with multipart/alternative if HTML is present
if htmlBody != "" {
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
sb.WriteString("\r\n")
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
sb.WriteString(body)
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
sb.WriteString(htmlBody)
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
} else {
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(body)
sb.WriteString("\r\n")
}
// Append attachment parts to the multipart/mixed container.
for _, att := range attachments {
contentType := mime.FormatMediaType(att.contentType, map[string]string{"name": att.filename})
sb.WriteString(fmt.Sprintf("--%s\r\n", outerBoundary))
sb.WriteString(fmt.Sprintf("Content-Type: %s\r\n", contentType))
sb.WriteString("Content-Transfer-Encoding: base64\r\n")
sb.WriteString(fmt.Sprintf("Content-Disposition: %s\r\n\r\n", formatContentDisposition(att.filename)))
sb.WriteString(base64LineWrap(att.data))
sb.WriteString("\r\n")
}
if hasAttachments {
sb.WriteString(fmt.Sprintf("--%s--\r\n", outerBoundary))
}
return messageID, sb.String()
}
// mimeTypes maps common file extensions to MIME types.
var mimeTypes = map[string]string{
".txt": "text/plain",
@@ -623,7 +658,7 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
c.Header("Content-Disposition", formatContentDisposition(att.FileName))
c.Data(http.StatusOK, att.ContentType, data)
}
@@ -688,6 +723,7 @@ func (h *MailHandler) Settings(c *gin.Context) {
"activeFolder": "settings",
"error": "",
"success": "",
"mustChange": c.Query("force") == "1",
"inboxUnread": inboxUnread,
"draftsTotal": draftsTotal,
"sentTotal": sentTotal,
@@ -0,0 +1,106 @@
package handlers
// P1 #4 回归测试:Web 写信的邮件头不可被 CRLF 注入。
// 旧实现把 to/cc/subject/附件名原样拼进 MIME 头,攻击者可通过
// subject 注入 Reply-To/Bcc 等任意头用于钓鱼。
import (
"strings"
"testing"
)
func TestSanitizeHeaderField(t *testing.T) {
cases := []struct {
in, want string
}{
{"normal value", "normal value"},
{"with\r\ninjected: header", "withinjected: header"},
{"lf\nonly", "lfonly"},
{"cr\ronly", "cronly"},
{"nul\x00byte", "nulbyte"},
{"mixed\r\n\x00all", "mixedall"},
{"中文主题", "中文主题"},
}
for _, tc := range cases {
if got := sanitizeHeaderField(tc.in); got != tc.want {
t.Errorf("sanitizeHeaderField(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestBuildOutgoingMessageBlocksHeaderInjection(t *testing.T) {
_, raw := buildOutgoingMessage(
"alice@example.com",
"bob@example.com\r\nBcc: victim@evil.com",
"carol@example.com\r\nReply-To: attacker@evil.com",
"Hi\r\nBcc: victim@evil.com\r\nReply-To: attacker@evil.com",
"body",
"",
nil,
)
// 注入的头不允许以独立头形式出现
for _, injected := range []string{
"Bcc:", "Reply-To:",
} {
if strings.Contains(raw, "\r\n"+injected) || strings.HasPrefix(raw, injected) {
t.Fatalf("injected header %q found in message:\n%s", injected, raw)
}
}
// 注入的邮箱地址本身允许以折叠形式残留在原头值中,
// 但绝不能成为独立的一行头。
lines := strings.Split(raw, "\r\n")
for _, line := range lines[1:] { // 跳过 From
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") || strings.HasPrefix(trimmed, "Reply-To:") {
t.Fatalf("injected header line %q found in message:\n%s", line, raw)
}
}
}
func TestBuildOutgoingMessageAttFilenameInjection(t *testing.T) {
atts := []pendingAttachment{
{filename: "evil.png\r\nBcc: victim@evil.com", contentType: "image/png", data: []byte("x")},
{filename: `quote".png`, contentType: "image/png", data: []byte("x")},
}
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "t", "body", "", atts)
lines := strings.Split(raw, "\r\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") {
t.Fatalf("filename header injection found: %q\nmessage:\n%s", line, raw)
}
}
// 含引号/换行的文件名必须被正确编码,不能破坏头结构
if !strings.Contains(raw, "Content-Disposition: attachment;") {
t.Fatalf("Content-Disposition missing in message:\n%s", raw)
}
}
func TestBuildOutgoingMessageEncodesNonASCIISubject(t *testing.T) {
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "中文主题测试", "body", "", nil)
// 非 ASCII 主题应按 RFC 2047 编码为 =?utf-8?...?= 形式
if !strings.Contains(raw, "Subject: =?utf-8?") && !strings.Contains(raw, "Subject: =?UTF-8?") {
t.Fatalf("non-ASCII subject should be RFC 2047 encoded, got:\n%s", raw)
}
// 头部不应再包含裸中文(应被编码)
for _, line := range strings.Split(raw, "\r\n") {
if strings.HasPrefix(line, "Subject:") && strings.ContainsAny(line, "中文测试") {
t.Fatalf("raw non-ASCII in Subject header: %q", line)
}
}
}
func TestFormatContentDisposition(t *testing.T) {
if got := formatContentDisposition("report.pdf"); got != "attachment; filename=report.pdf" {
t.Fatalf("simple filename: got %q", got)
}
// 特殊字符需要安全编码而不是原样嵌入
got := formatContentDisposition("a\"b\\c\r\nd.png")
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
}
}
+208
View File
@@ -0,0 +1,208 @@
package handlers
// P1 #2 回归测试:OAuth2 state 必须随机、回调必须校验。
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
import (
"html/template"
"math"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/mailutil"
"github.com/gin-gonic/gin"
)
// testTemplateFuncs 提供模板解析所需的自定义函数(与 web 包的
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
func testTemplateFuncs() template.FuncMap {
return template.FuncMap{
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"safeJS": func(s string) template.JS { return template.JS(s) },
"formatBytes": func(b int64) string {
return "1 KB"
},
"decodeHeader": mailutil.DecodeRFC2047,
"mailName": func(s string) string { return s },
"mailEmail": func(s string) string { return s },
"initial": func(s string) string { return "?" },
"truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
}
}
func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, engine := gin.CreateTestContext(w)
// 回调的错误分支渲染 login 模板,需要加载模板及自定义函数
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
engine.SetHTMLTemplate(tmpl)
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
authCfg := config.AuthConfig{
OAuth2Enabled: true,
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
// 测试不依赖外部网络。
OAuth2Provider: "127.0.0.1:1",
OAuth2ClientID: "test-client-id",
OAuth2ClientSecret: "test-client-secret",
OAuth2RedirectURL: "https://mail.example.com/auth/oauth2/callback",
}
h := NewAuthHandler(nil, authCfg, config.BanConfig{MaxFailAttempts: 100})
return c, h, w
}
func TestRandomOAuth2State(t *testing.T) {
s1, err := randomOAuth2State()
if err != nil {
t.Fatalf("randomOAuth2State() error: %v", err)
}
if len(s1) != oauth2StateRandLen*2 {
t.Fatalf("state length = %d, want %d (hex)", len(s1), oauth2StateRandLen*2)
}
s2, _ := randomOAuth2State()
if s1 == s2 {
t.Fatal("state must be unique per request")
}
if s1 == "mailgo_oauth2_state" {
t.Fatal("state must not be the old hardcoded constant")
}
}
func TestOAuth2StartSetsRandomStateCookie(t *testing.T) {
c, h, w := newOAuth2TestContext(t)
h.OAuth2Start(c)
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "state=") {
t.Fatalf("redirect URL should carry state: %s", loc)
}
// state cookie 必须存在且与 URL 中的一致
cookies := w.Result().Cookies()
var stateVal string
found := false
for _, ck := range cookies {
if ck.Name == oauth2StateCookie {
found = true
stateVal = ck.Value
if !ck.HttpOnly {
t.Error("state cookie must be HttpOnly")
}
if !ck.Secure {
t.Error("state cookie must be Secure")
}
if ck.MaxAge <= 0 || ck.MaxAge > oauth2StateMaxAge {
t.Errorf("state cookie MaxAge = %d, want in (0, %d]", ck.MaxAge, oauth2StateMaxAge)
}
}
}
if !found {
t.Fatal("OAuth2Start should set state cookie")
}
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("parse location: %v", err)
}
if u.Query().Get("state") != stateVal {
t.Fatalf("cookie state %q != URL state %q", stateVal, u.Query().Get("state"))
}
// 两次发起的 state 不同
c2, h2, w2 := newOAuth2TestContext(t)
h2.OAuth2Start(c2)
u2, _ := url.Parse(w2.Header().Get("Location"))
if u2.Query().Get("state") == stateVal {
t.Fatal("state must differ between sessions")
}
}
func TestOAuth2CallbackRejectsMissingOrMismatchedState(t *testing.T) {
cases := []struct {
name string
cookieState string
queryState string
}{
{"no cookie", "", "abc"},
{"no query state", "abc", ""},
{"mismatch", "abc", "xyz"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, h, w := newOAuth2TestContext(t)
q := url.Values{}
q.Set("code", "test-code")
if tc.queryState != "" {
q.Set("state", tc.queryState)
}
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
if tc.cookieState != "" {
c.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: tc.cookieState})
}
h.OAuth2Callback(c)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
})
}
}
func TestOAuth2CallbackAcceptsValidState(t *testing.T) {
// 模拟完整流程:Start 下发 state -> Callback 带回同一 state。
// state 校验通过后应进入后续流程(本测试无真实 IdP,
// code 交换会失败并渲染登录错误页,但这证明 state 关卡已通过)。
c, h, w := newOAuth2TestContext(t)
h.OAuth2Start(c)
var stateVal string
for _, ck := range w.Result().Cookies() {
if ck.Name == oauth2StateCookie {
stateVal = ck.Value
}
}
w2 := httptest.NewRecorder()
c2, engine2 := gin.CreateTestContext(w2)
tmpl2 := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
engine2.SetHTMLTemplate(tmpl2)
q := url.Values{}
q.Set("code", "test-code")
q.Set("state", stateVal)
c2.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
c2.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: stateVal})
h.OAuth2Callback(c2)
// state 校验失败返回 403;此处应为非 403(进入 token 交换失败分支)
if w2.Code == http.StatusForbidden {
t.Fatalf("valid state was rejected")
}
if !strings.Contains(w2.Body.String(), "OAuth2") {
body := w2.Body.String()
if len(body) > 200 {
body = body[:200]
}
t.Fatalf("expected OAuth2 error page after state check passed, body: %s", body)
}
}
+7
View File
@@ -48,6 +48,13 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
return
}
// 首次登录/密码被重置的用户必须先修改密码才能使用其他功能
if user.MustChangePassword && c.Request.URL.Path != "/settings" && c.Request.URL.Path != "/logout" {
c.Redirect(302, "/settings?force=1")
c.Abort()
return
}
c.Set("currentUser", user)
c.Set("userID", id)
c.Next()
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// securityCSP 是本应用的基础 CSP。
//
// 说明:
// - 模板大量使用内联脚本/样式(Quill 初始化、行内事件处理、
// avatarStyle 内联 CSS),故 script-src / style-src 需要
// 'unsafe-inline'
// - 邮件正文在 srcdoc iframe 中渲染,可能引用远程图片(https:),
// 因此 img-src 放行 https,同时仍阻止 data: 以外的自定义协议;
// - frame-ancestors 'none' 与 X-Frame-Options 共同防护点击劫持;
// - connect-src 'self' / form-action 'self' 阻止页面数据外泄到
// 外部域名。
const securityCSP = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self'; object-src 'none'; base-uri 'self'; " +
"form-action 'self'; frame-ancestors 'none'"
// SecurityHeaders 为所有响应设置基础安全头:HSTS、点击劫持防护、
// MIME 嗅探防护、Referrer 策略与基础 CSP。
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", securityCSP)
c.Next()
}
}
+45
View File
@@ -0,0 +1,45 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// TestSecurityHeaders 验证所有基础安全响应头都存在。
func TestSecurityHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(w, req)
for _, h := range []string{
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Content-Security-Policy",
} {
if v := w.Header().Get(h); v == "" {
t.Errorf("missing security header %q", h)
}
}
// 关键头内容抽查
if got := w.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", got)
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
t.Errorf("CSP should include frame-ancestors 'none', got %q", got)
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "connect-src 'self'") {
t.Errorf("CSP should include connect-src 'self', got %q", got)
}
}
+28 -5
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"math"
"net"
"net/http"
"os"
"path/filepath"
"strings"
@@ -166,17 +167,31 @@ 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"))
// 仅信任本机回环上的反向代理(Caddy/Nginx)。外部直连时
// X-Forwarded-For 不可信,防止伪造客户端 IP 绕过登录封禁或
// 恶意封禁他人 IP。gin 对 Unix socket 监听无条件信任转发头,
// 因此 socket 必须保持仅本机可达。
if err := engine.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
return nil, fmt.Errorf("设置可信代理失败: %w", err)
}
// 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 更严格)
Secure: cfg.CookieSecure,
MaxAge: 86400,
Path: "/",
})
@@ -201,7 +216,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.
@@ -212,6 +227,8 @@ func (ws *WebServer) registerRoutes() {
// Apply BanMiddleware globally before public routes
ws.engine.Use(middleware.BanMiddleware(ws.stores))
// Security headers on every response
ws.engine.Use(middleware.SecurityHeaders())
// Public routes (no auth required)
ws.engine.GET("/login", authHandler.ShowLogin)
@@ -278,6 +295,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 {
+196
View File
@@ -0,0 +1,196 @@
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, 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)
}
})
}
}
+124
View File
@@ -489,3 +489,127 @@
</div>
</aside>
{{end}}
{{define "listjs"}}
<script>
(function () {
var searchInput = document.getElementById('mail-search');
var rows = Array.prototype.slice.call(document.querySelectorAll('.mail-row'));
var selectAll = document.getElementById('select-all');
var btnDelete = document.getElementById('btn-delete');
// 行点击跳转(复选框 / 行内删除按钮除外)
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('.cell-check') || e.target.closest('.row-del')) return;
var link = row.querySelector('.cell-subject');
if (link) window.location.href = link.getAttribute('href');
});
});
// 全选
selectAll && selectAll.addEventListener('change', function () {
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.checked = selectAll.checked;
row.classList.toggle('selected', cb.checked);
});
updateDeleteState();
});
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.addEventListener('change', function () {
row.classList.toggle('selected', cb.checked);
if (!cb.checked && selectAll) selectAll.checked = false;
updateDeleteState();
});
});
function updateDeleteState() {
if (!btnDelete) return;
var n = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; }).length;
btnDelete.disabled = n === 0;
}
// 批量删除
btnDelete && btnDelete.addEventListener('click', function () {
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
var done = 0;
ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
.then(function () { if (++done === ids.length) window.location.reload(); })
.catch(function () { if (++done === ids.length) window.location.reload(); });
});
});
// 刷新
var btnRefresh = document.getElementById('btn-refresh');
btnRefresh && btnRefresh.addEventListener('click', function () { window.location.reload(); });
// 搜索过滤(发件人 / 主题 / 摘要)
searchInput && searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
rows.forEach(function (row) {
if (!row.isConnected) return;
var text = row.textContent.toLowerCase();
row.style.display = (!q || text.indexOf(q) !== -1) ? '' : 'none';
});
});
// 从 BFCache(往返缓存)恢复时静默同步最新列表状态:
// 阅读页已把邮件标记为已读/删除,恢复的旧 DOM 需要与服务端对齐,
// 避免“返回后仍是未读/已删邮件仍显示”。
// 真实浏览器在 BFCache 恢复时会触发 pageshow(persisted=true)
// visibilitychange 作为兜底(页面重新可见时也同步一次)。
var syncing = false;
function syncFromServer(forceReloadOnError) {
if (syncing) return;
syncing = true;
fetch(window.location.href, { headers: { 'Accept': 'text/html' }, credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = {};
doc.querySelectorAll('.mail-row').forEach(function (r) { fresh[r.dataset.id] = r.classList.contains('unread'); });
rows.forEach(function (row) {
var id = row.dataset.id;
if (!(id in fresh)) { row.remove(); return; } // 已被删除
row.classList.toggle('unread', fresh[id]);
var wrap = row.querySelector('.cell-subject-wrap');
var dot = row.querySelector('.unread-dot');
if (fresh[id] && !dot && wrap) {
var s = document.createElement('span');
s.className = 'unread-dot';
wrap.insertBefore(s, wrap.firstChild);
} else if (!fresh[id] && dot) {
dot.remove();
}
});
// 同步“共 N 封”与侧栏未读角标
var total = doc.querySelector('.page-info');
var curTotal = document.querySelector('.page-info');
if (total && curTotal) curTotal.textContent = total.textContent;
var badge = doc.querySelector('.folder.active .badge');
var curBadge = document.querySelector('.folder.active .badge');
if (badge && curBadge) curBadge.textContent = badge.textContent;
else if (!badge && curBadge) curBadge.remove();
})
.catch(function () {
// BFCache 恢复时 DOM 是旧的,同步失败只能整页刷新兜底;
// 标签页切回触发的同步失败则静默忽略(可能只是离线)。
if (forceReloadOnError) window.location.reload();
})
.then(function () { syncing = false; });
}
window.addEventListener('pageshow', function (e) {
if (e.persisted) syncFromServer(true);
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') syncFromServer(false);
});
})();
</script>
{{end}}
+1 -62
View File
@@ -87,68 +87,7 @@
</div>
</main>
</div>
<script>
(function () {
var searchInput = document.getElementById('mail-search');
var rows = Array.prototype.slice.call(document.querySelectorAll('.mail-row'));
var selectAll = document.getElementById('select-all');
var btnDelete = document.getElementById('btn-delete');
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('.cell-check') || e.target.closest('.row-del')) return;
window.location.href = row.querySelector('.cell-subject').getAttribute('href');
});
});
selectAll && selectAll.addEventListener('change', function () {
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.checked = selectAll.checked;
row.classList.toggle('selected', cb.checked);
});
updateDeleteState();
});
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.addEventListener('change', function () {
row.classList.toggle('selected', cb.checked);
if (!cb.checked && selectAll) selectAll.checked = false;
updateDeleteState();
});
});
function updateDeleteState() {
if (!btnDelete) return;
var n = rows.filter(function (r) { return r.querySelector('.row-check').checked; }).length;
btnDelete.disabled = n === 0;
}
btnDelete && btnDelete.addEventListener('click', function () {
var ids = rows.filter(function (r) { return r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
var done = 0;
ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
.then(function () { if (++done === ids.length) window.location.reload(); })
.catch(function () { if (++done === ids.length) window.location.reload(); });
});
});
var btnRefresh = document.getElementById('btn-refresh');
btnRefresh && btnRefresh.addEventListener('click', function () { window.location.reload(); });
searchInput && searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
rows.forEach(function (row) {
var text = row.textContent.toLowerCase();
row.style.display = (!q || text.indexOf(q) !== -1) ? '' : 'none';
});
});
})();
</script>
{{template "listjs" .}}
</body>
</html>
{{end}}
+1 -69
View File
@@ -82,75 +82,7 @@
</div>
</main>
</div>
<script>
(function () {
var searchInput = document.getElementById('mail-search');
var rows = Array.prototype.slice.call(document.querySelectorAll('.mail-row'));
var selectAll = document.getElementById('select-all');
var btnDelete = document.getElementById('btn-delete');
// 行点击跳转(复选框除外)
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('.cell-check')) return;
window.location.href = row.querySelector('.cell-subject').getAttribute('href');
});
});
// 全选
selectAll && selectAll.addEventListener('change', function () {
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.checked = selectAll.checked;
row.classList.toggle('selected', cb.checked);
});
updateDeleteState();
});
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.addEventListener('change', function () {
row.classList.toggle('selected', cb.checked);
if (!cb.checked && selectAll) selectAll.checked = false;
updateDeleteState();
});
});
function updateDeleteState() {
if (!btnDelete) return;
var n = rows.filter(function (r) { return r.querySelector('.row-check').checked; }).length;
btnDelete.disabled = n === 0;
}
// 批量删除
btnDelete && btnDelete.addEventListener('click', function () {
var ids = rows.filter(function (r) { return r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
var done = 0;
ids.forEach(function (id) {
var fd = new FormData();
fd.append('_method', 'DELETE');
fetch('/mail/delete/' + id, { method: 'POST', body: fd })
.then(function () { if (++done === ids.length) window.location.reload(); })
.catch(function () { if (++done === ids.length) window.location.reload(); });
});
});
// 刷新
var btnRefresh = document.getElementById('btn-refresh');
btnRefresh && btnRefresh.addEventListener('click', function () { window.location.reload(); });
// 搜索过滤(发件人 / 主题 / 摘要)
searchInput && searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
rows.forEach(function (row) {
var text = row.textContent.toLowerCase();
row.style.display = (!q || text.indexOf(q) !== -1) ? '' : 'none';
});
});
})();
</script>
{{template "listjs" .}}
</body>
</html>
{{end}}
+1 -62
View File
@@ -87,68 +87,7 @@
</div>
</main>
</div>
<script>
(function () {
var searchInput = document.getElementById('mail-search');
var rows = Array.prototype.slice.call(document.querySelectorAll('.mail-row'));
var selectAll = document.getElementById('select-all');
var btnDelete = document.getElementById('btn-delete');
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('.cell-check') || e.target.closest('.row-del')) return;
window.location.href = row.querySelector('.cell-subject').getAttribute('href');
});
});
selectAll && selectAll.addEventListener('change', function () {
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.checked = selectAll.checked;
row.classList.toggle('selected', cb.checked);
});
updateDeleteState();
});
rows.forEach(function (row) {
var cb = row.querySelector('.row-check');
cb.addEventListener('change', function () {
row.classList.toggle('selected', cb.checked);
if (!cb.checked && selectAll) selectAll.checked = false;
updateDeleteState();
});
});
function updateDeleteState() {
if (!btnDelete) return;
var n = rows.filter(function (r) { return r.querySelector('.row-check').checked; }).length;
btnDelete.disabled = n === 0;
}
btnDelete && btnDelete.addEventListener('click', function () {
var ids = rows.filter(function (r) { return r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
var done = 0;
ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
.then(function () { if (++done === ids.length) window.location.reload(); })
.catch(function () { if (++done === ids.length) window.location.reload(); });
});
});
var btnRefresh = document.getElementById('btn-refresh');
btnRefresh && btnRefresh.addEventListener('click', function () { window.location.reload(); });
searchInput && searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
rows.forEach(function (row) {
var text = row.textContent.toLowerCase();
row.style.display = (!q || text.indexOf(q) !== -1) ? '' : 'none';
});
});
})();
</script>
{{template "listjs" .}}
</body>
</html>
{{end}}
+3
View File
@@ -14,6 +14,9 @@
<main class="mail-main settings-main">
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
{{if .mustChange}}<div class="alert" style="border:1px solid #ffa940;background:#fff7e6;color:#d46b08;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13.5px;">
⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。
</div>{{end}}
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
+67
View File
@@ -0,0 +1,67 @@
package web
// P1 #3 回归测试:客户端 IP 不可通过 X-Forwarded-For 伪造。
// 外部直连时伪造头必须被忽略(防绕过登录封禁/恶意封禁他人),
// 本机回环(反向代理)转发时必须取 X-Forwarded-For 中的真实客户端 IP。
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doLoginFailure 触发一次登录失败(使 BanStore 按 ClientIP 记录失败计数),
// 返回使用的请求。
func doLoginFailure(t *testing.T, ws *WebServer, remoteAddr, xff string) {
t.Helper()
form := strings.NewReader("email=nobody@example.com&password=wrong")
req := httptest.NewRequest(http.MethodPost, "/login", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = remoteAddr
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
w := httptest.NewRecorder()
ws.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK { // 登录失败重渲染登录页
t.Fatalf("login failure status = %d, want 200", w.Code)
}
}
func TestExternalClientIPCannotBeSpoofed(t *testing.T) {
ws, stores := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
// 模拟外部攻击者直连 8080 端口,伪造 X-Forwarded-For
doLoginFailure(t, ws, "203.0.113.99:5555", "1.2.3.4")
// 失败计数必须记在真实来源 IP 上
if _, err := stores.Bans.GetByIP("1.2.3.4"); err == nil {
t.Fatal("spoofed X-Forwarded-For IP must not be recorded")
}
entry, err := stores.Bans.GetByIP("203.0.113.99")
if err != nil {
t.Fatalf("real client IP should be recorded: %v", err)
}
if entry.FailCount != 1 {
t.Fatalf("fail count = %d, want 1", entry.FailCount)
}
}
func TestLoopbackProxyXFFIsHonored(t *testing.T) {
ws, stores := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
// 模拟本机 Caddy/Nginx 转发:RemoteAddr 是回环,XFF 是真实客户端
doLoginFailure(t, ws, "127.0.0.1:5555", "198.51.100.7")
entry, err := stores.Bans.GetByIP("198.51.100.7")
if err != nil {
t.Fatalf("proxied client IP should be recorded: %v", err)
}
if entry.FailCount != 1 {
t.Fatalf("fail count = %d, want 1", entry.FailCount)
}
if _, err := stores.Bans.GetByIP("127.0.0.1"); err == nil {
t.Fatal("proxy's own IP should not be recorded")
}
}
+52 -14
View File
@@ -238,7 +238,7 @@ func main() {
}
// 7. Start SMTP server
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS)
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban)
go func() {
if err := smtpSrv.Start(); err != nil {
log.Printf("SMTP 服务启动失败: %v", err)
@@ -259,7 +259,7 @@ func main() {
}
// 7. Start IMAP server
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS)
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban)
go func() {
if err := imapSrv.Start(); err != nil {
log.Printf("IMAP 服务启动失败: %v", err)
@@ -275,7 +275,7 @@ func main() {
}
// 8. Start POP3 server
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS)
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban)
go func() {
if err := pop3Srv.Start(); err != nil {
log.Printf("POP3 服务启动失败: %v", err)
@@ -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 {
@@ -331,8 +334,18 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
fmt.Println("默认域名 example.com 创建成功")
}
// Hash the default admin password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
// 初始密码:优先取环境变量 MAILGO_ADMIN_PASSWORD
// 否则生成随机密码并打印一次(只能在本机启动日志中看到)。
// 无论哪种方式都会标记首次登录必须改密,杜绝默认口令。
adminPassword := os.Getenv("MAILGO_ADMIN_PASSWORD")
generated := false
if adminPassword == "" {
adminPassword = randomPassword()
generated = true
}
// Hash the admin password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("密码哈希失败: %v", err)
return
@@ -340,13 +353,14 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
// Create the admin user
adminUser := &db.User{
Username: "admin",
PasswordHash: string(hashedPassword),
DomainID: domain.ID,
QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
IsActive: true,
IsAdmin: true,
Username: "admin",
PasswordHash: string(hashedPassword),
DomainID: domain.ID,
QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
IsActive: true,
IsAdmin: true,
MustChangePassword: true,
}
if createErr := stores.Users.Create(adminUser); createErr != nil {
@@ -354,5 +368,29 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
return
}
fmt.Println("管理员账户 admin@example.com 创建成功(密码: admin")
if generated {
fmt.Printf("管理员账户 admin@example.com 创建成功,初始密码: %s\n", adminPassword)
} else {
fmt.Println("管理员账户 admin@example.com 创建成功(密码来自 MAILGO_ADMIN_PASSWORD")
}
fmt.Println("安全提示:该账户已被标记为“首次登录必须修改密码”,请登录后立即在 设置 页面修改。")
}
// randomPassword 生成 16 位随机密码(数字+大小写字母),用于初始管理员账户。
func randomPassword() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
// crypto/rand 失败极罕见;退化为时间种子以避免空密码
log.Printf("生成随机密码失败: %v,使用弱随机回退", err)
n := time.Now().UnixNano()
for i := range buf {
buf[i] = charset[(n>>(uint(i)*4))%int64(len(charset))]
}
return string(buf)
}
for i := range buf {
buf[i] = charset[int(buf[i])%len(charset)]
}
return string(buf)
}
+180
View File
@@ -0,0 +1,180 @@
# 安全漏洞修复 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 / 授权码注入)
- [x] 位置:`internal/web/handlers/auth.go`(原硬编码 `mailgo_oauth2_state``OAuth2Callback` 不校验 state
- 现状:当前部署未启用 OAuth2,属休眠漏洞,启用前必须修复。
- 修复方案:
- [x] `OAuth2Start``crypto/rand` 生成 16 字节随机 state,写入独立的短期 SameSite=Lax cookie`mail_go_oauth2_state`10 分钟过期,HttpOnly+Secure)。注意主会话 cookie 是 SameSite=Strict,跨站回调导航不会携带,故不能放主会话。
- [x] `OAuth2Callback` 读取 `c.Query("state")` 与 cookie 值做 `subtle.ConstantTimeCompare` 比对,缺失/不匹配返回 403。
- [x] 比对后立即清除 cookie`MaxAge=-1`),保证一次性使用。
- 验证:
- [x] 单测:state 缺失/不匹配/无 cookie 均 403start 设置的 cookie 与 URL state 一致且每次不同;有效 state 通过校验进入后续流程(`oauth2_state_test.go`)。
- [ ] 手工走完一次 OAuth2 流程(Google/GitHub)确认正常登录。
### 3. Gin 信任所有代理,`ClientIP()` 可伪造(封禁绕过 / 爆破)
- [x] 位置:`internal/web/server.go`(未调用 `SetTrustedProxies`
- 现状:gin 默认信任 0.0.0.0/0`X-Forwarded-For` 可任意伪造。线上 8080 端口当前被防火墙挡住,属纵深防御缺失;一旦 8080/socket 可达:伪造不同 IP 即可绕过登录失败封禁无限爆破,也可恶意封禁任意 IP 造成 DoS。
- 修复方案:
- [x] 统一 `engine.SetTrustedProxies([]string{"127.0.0.1", "::1"})`:外部直连时 XFF 完全不可信(防伪造/防封禁污染);本机 Caddy/Nginx 转发时 XFF 仍可信(保留真实客户端 IP)。注意 gin 对 Unix socket 监听无条件信任转发头,socket 必须保持仅本机可达。
- [ ] install.sh 文档注明:8080 端口必须保持仅本机可达(防火墙/绑定 127.0.0.1)。
- 验证:
- [x] 单测:外部直连 + 伪造 `X-Forwarded-For` 时封禁记录落在真实 IP 上;回环代理 + XFF 时记录 XFF 中的真实客户端 IP(`trustedproxy_test.go`)。
- [ ] 线上回归:Caddy 反代路径下管理后台封禁列表仍显示真实客户端 IP。
### 4. Web 写信 CRLF 邮件头注入
- [x] 位置:`internal/web/handlers/mail.go`(原 `to`/`cc`/`subject` 直接拼头、附件文件名拼进 `Content-Disposition`/`Content-Type`
- 现状:信封收件人经 `ParseAddress` 校验无法注入,但注入的头(如 `Reply-To`)会随邮件存储并外发,可被用于钓鱼。
- 修复方案:
- [x] 新增 `sanitizeHeaderField`strip `\r``\n`、NUL,应用于 From/To/Cc 头。
- [x] `subject``sanitizeHeaderField` + RFC 2047`mime.QEncoding`)编码非 ASCII 内容。
- [x] 附件名经 `mime.FormatMediaType` 生成 `Content-Disposition`/`Content-Type name` 参数(RFC 2231 编码,中和 CRLF 注入)。
- [x] 消息构建抽出为 `buildOutgoingMessage` 纯函数(可单测);`DownloadAttachment`/`AdminDownloadAttachment` 的响应头同步改用 `formatContentDisposition`
- 验证:
- [x] 单测:`to`/`cc`/`subject` 携带 CRLF 注入载荷时 RawData 无独立注入头;文件名含 CRLF/引号时头结构完好;非 ASCII 主题正确编码(`mail_injection_test.go`)。
- [ ] 含特殊字符附件名的邮件实测收发正常。
## P2 中危
### 5. 会话 Cookie 缺 Secure 标志
- [x] 位置:`internal/web/server.go`
- 修复方案:
- [x] `sessions.Options` 增加 `Secure: cfg.CookieSecure`;新增配置项 `[web].cookie_secure`(默认 true,仅本地 HTTP 调试时改 false;缺失字段按默认 true 处理,参照 relay_starttls 的原始文件检查)。
- [x] 修正 SameSite 注释(3 = Strict)。
- [x] 测试:会话 cookie 断言 HttpOnly+Secure+SameSite=Strict。
- 验证:
- [x] 测试断言 cookie 标志。
- [ ] 线上登录后检查 `Set-Cookie` 包含 `Secure; HttpOnly; SameSite=Strict`
### 6. SMTP/IMAP/POP3 认证无速率限制
- [x] 位置:`internal/smtp_server/server.go``internal/imap_server/``internal/pop3_server/server.go`
- 修复方案:
- [x] `store.RecordAuthFailure(ip, maxFail, minutes)`:认证失败计数复用 BanStore,达到 `ban.max_fail_attempts` 阈值即封禁 `ban.ban_duration_min` 分钟(与 Web 登录共用封禁记录)。
- [x] SMTP`NewSession` 记录 `c.Conn().RemoteAddr()` 提取 IP;Auth 回调失败计数 + 封禁 IP 拒绝认证。
- [x] IMAP`Login(connInfo,...)``connInfo.RemoteAddr` 取 IP;失败计数 + 封禁拒绝。
- [x] POP3`handleConn` 开头检查封禁直接拒绝;`handlePASS` 失败计数。
- [x] 三个服务器构造函数注入 `config.BanConfig`
- 验证:
- [x] store 层单测:达到阈值封禁、空 IP 无副作用、与 Web 共用封禁记录(`auth_guard_test.go`)。
- [ ] 线上用错误密码连续尝试触发封禁后,SMTP/IMAP/POP3 认证被拒。
### 7. 附件存储路径遍历防护无效
- [x] 位置:`internal/storage/attachment.go`
- 修复方案:
- [x] `FullPath` 改为白名单校验(UUID 文件名正则),非法路径返回错误;兜底校验最终路径仍在 baseDir 内。
- [x] `Save` 的扩展名白名单化(`safeExt`,丢弃 CR/LF、路径分隔符等)。
- 验证:
- [x] 单测:`../`、绝对路径、Windows 分隔符、空路径、注入文件名全部拒绝;合法文件名正常读写删(`attachment_test.go`)。
### 8. 默认管理员 admin@example.com/admin
- [x] 位置:`main.go``ensureAdminUser`
- 修复方案:
- [x] 初始密码改为:环境变量 `MAILGO_ADMIN_PASSWORD` 显式指定,否则生成 16 位随机密码打印一次。
- [x] User 模型新增 `MustChangePassword`:初始管理员、管理员重置密码的用户在登录后强制跳转设置页改密,改密后清除标记(`UpdatePassword` 顺带清除)。
- [x] AuthMiddleware 拦截(除 /settings、/logout),settings 页显示提示横幅。
- 验证:
- [x] 全新数据库启动后 admin/admin 无法登录(密码为随机值);登录后强制改密流程生效。
- [ ] 线上验证新装机流程。
### 9. Smarthost 中继 TLS 不验证证书(凭据可被 MITM 截获)
- [x] 位置:`internal/outbound/mailer.go`
- 修复方案:
- [x] 直投 MX 保持机会式 TLS`InsecureSkipVerify=true`,业界常规);relay 路径默认验证证书(`InsecureSkipVerify=false`),IP literal 时以 IP 作为 ServerName 校验 IP SAN。
- [x] 新增配置 `outbound.relay_tls_insecure`(默认 false),供自签证书内网中继显式放行。
- 验证:
- [x] 集成测试:自签证书 STARTTLS 中继默认握手失败(certificate 错误)、开启开关后完整 SMTP 流程成功(`mailer_test.go` 两个新测试)。
### 10. 缺安全响应头(点击劫持/降级风险)
- [x] 位置:`internal/web/middleware/security.go`(新中间件,全局注册)
- 修复方案:
- [x] `Strict-Transport-Security: max-age=31536000; includeSubDomains`
- [x] `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`(点击劫持)
- [x] `X-Content-Type-Options: nosniff`
- [x] `Referrer-Policy: strict-origin-when-cross-origin`
- [x] 基础 CSP`default-src 'self'` + 放宽项(内联脚本/样式必须 unsafe-inline`img-src https:` 允许邮件远程图片;`connect-src 'self'`/`form-action 'self'` 防数据外泄)。CSP 具体策略在 `security.go` 顶部注释说明。
- 验证:
- [x] 单测:5 个头均存在,关键值抽查(`security_test.go`)。
- [ ] 线上回归:登录/收件箱/管理页功能不受 CSP 影响;邮件远程图片正常加载。
### 11. LDAP/OAuth 错误信息泄露与用户枚举
- [x] 位置:`internal/web/handlers/auth.go`
- 修复方案:
- [x] 错误提示统一为通用文案("LDAP 认证失败…"、"LDAP 账号未接入本系统…"、"OAuth2 认证失败…"),原始 err 只写日志,不回显页面;不再在提示中回显用户邮箱。
- 验证:
- [x] 现有 OAuth2 测试仍通过(错误页文案不含内部细节)。
- [ ] 线上(启用 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. ~~#1P0~~ 已完成 2026-08-19
2. ~~#2、#3、#4P1~~ 已完成 2026-08-19
3. ~~#5-#11P2~~ 已完成 2026-08-19
4. 其余 P3 项随版本迭代