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。
This commit is contained in:
@@ -85,6 +85,8 @@ addr = ":8080" # 监听地址,支持 TCP 端口或
|
|||||||
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
|
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
|
||||||
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
|
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
|
||||||
# 分别意味着会话可被伪造/所有登录态失效)
|
# 分别意味着会话可被伪造/所有登录态失效)
|
||||||
|
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
|
||||||
|
# 仅本地 HTTP 调试时才改为 false
|
||||||
|
|
||||||
[smtp]
|
[smtp]
|
||||||
addr = ":25" # SMTP 明文端口
|
addr = ":25" # SMTP 明文端口
|
||||||
@@ -143,6 +145,9 @@ relay_port = 587 # 465 = 隐式 TLS,其他端口按需
|
|||||||
relay_user = "" # 中继认证用户名(AUTH PLAIN)
|
relay_user = "" # 中继认证用户名(AUTH PLAIN)
|
||||||
relay_password = "" # 中继认证密码
|
relay_password = "" # 中继认证密码
|
||||||
relay_starttls = true # 非 465 端口是否使用 STARTTLS
|
relay_starttls = true # 非 465 端口是否使用 STARTTLS
|
||||||
|
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
|
||||||
|
# 默认验证证书(保护中继凭据),仅自签证书
|
||||||
|
# 内网中继且明确知晓风险时才改为 true
|
||||||
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
|
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
|
||||||
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
|
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
|
||||||
```
|
```
|
||||||
|
|||||||
+16
-3
@@ -32,6 +32,10 @@ type WebConfig struct {
|
|||||||
// 随机密钥并持久化到配置文件;也可通过环境变量 MAILGO_SECRET_KEY
|
// 随机密钥并持久化到配置文件;也可通过环境变量 MAILGO_SECRET_KEY
|
||||||
// 覆盖(覆盖值不落盘,适合容器部署)。
|
// 覆盖(覆盖值不落盘,适合容器部署)。
|
||||||
SecretKey string `toml:"secret_key"`
|
SecretKey string `toml:"secret_key"`
|
||||||
|
// CookieSecure 控制会话 cookie 是否仅通过 HTTPS 传输(Secure 标志)。
|
||||||
|
// 默认 true;仅当应用直接以 HTTP 提供服务(本地调试、内网明文)时
|
||||||
|
// 才应改为 false。
|
||||||
|
CookieSecure bool `toml:"cookie_secure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
|
// SecretKeyEnvVar 是覆盖会话签名密钥的环境变量名。
|
||||||
@@ -127,6 +131,10 @@ type OutboundConfig struct {
|
|||||||
RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN)
|
RelayUser string `toml:"relay_user"` // 中继认证用户名(AUTH PLAIN)
|
||||||
RelayPassword string `toml:"relay_password"` // 中继认证密码
|
RelayPassword string `toml:"relay_password"` // 中继认证密码
|
||||||
RelayStartTLS bool `toml:"relay_starttls"` // 非 465 端口是否使用 STARTTLS
|
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.
|
// IP family and source address binding for outbound connections.
|
||||||
IPFamily string `toml:"ip_family"` // ipv4(默认,PTR/SPF 最可靠)| ipv6 | auto
|
IPFamily string `toml:"ip_family"` // ipv4(默认,PTR/SPF 最可靠)| ipv6 | auto
|
||||||
@@ -189,7 +197,8 @@ func defaultConfig() *Config {
|
|||||||
AttachDir: filepath.Join(bd, "attachments"),
|
AttachDir: filepath.Join(bd, "attachments"),
|
||||||
},
|
},
|
||||||
Web: WebConfig{
|
Web: WebConfig{
|
||||||
Addr: DefaultWebPort,
|
Addr: DefaultWebPort,
|
||||||
|
CookieSecure: true,
|
||||||
},
|
},
|
||||||
SMTP: SMTPConfig{
|
SMTP: SMTPConfig{
|
||||||
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
|
Addr: fmt.Sprintf(":%d", DefaultSMTPPort),
|
||||||
@@ -435,11 +444,15 @@ func loadConfigFrom(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// relay_starttls defaults to true for safety; the raw file is checked
|
// relay_starttls 与 web.cookie_secure 默认值为 true for safety;
|
||||||
// because TOML decoding cannot distinguish an absent bool from false.
|
// the raw file is checked because TOML decoding cannot distinguish
|
||||||
|
// an absent bool from false.
|
||||||
if !strings.Contains(string(data), "relay_starttls") {
|
if !strings.Contains(string(data), "relay_starttls") {
|
||||||
cfg.Outbound.RelayStartTLS = defaults.Outbound.RelayStartTLS
|
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 {
|
if err := ensureSecretKey(cfg); err != nil {
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ type User struct {
|
|||||||
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// 用户)在首次登录后必须修改密码。
|
||||||
|
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.
|
// TableName specifies the table name for User.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mail_go/config"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
@@ -26,12 +27,22 @@ import (
|
|||||||
// imapBackend implements backend.Backend.
|
// imapBackend implements backend.Backend.
|
||||||
type imapBackend struct {
|
type imapBackend struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
|
banCfg config.BanConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login authenticates a user by email and password.
|
// Login authenticates a user by email and password.
|
||||||
func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
|
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)
|
user, err := b.stores.Users.Authenticate(username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries)
|
||||||
|
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin)
|
||||||
return nil, fmt.Errorf("invalid credentials: %w", err)
|
return nil, fmt.Errorf("invalid credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ import (
|
|||||||
type IMAPServer struct {
|
type IMAPServer struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
cfg config.IMAPConfig
|
cfg config.IMAPConfig
|
||||||
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// 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{
|
return &IMAPServer{
|
||||||
stores: stores,
|
stores: stores,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
banCfg: banCfg,
|
||||||
tlsLoader: tlsLoader,
|
tlsLoader: tlsLoader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
|
|||||||
|
|
||||||
// newServer creates a configured imapserver.Server with the given address.
|
// newServer creates a configured imapserver.Server with the given address.
|
||||||
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
|
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 := imapserver.New(be)
|
||||||
srv.Addr = addr
|
srv.Addr = addr
|
||||||
srv.TLSConfig = tlsConfig
|
srv.TLSConfig = tlsConfig
|
||||||
|
|||||||
+25
-13
@@ -49,11 +49,12 @@ func newPermError(format string, args ...interface{}) *DeliveryError {
|
|||||||
|
|
||||||
// RelayConfig describes a smarthost through which all external mail is sent.
|
// RelayConfig describes a smarthost through which all external mail is sent.
|
||||||
type RelayConfig struct {
|
type RelayConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port int // 465 = implicit TLS; other ports may use STARTTLS
|
Port int // 465 = implicit TLS; other ports may use STARTTLS
|
||||||
Username string // AUTH PLAIN credentials (empty = no authentication)
|
Username string // AUTH PLAIN credentials (empty = no authentication)
|
||||||
Password string
|
Password string
|
||||||
StartTLS bool // use STARTTLS on non-465 ports
|
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.
|
// 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.
|
// 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) {
|
func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
|
||||||
port := m.Relay.Port
|
port := m.Relay.Port
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
@@ -139,7 +142,8 @@ func (m *Mailer) deliverViaRelay(from, to string, data []byte) (string, error) {
|
|||||||
implicitTLS := port == 465
|
implicitTLS := port == 465
|
||||||
return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
|
return m.smtpTransaction(m.Relay.Host, port, implicitTLS,
|
||||||
m.Relay.StartTLS && !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.
|
// 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.
|
// 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) {
|
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,
|
// smtpTransaction performs one complete SMTP session: connect, greeting,
|
||||||
// optional implicit TLS / STARTTLS, optional AUTH PLAIN, MAIL/RCPT/DATA/QUIT.
|
// 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))
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), m.ConnectTimeout)
|
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
|
tlsServerName := host
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
tlsServerName = "" // no SNI for IP literals
|
// IP literal 同样作为 ServerName:证书验证模式下校验其 IP SAN
|
||||||
|
tlsServerName = ip.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if implicitTLS {
|
if implicitTLS {
|
||||||
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
if _, _, err := c.cmd(220, "STARTTLS"); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host)
|
tlsConn, err := tlsClientHandshake(ctx, conn, tlsServerName, host, tlsInsecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -354,10 +363,13 @@ func (m *Mailer) smtpTransaction(host string, port int, implicitTLS, startTLS bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tlsClientHandshake upgrades a plain connection to TLS.
|
// 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{
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
ServerName: serverName,
|
ServerName: serverName,
|
||||||
InsecureSkipVerify: true, // remote MX certificates often cannot be verified
|
InsecureSkipVerify: insecure,
|
||||||
})
|
})
|
||||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||||
return nil, newTempError("TLS handshake with %s failed: %v", host, err)
|
return nil, newTempError("TLS handshake with %s failed: %v", host, err)
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ package outbound
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -340,3 +348,172 @@ func TestMailerSmarthostRelay(t *testing.T) {
|
|||||||
t.Fatalf("relay data mismatch.\ngot: %q\nwant: %q", res.gotData, input)
|
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
|
||||||
|
}
|
||||||
@@ -64,11 +64,12 @@ func NewManager(cfg config.OutboundConfig, hostname string, stores *store.Stores
|
|||||||
|
|
||||||
if cfg.RelayHost != "" {
|
if cfg.RelayHost != "" {
|
||||||
m.mailer.Relay = &RelayConfig{
|
m.mailer.Relay = &RelayConfig{
|
||||||
Host: cfg.RelayHost,
|
Host: cfg.RelayHost,
|
||||||
Port: cfg.RelayPort,
|
Port: cfg.RelayPort,
|
||||||
Username: cfg.RelayUser,
|
Username: cfg.RelayUser,
|
||||||
Password: cfg.RelayPassword,
|
Password: cfg.RelayPassword,
|
||||||
StartTLS: cfg.RelayStartTLS,
|
StartTLS: cfg.RelayStartTLS,
|
||||||
|
TLSInsecure: cfg.RelayTLSInsecure,
|
||||||
}
|
}
|
||||||
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
|
log.Printf("outbound: using smarthost relay %s:%d", cfg.RelayHost, cfg.RelayPort)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,15 @@ type POP3Server struct {
|
|||||||
listener net.Listener
|
listener net.Listener
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
cfg config.POP3Config
|
cfg config.POP3Config
|
||||||
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// when TLS is not configured.
|
||||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader) *POP3Server {
|
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig) *POP3Server {
|
||||||
return &POP3Server{stores: stores, cfg: cfg, tlsLoader: tlsLoader}
|
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
|
||||||
@@ -107,6 +108,14 @@ func (s *POP3Server) handleConn(conn net.Conn) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
conn.SetDeadline(time.Now().Add(10 * time.Minute))
|
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)
|
reader := bufio.NewReader(conn)
|
||||||
var user *db.User
|
var user *db.User
|
||||||
var messages []pop3Message
|
var messages []pop3Message
|
||||||
@@ -272,8 +281,12 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
|
|||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientIP := store.ClientIPFromAddr(conn.RemoteAddr())
|
||||||
|
|
||||||
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries)
|
||||||
|
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin)
|
||||||
sendResponse(conn, "-ERR authentication failed")
|
sendResponse(conn, "-ERR authentication failed")
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,14 @@ type SMTPServer struct {
|
|||||||
storage *storage.AttachmentStorage
|
storage *storage.AttachmentStorage
|
||||||
outbound *outbound.Manager
|
outbound *outbound.Manager
|
||||||
cfg config.SMTPConfig
|
cfg config.SMTPConfig
|
||||||
|
banCfg config.BanConfig
|
||||||
tlsLoader *tlsutil.Loader
|
tlsLoader *tlsutil.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
||||||
// when TLS is not configured.
|
// when TLS is not configured.
|
||||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader) *SMTPServer {
|
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, tlsLoader: tlsLoader}
|
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
||||||
@@ -108,9 +109,10 @@ type smtpBackend struct {
|
|||||||
// NewSession creates a new SMTP session for the incoming connection.
|
// NewSession creates a new SMTP session for the incoming connection.
|
||||||
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||||
return &smtpSession{
|
return &smtpSession{
|
||||||
backend: be,
|
backend: be,
|
||||||
mode: be.mode,
|
mode: be.mode,
|
||||||
rcpts: make([]string, 0),
|
rcpts: make([]string, 0),
|
||||||
|
clientIP: store.ClientIPFromAddr(c.Conn().RemoteAddr()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ type smtpSession struct {
|
|||||||
userID uint
|
userID uint
|
||||||
email string
|
email string
|
||||||
user *db.User
|
user *db.User
|
||||||
|
clientIP string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
||||||
@@ -139,8 +142,19 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
|||||||
return nil, smtp.ErrAuthUnknownMechanism
|
return nil, smtp.ErrAuthUnknownMechanism
|
||||||
}
|
}
|
||||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
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)
|
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
||||||
if err != nil {
|
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
|
return smtp.ErrAuthFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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.
|
// AttachmentStorage handles file operations for email attachments on disk.
|
||||||
type AttachmentStorage struct {
|
type AttachmentStorage struct {
|
||||||
baseDir string // cfg.Storage.AttachDir
|
baseDir string // cfg.Storage.AttachDir
|
||||||
@@ -19,6 +24,24 @@ func NewAttachmentStorage(baseDir string) *AttachmentStorage {
|
|||||||
return &AttachmentStorage{baseDir: baseDir}
|
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.
|
// Save writes attachment data to disk and returns the relative file path.
|
||||||
// The filename is generated as {uuid}{ext} to avoid collisions.
|
// The filename is generated as {uuid}{ext} to avoid collisions.
|
||||||
func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
|
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)
|
return "", fmt.Errorf("创建附件目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique filename with the original extension
|
// Generate a unique filename with a sanitized extension
|
||||||
ext := filepath.Ext(filename)
|
ext := safeExt(filename)
|
||||||
uniqueName := uuid.New().String() + ext
|
uniqueName := uuid.New().String() + ext
|
||||||
|
|
||||||
fullPath := filepath.Join(s.baseDir, uniqueName)
|
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.
|
// Read reads attachment data from disk given a relative path.
|
||||||
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
|
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)
|
data, err := os.ReadFile(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取附件文件失败: %w", err)
|
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.
|
// Delete removes an attachment file from disk given a relative path.
|
||||||
func (s *AttachmentStorage) Delete(relPath string) error {
|
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) {
|
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("删除附件文件失败: %w", err)
|
return fmt.Errorf("删除附件文件失败: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullPath returns the absolute path for a given relative path.
|
// FullPath returns the absolute path for a relative path produced by Save.
|
||||||
func (s *AttachmentStorage) FullPath(relPath string) string {
|
// Paths that do not match the saved-file format (traversal attempts,
|
||||||
// Prevent directory traversal attacks
|
// absolute paths, unrelated names) are rejected with an error so they can
|
||||||
cleanRel := filepath.Clean(relPath)
|
// never escape baseDir.
|
||||||
if strings.HasPrefix(cleanRel, "..") {
|
func (s *AttachmentStorage) FullPath(relPath string) (string, error) {
|
||||||
cleanRel = strings.TrimPrefix(cleanRel, "../")
|
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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -124,9 +124,14 @@ func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
|
|||||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).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 {
|
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.
|
// ListAll retrieves a paginated list of all users across all domains.
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.PasswordHash = string(hashedPassword)
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
// 管理员重置的密码必须由用户本人修改后才能正常使用
|
||||||
|
user.MustChangePassword = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Users.Update(user); err != nil {
|
if err := h.stores.Users.Update(user); err != nil {
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
|
|
||||||
remaining := h.banCfg.MaxFailAttempts - failCount
|
remaining := h.banCfg.MaxFailAttempts - failCount
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
|
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
@@ -182,7 +182,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
user, err := h.stores.Users.GetByEmail(email)
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
|
"error": "LDAP 账号未接入本系统,请联系管理员",
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
@@ -308,7 +308,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("OAuth2 回调失败: %v", err)
|
log.Printf("OAuth2 回调失败: %v", err)
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
|
"error": "OAuth2 认证失败,请重试或联系管理员",
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
@@ -320,7 +320,7 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
user, err := h.stores.Users.GetByEmail(email)
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
|
"error": "OAuth2 账号未接入本系统,请联系管理员",
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ func (h *MailHandler) Settings(c *gin.Context) {
|
|||||||
"activeFolder": "settings",
|
"activeFolder": "settings",
|
||||||
"error": "",
|
"error": "",
|
||||||
"success": "",
|
"success": "",
|
||||||
|
"mustChange": c.Query("force") == "1",
|
||||||
"inboxUnread": inboxUnread,
|
"inboxUnread": inboxUnread,
|
||||||
"draftsTotal": draftsTotal,
|
"draftsTotal": draftsTotal,
|
||||||
"sentTotal": sentTotal,
|
"sentTotal": sentTotal,
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
|||||||
return
|
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("currentUser", user)
|
||||||
c.Set("userID", id)
|
c.Set("userID", id)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,6 +191,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
|||||||
cookieStore.Options(sessions.Options{
|
cookieStore.Options(sessions.Options{
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: 3, // SameSiteStrictMode(比 Lax 更严格)
|
SameSite: 3, // SameSiteStrictMode(比 Lax 更严格)
|
||||||
|
Secure: cfg.CookieSecure,
|
||||||
MaxAge: 86400,
|
MaxAge: 86400,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
@@ -226,6 +227,8 @@ func (ws *WebServer) registerRoutes() {
|
|||||||
|
|
||||||
// Apply BanMiddleware globally before public routes
|
// Apply BanMiddleware globally before public routes
|
||||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||||
|
// Security headers on every response
|
||||||
|
ws.engine.Use(middleware.SecurityHeaders())
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Public routes (no auth required)
|
||||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func newTestWebServer(t *testing.T, secretKey string) (*WebServer, *store.Stores
|
|||||||
|
|
||||||
baseDir := t.TempDir()
|
baseDir := t.TempDir()
|
||||||
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
|
attStorage := storage.NewAttachmentStorage(filepath.Join(baseDir, "attachments"))
|
||||||
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey}
|
cfg := config.WebConfig{Addr: "127.0.0.1:0", SecretKey: secretKey, CookieSecure: true}
|
||||||
|
|
||||||
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
ws, err := NewWebServer(cfg, stores, attStorage, config.StorageConfig{BaseDir: baseDir},
|
||||||
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil)
|
config.AuthConfig{}, config.BanConfig{MaxFailAttempts: 100}, config.CaddyConfig{}, nil)
|
||||||
@@ -104,6 +104,15 @@ func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
|
|||||||
for _, c := range resp.Cookies() {
|
for _, c := range resp.Cookies() {
|
||||||
if c.Name == "mail_go_session" {
|
if c.Name == "mail_go_session" {
|
||||||
sessionCookie = c.Value
|
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 == "" {
|
if sessionCookie == "" {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<main class="mail-main settings-main">
|
<main class="mail-main settings-main">
|
||||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
{{if .success}}<div class="alert alert-success">{{.success}}</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;">
|
<div class="card" style="max-width:720px;">
|
||||||
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
|
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Start SMTP server
|
// 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() {
|
go func() {
|
||||||
if err := smtpSrv.Start(); err != nil {
|
if err := smtpSrv.Start(); err != nil {
|
||||||
log.Printf("SMTP 服务启动失败: %v", err)
|
log.Printf("SMTP 服务启动失败: %v", err)
|
||||||
@@ -259,7 +259,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Start IMAP server
|
// 7. Start IMAP server
|
||||||
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS)
|
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban)
|
||||||
go func() {
|
go func() {
|
||||||
if err := imapSrv.Start(); err != nil {
|
if err := imapSrv.Start(); err != nil {
|
||||||
log.Printf("IMAP 服务启动失败: %v", err)
|
log.Printf("IMAP 服务启动失败: %v", err)
|
||||||
@@ -275,7 +275,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Start POP3 server
|
// 8. Start POP3 server
|
||||||
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS)
|
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban)
|
||||||
go func() {
|
go func() {
|
||||||
if err := pop3Srv.Start(); err != nil {
|
if err := pop3Srv.Start(); err != nil {
|
||||||
log.Printf("POP3 服务启动失败: %v", err)
|
log.Printf("POP3 服务启动失败: %v", err)
|
||||||
@@ -334,8 +334,18 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
|
|||||||
fmt.Println("默认域名 example.com 创建成功")
|
fmt.Println("默认域名 example.com 创建成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the default admin password
|
// 初始密码:优先取环境变量 MAILGO_ADMIN_PASSWORD;
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
// 否则生成随机密码并打印一次(只能在本机启动日志中看到)。
|
||||||
|
// 无论哪种方式都会标记首次登录必须改密,杜绝默认口令。
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Printf("密码哈希失败: %v", err)
|
log.Printf("密码哈希失败: %v", err)
|
||||||
return
|
return
|
||||||
@@ -343,13 +353,14 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
|
|||||||
|
|
||||||
// Create the admin user
|
// Create the admin user
|
||||||
adminUser := &db.User{
|
adminUser := &db.User{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
PasswordHash: string(hashedPassword),
|
PasswordHash: string(hashedPassword),
|
||||||
DomainID: domain.ID,
|
DomainID: domain.ID,
|
||||||
QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB
|
QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB
|
||||||
UsedBytes: 0,
|
UsedBytes: 0,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
|
MustChangePassword: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if createErr := stores.Users.Create(adminUser); createErr != nil {
|
if createErr := stores.Users.Create(adminUser); createErr != nil {
|
||||||
@@ -357,5 +368,29 @@ func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
|
|||||||
return
|
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)
|
||||||
}
|
}
|
||||||
+50
-38
@@ -63,65 +63,78 @@
|
|||||||
|
|
||||||
### 5. 会话 Cookie 缺 Secure 标志
|
### 5. 会话 Cookie 缺 Secure 标志
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/server.go:177-182`
|
- [x] 位置:`internal/web/server.go`
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] `sessions.Options` 增加 `Secure: true`。
|
- [x] `sessions.Options` 增加 `Secure: cfg.CookieSecure`;新增配置项 `[web].cookie_secure`(默认 true,仅本地 HTTP 调试时改 false;缺失字段按默认 true 处理,参照 relay_starttls 的原始文件检查)。
|
||||||
- [ ] 同时修正注释:当前 `SameSite: 3` 实为 Strict 而非注释所写的 Lax。
|
- [x] 修正 SameSite 注释(3 = Strict)。
|
||||||
- [ ] (可选)新增配置项允许本地 HTTP 调试时关闭 Secure。
|
- [x] 测试:会话 cookie 断言 HttpOnly+Secure+SameSite=Strict。
|
||||||
- 验证:线上登录后检查 `Set-Cookie` 包含 `Secure; HttpOnly; SameSite=Strict`。
|
- 验证:
|
||||||
|
- [x] 测试断言 cookie 标志。
|
||||||
|
- [ ] 线上登录后检查 `Set-Cookie` 包含 `Secure; HttpOnly; SameSite=Strict`。
|
||||||
|
|
||||||
### 6. SMTP/IMAP/POP3 认证无速率限制
|
### 6. SMTP/IMAP/POP3 认证无速率限制
|
||||||
|
|
||||||
- [ ] 位置:`internal/smtp_server/server.go`、`internal/imap_server/`、`internal/pop3_server/server.go`
|
- [x] 位置:`internal/smtp_server/server.go`、`internal/imap_server/`、`internal/pop3_server/server.go`
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] 认证失败计数复用 `BanStore`(按 `RemoteAddr` 提取 IP 记录 fail/ban)。
|
- [x] `store.RecordAuthFailure(ip, maxFail, minutes)`:认证失败计数复用 BanStore,达到 `ban.max_fail_attempts` 阈值即封禁 `ban.ban_duration_min` 分钟(与 Web 登录共用封禁记录)。
|
||||||
- [ ] go-smtp 可通过 `AuthHandler` 包一层计数;IMAP/POP3 在各自登录入口计数。
|
- [x] SMTP:`NewSession` 记录 `c.Conn().RemoteAddr()` 提取 IP;Auth 回调失败计数 + 封禁 IP 拒绝认证。
|
||||||
- [ ] 达到 `ban.max_fail_attempts` 后直接拒绝连接(SMTP 返回 421,IMAP/POP3 断开)。
|
- [x] IMAP:`Login(connInfo,...)` 从 `connInfo.RemoteAddr` 取 IP;失败计数 + 封禁拒绝。
|
||||||
- 验证:连续输错 N 次密码后,后续 AUTH 尝试被拒绝且管理后台封禁列表出现对应记录。
|
- [x] POP3:`handleConn` 开头检查封禁直接拒绝;`handlePASS` 失败计数。
|
||||||
|
- [x] 三个服务器构造函数注入 `config.BanConfig`。
|
||||||
|
- 验证:
|
||||||
|
- [x] store 层单测:达到阈值封禁、空 IP 无副作用、与 Web 共用封禁记录(`auth_guard_test.go`)。
|
||||||
|
- [ ] 线上用错误密码连续尝试触发封禁后,SMTP/IMAP/POP3 认证被拒。
|
||||||
|
|
||||||
### 7. 附件存储路径遍历防护无效
|
### 7. 附件存储路径遍历防护无效
|
||||||
|
|
||||||
- [ ] 位置:`internal/storage/attachment.go:62-68`
|
- [x] 位置:`internal/storage/attachment.go`
|
||||||
- 现状:`filepath.Clean("../../x")` 仍以 `..` 开头,`TrimPrefix` 只剥离一层 `../`;`../../../etc/passwd` 清洗后仍可逃逸。路径来自 DB,需配合 SQL 写权限才可利用,属纵深防御缺陷。
|
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] 改为白名单校验:`cleanRel` 必须匹配 `^[a-f0-9-]{36}(\.[A-Za-z0-9.]+)?$`(uuid 命名格式),否则返回错误。
|
- [x] `FullPath` 改为白名单校验(UUID 文件名正则),非法路径返回错误;兜底校验最终路径仍在 baseDir 内。
|
||||||
- [ ] 兜底再校验 `strings.HasPrefix(fullPath, s.baseDir + string(os.PathSeparator))`。
|
- [x] `Save` 的扩展名白名单化(`safeExt`,丢弃 CR/LF、路径分隔符等)。
|
||||||
- 验证:单测覆盖 `../`、`..\`(Windows)、绝对路径、符号链接名等用例,均应拒绝。
|
- 验证:
|
||||||
|
- [x] 单测:`../`、绝对路径、Windows 分隔符、空路径、注入文件名全部拒绝;合法文件名正常读写删(`attachment_test.go`)。
|
||||||
|
|
||||||
### 8. 默认管理员 admin@example.com/admin
|
### 8. 默认管理员 admin@example.com/admin
|
||||||
|
|
||||||
- [ ] 位置:`main.go:308-358`(`ensureAdminUser`)
|
- [x] 位置:`main.go`(`ensureAdminUser`)
|
||||||
- 现状:线上实测默认凭据**未生效**(管理员已修改),但新装机仍存在默认口令窗口。
|
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] 首次启动生成 16 位随机密码,打印一次并要求首次登录强制修改(User 模型加 `MustChangePassword bool`)。
|
- [x] 初始密码改为:环境变量 `MAILGO_ADMIN_PASSWORD` 显式指定,否则生成 16 位随机密码打印一次。
|
||||||
- [ ] 或支持环境变量 `MAILGO_ADMIN_PASSWORD` 由部署者显式指定。
|
- [x] User 模型新增 `MustChangePassword`:初始管理员、管理员重置密码的用户在登录后强制跳转设置页改密,改密后清除标记(`UpdatePassword` 顺带清除)。
|
||||||
- 验证:全新数据库启动后,用 admin/admin 无法登录。
|
- [x] AuthMiddleware 拦截(除 /settings、/logout),settings 页显示提示横幅。
|
||||||
|
- 验证:
|
||||||
|
- [x] 全新数据库启动后 admin/admin 无法登录(密码为随机值);登录后强制改密流程生效。
|
||||||
|
- [ ] 线上验证新装机流程。
|
||||||
|
|
||||||
### 9. Smarthost 中继 TLS 不验证证书(凭据可被 MITM 截获)
|
### 9. Smarthost 中继 TLS 不验证证书(凭据可被 MITM 截获)
|
||||||
|
|
||||||
- [ ] 位置:`internal/outbound/mailer.go:357-366`(`InsecureSkipVerify: true`)
|
- [x] 位置:`internal/outbound/mailer.go`
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] 区分两条路径:直投 MX 保持机会式 TLS(不验证,业界常规);relay 配置了用户名密码时默认验证证书(`ServerName` + 可选 `relay_tls_ca` pin 根证书),提供 `relay_tls_insecure` 开关逃生。
|
- [x] 直投 MX 保持机会式 TLS(`InsecureSkipVerify=true`,业界常规);relay 路径默认验证证书(`InsecureSkipVerify=false`),IP literal 时以 IP 作为 ServerName 校验 IP SAN。
|
||||||
- 验证:对自签证书 relay 测试:默认握手失败,开启开关后成功。
|
- [x] 新增配置 `outbound.relay_tls_insecure`(默认 false),供自签证书内网中继显式放行。
|
||||||
|
- 验证:
|
||||||
|
- [x] 集成测试:自签证书 STARTTLS 中继默认握手失败(certificate 错误)、开启开关后完整 SMTP 流程成功(`mailer_test.go` 两个新测试)。
|
||||||
|
|
||||||
### 10. 缺安全响应头(点击劫持/降级风险)
|
### 10. 缺安全响应头(点击劫持/降级风险)
|
||||||
|
|
||||||
- [ ] 位置:Caddy 层或 `internal/web/server.go` 全局中间件
|
- [x] 位置:`internal/web/middleware/security.go`(新中间件,全局注册)
|
||||||
- 修复方案(推荐 Caddy 统一加):
|
- 修复方案:
|
||||||
- [ ] `Strict-Transport-Security: max-age=31536000; includeSubDomains`
|
- [x] `Strict-Transport-Security: max-age=31536000; includeSubDomains`
|
||||||
- [ ] `X-Frame-Options: DENY`(或 CSP `frame-ancestors 'none'`)
|
- [x] `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`(点击劫持)
|
||||||
- [ ] `X-Content-Type-Options: nosniff`
|
- [x] `X-Content-Type-Options: nosniff`
|
||||||
- [ ] `Referrer-Policy: strict-origin-when-cross-origin`
|
- [x] `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
- [ ] 基础 CSP(注意 Gmail/管理页内联脚本较多,先从 `default-src 'self'` + 按需放宽起步)
|
- [x] 基础 CSP:`default-src 'self'` + 放宽项(内联脚本/样式必须 unsafe-inline;`img-src https:` 允许邮件远程图片;`connect-src 'self'`/`form-action 'self'` 防数据外泄)。CSP 具体策略在 `security.go` 顶部注释说明。
|
||||||
- 验证:`curl -sD - https://mail.lmve.net/login` 检查各头存在。
|
- 验证:
|
||||||
|
- [x] 单测:5 个头均存在,关键值抽查(`security_test.go`)。
|
||||||
|
- [ ] 线上回归:登录/收件箱/管理页功能不受 CSP 影响;邮件远程图片正常加载。
|
||||||
|
|
||||||
### 11. LDAP/OAuth 错误信息泄露与用户枚举
|
### 11. LDAP/OAuth 错误信息泄露与用户枚举
|
||||||
|
|
||||||
- [ ] 位置:`internal/web/handlers/auth.go:170,182,269`
|
- [x] 位置:`internal/web/handlers/auth.go`
|
||||||
- 修复方案:
|
- 修复方案:
|
||||||
- [ ] 错误提示统一为“认证失败”,内部细节只写日志,原始 `err` 不回显页面。
|
- [x] 错误提示统一为通用文案("LDAP 认证失败…"、"LDAP 账号未接入本系统…"、"OAuth2 认证失败…"),原始 err 只写日志,不回显页面;不再在提示中回显用户邮箱。
|
||||||
- [ ] “用户 %s 在系统中不存在”改为与密码错误相同的提示。
|
- 验证:
|
||||||
- 验证:LDAP/OAuth 登录失败时页面不含内部地址、DN、原始错误串。
|
- [x] 现有 OAuth2 测试仍通过(错误页文案不含内部细节)。
|
||||||
|
- [ ] 线上(启用 LDAP/OAuth 后)验证失败页面不含内部地址/DN/原始错误串。
|
||||||
|
|
||||||
## P3 低危 / 加固
|
## P3 低危 / 加固
|
||||||
|
|
||||||
@@ -163,6 +176,5 @@
|
|||||||
|
|
||||||
1. ~~#1(P0)~~ 已完成 2026-08-19
|
1. ~~#1(P0)~~ 已完成 2026-08-19
|
||||||
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
||||||
3. #5、#10(Cookie/安全头,部署层加固,改动小)
|
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||||
4. #6、#7、#9(协议与存储层)
|
4. 其余 P3 项随版本迭代
|
||||||
5. 其余 P3 项随版本迭代
|
|
||||||
Reference in New Issue
Block a user