收紧邮件协议认证并加固环境文件
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-15 23:18:15 +08:00
parent 8d09a1ad49
commit 038f425f2d
7 changed files with 47 additions and 17 deletions

View File

@ -113,6 +113,16 @@ func TestSmailServiceUsesOnlyBindServiceCapability(t *testing.T) {
t.Fatalf("smail unit is missing %q", required)
}
}
usesEnvironmentFile := false
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == "EnvironmentFile=/etc/smail/smail.env" {
usesEnvironmentFile = true
break
}
}
if !usesEnvironmentFile {
t.Fatal("smail unit must define EnvironmentFile as a standalone directive")
}
if strings.Contains(content, "User=root") {
t.Fatal("smail unit must not run as root")
}

View File

@ -150,6 +150,7 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T
if err != nil {
t.Fatal(err)
}
protocolCodes := make(map[string]string)
for _, protocol := range []string{"smtp", "imap", "pop3"} {
if err := service.SetProtocolEnabled(user.ID, protocol, true); err != nil {
t.Fatalf("enable %s: %v", protocol, err)
@ -158,6 +159,7 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T
if err != nil {
t.Fatalf("generate %s code: %v", protocol, err)
}
protocolCodes[protocol] = code
got, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", " Alice@Example.com ", code)
if err != nil {
t.Fatalf("%s full email authentication failed: %v", protocol, err)
@ -170,10 +172,10 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T
}
}
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice", "CorrectP12"); err == nil {
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice", protocolCodes["imap"]); err == nil {
t.Fatal("account-only protocol login should be rejected")
}
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice@other.example", "CorrectP12"); err == nil {
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice@other.example", protocolCodes["imap"]); err == nil {
t.Fatal("unconfigured email domain should be rejected")
}
}

View File

@ -52,6 +52,20 @@ func NormalizeIdentifierKey(identifier string) (string, error) {
return normalized, err
}
// NormalizeProtocolIdentifier 仅接受属于已配置邮箱域名的完整地址,供 SMTP、IMAP、POP3 客户端认证使用。
// Web 登录仍可使用短账号或已验证的外部邮箱,不能复用到邮件协议认证。
func NormalizeProtocolIdentifier(identifier string) (string, error) {
normalized, kind, err := parseLoginIdentifier(identifier)
if err != nil || kind != IdentifierEmail {
return "", errors.New("invalid identifier")
}
parts := strings.SplitN(normalized, "@", 2)
if len(parts) != 2 || !isConfiguredLoginDomain(parts[1]) {
return "", errors.New("invalid identifier")
}
return normalized, nil
}
// ResolveIdentifier 将账号或已验证邮箱绑定解析为唯一用户身份。
func ResolveIdentifier(identifier string) (ResolvedIdentity, error) {
normalized, kind, err := parseLoginIdentifier(identifier)

View File

@ -93,7 +93,6 @@ func TestNormalizeIdentifierKeepsLegacyAccountCompatibility(t *testing.T) {
}
}
func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
oldConfig := config.Instance
oldDB := db.Instance
@ -106,6 +105,14 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
if err := db.Init(""); err != nil {
t.Fatal(err)
}
if got, err := NormalizeProtocolIdentifier(" Bob@Solit.cn "); err != nil || got != "bob@solit.cn" {
t.Fatalf("normalized protocol identifier = %q, err = %v", got, err)
}
for _, identifier := range []string{"bob", "bob@other.com"} {
if _, err := NormalizeProtocolIdentifier(identifier); err == nil {
t.Fatalf("protocol identifier %q was accepted", identifier)
}
}
t.Cleanup(func() {
if db.Instance != nil && db.Instance != oldDB {
_ = db.Instance.Close()
@ -141,4 +148,4 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
if _, err := ResolveIdentifier("bob@other.com"); err == nil {
t.Fatal("unconfigured domain must not be resolved")
}
}
}

View File

@ -1,4 +1,4 @@
package auth
package auth
import (
"crypto/sha256"
@ -46,7 +46,7 @@ var (
func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) {
normalizedProtocol, protocolErr := credentials.NormalizeProtocol(protocol)
normalizedAccount := strings.ToLower(strings.TrimSpace(account))
if normalizedIdentifier, err := NormalizeIdentifierKey(account); err == nil {
if normalizedIdentifier, err := NormalizeProtocolIdentifier(account); err == nil {
normalizedAccount = normalizedIdentifier
}
normalizedRemote := protocolRemoteHost(remoteAddress)
@ -67,19 +67,15 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
return fail()
}
identity, err := ResolveIdentifier(account)
protocolIdentifier, err := NormalizeProtocolIdentifier(account)
if err != nil {
return fail()
}
identity, err := ResolveIdentifier(protocolIdentifier)
if err != nil {
return fail()
}
userID := identity.UserID
if identity.Kind == IdentifierAccount {
var u models.User
has, err := db.Instance.Where("account = ? AND disabled = 0", identity.Account).Get(&u)
if err != nil || !has || u.ID <= 0 {
return fail()
}
userID = u.ID
}
if userID <= 0 {
return fail()
}

View File

@ -84,9 +84,9 @@ if [ ! -e /etc/smail/smail.env ]; then
# 只创建默认根目录;已存在的文件始终保留部署人员的自定义环境配置。
umask 0077
printf '%s\n' 'SMAIL_ROOT=/opt/smail' > /etc/smail/smail.env
chown root:root /etc/smail/smail.env
chmod 0600 /etc/smail/smail.env
fi
chown root:root /etc/smail/smail.env
chmod 0600 /etc/smail/smail.env
for directory in config data ssl log plugins; do
target="$runtime_dir/$directory"
# 内置插件在临时沙箱中展开,只兼容已存在的历史插件目录。

View File

@ -7,6 +7,7 @@ Wants=network-online.target
Type=simple
User=smail
Group=smail
EnvironmentFile=/etc/smail/smail.env
# 运行环境仅由 root 可读写的独立文件提供,避免在 unit 中混入部署环境配置。
EnvironmentFile=/etc/smail/smail.env
WorkingDirectory=/opt/smail