修复邮件协议认证错误分类并补齐真实响应验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 13:05:39 +08:00
parent c52ea24663
commit b47f366435
8 changed files with 166 additions and 14 deletions

View File

@ -17,6 +17,9 @@ const maxPOP3CommandLineBytes = 8 << 10
var errPOP3CommandLineTooLong = errors.New("POP3 command line exceeds the allowed size")
// ErrAuthenticationTemporary 仅用于映射固定的临时失败响应,不回显内部错误。
var ErrAuthenticationTemporary = errors.New("[SYS/TEMP] Authentication temporarily unavailable")
// Server Pop3服务实例
type Server struct {
Domain string // 域名
@ -215,7 +218,9 @@ func (s *Server) handleClient(conn net.Conn) {
case "PASS":
password := getSafeArg(args, 0)
cmdError = s.Action.Pass(session, password)
if cmdError != nil {
if errors.Is(cmdError, ErrAuthenticationTemporary) {
fmt.Fprint(conn, "-ERR [SYS/TEMP] Authentication temporarily unavailable"+eol)
} else if cmdError != nil {
fmt.Fprint(conn, "-ERR Password incorrect!"+eol)
} else {
fmt.Fprint(conn, "+OK User signed in"+eol)

View File

@ -19,6 +19,7 @@ import (
type securityTestAction struct {
quitErr error
passErrors []error
}
func (a *securityTestAction) User(session *Session, username string) error {
@ -27,6 +28,13 @@ func (a *securityTestAction) User(session *Session, username string) error {
}
func (a *securityTestAction) Pass(session *Session, password string) error {
if len(a.passErrors) > 0 {
err := a.passErrors[0]
a.passErrors = a.passErrors[1:]
if err != nil {
return err
}
}
session.Status = TRANSACTION
return nil
}
@ -100,6 +108,49 @@ func TestQuitReportsMailboxUpdateResult(t *testing.T) {
}
}
func TestPassReportsTemporaryFailureWithoutLeakingInternalErrors(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
if err := clientConn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatal(err)
}
server := &Server{Domain: "test", Action: &securityTestAction{passErrors: []error{
ErrAuthenticationTemporary,
fmt.Errorf("private database detail: %w", ErrAuthenticationTemporary),
errors.New("[SYS/TEMP] untrusted detail\r\n+OK injected"),
errors.New("private credential detail"),
}}}
done := make(chan struct{})
go func() {
server.handleClient(serverConn)
close(done)
}()
reader := bufio.NewReader(clientConn)
readResponseLine(t, reader, "+OK")
writeCommand(t, clientConn, "USER user")
readResponseLine(t, reader, "+OK")
for _, want := range []string{
"-ERR [SYS/TEMP] Authentication temporarily unavailable\r\n",
"-ERR [SYS/TEMP] Authentication temporarily unavailable\r\n",
"-ERR Password incorrect!\r\n",
"-ERR Password incorrect!\r\n",
"+OK User signed in\r\n",
} {
writeCommand(t, clientConn, "PASS test-password")
line, err := reader.ReadString('\n')
if err != nil || line != want {
t.Fatalf("POP3 认证响应错误:%q,预期 %q,错误 %v", line, want, err)
}
}
writeCommand(t, clientConn, "QUIT")
readResponseLine(t, reader, "+OK Bye")
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("POP3 恢复认证后未正常退出")
}
}
func TestSTLSUpgradesConnectionBeforeAuthentication(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })

View File

@ -107,7 +107,7 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
user, err := auth.AuthenticateProtocol("pop3", remoteAddress, session.User, pwd)
if err != nil {
if errors2.Is(err, password.ErrBusy) {
return errors2.New("[SYS/TEMP] Authentication temporarily unavailable")
return gopop.ErrAuthenticationTemporary
}
log.WithContext(session.Ctx).WithFields(log.Fields{
"action": "POP3 客户端登录",

View File

@ -1,7 +1,7 @@
package pop3_server
import (
"strings"
"errors"
"testing"
"git.pcux.cn/xuans/Smail/config"
@ -21,7 +21,7 @@ func TestPOP3BusyReturnsTemporaryAuthenticationFailure(t *testing.T) {
release := passwordtest.Hold(t)
for index := 0; index < 12; index++ {
err := (action{}).Pass(session, code)
if err == nil || !strings.HasPrefix(err.Error(), "[SYS/TEMP]") || session.Status == gopop.TRANSACTION || ctx.UserID != 0 {
if !errors.Is(err, gopop.ErrAuthenticationTemporary) || session.Status == gopop.TRANSACTION || ctx.UserID != 0 {
t.Fatalf("POP3 繁忙响应或会话状态错误:%v", err)
}
}

View File

@ -73,7 +73,7 @@ func (s *Session) AuthPlain(username, pwd string) error {
"result": "failure",
"object": s.RemoteAddress.String(),
}).Info("SMTP 客户端认证失败")
return errors.New("password error")
return &smtp.SMTPError{Code: 535, EnhancedCode: smtp.EnhancedCode{5, 7, 8}, Message: "Authentication credentials invalid"}
}
s.User = user.Account

View File

@ -1,9 +1,15 @@
package smtp_server
import (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"net"
"net/textproto"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
@ -31,3 +37,65 @@ func TestSMTPBusyReturnsTemporaryAuthenticationFailure(t *testing.T) {
t.Fatalf("SMTP 繁忙恢复后无法认证:%v", err)
}
}
func TestSMTPAuthenticationWireSeparatesBusyAndInvalidCredentials(t *testing.T) {
engine, _, code := newSMTPProtocolAuthFixture(t)
oldDB, oldConfig := db.Instance, config.Instance
db.Instance, config.Instance = engine, &config.Config{Domain: "example.com"}
t.Cleanup(func() { db.Instance, config.Instance = oldDB, oldConfig })
certificate := newTestCertificate(t)
server := smtp.NewServer(&Backend{})
server.Domain = "localhost"
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() { _ = server.Close() })
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(20 * time.Second)); err != nil {
t.Fatal(err)
}
plain := textproto.NewConn(conn)
if _, _, err := plain.ReadResponse(220); err != nil {
t.Fatal(err)
}
command := func(client *textproto.Conn, line string, status int, prefix string) {
t.Helper()
if err := client.PrintfLine("%s", line); err != nil {
t.Fatal(err)
}
_, message, err := client.ReadResponse(status)
if err != nil || !strings.HasPrefix(message, prefix) {
t.Fatalf("SMTP 响应错误:%q,预期 %d %q,错误 %v", message, status, prefix, err)
}
}
command(plain, "EHLO test.example", 250, "")
command(plain, "STARTTLS", 220, "")
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(leaf)
tlsConn := tls.Client(conn, &tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12})
if err := tlsConn.Handshake(); err != nil {
t.Fatal(err)
}
secure := textproto.NewConn(tlsConn)
command(secure, "EHLO test.example", 250, "")
authLine := func(password string) string {
return "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00alice@example.com\x00"+password))
}
command(secure, authLine("invalid-code"), 535, "5.7.8 Authentication credentials invalid")
release := passwordtest.Hold(t)
command(secure, authLine(code), 454, "4.7.0 Temporary authentication failure")
release()
command(secure, authLine(code), 235, "2.0.0")
command(secure, "QUIT", 221, "")
}

View File

@ -193,6 +193,13 @@ def main():
codes[protocol] = client.success(base + 'generate', {'protocol': protocol})['code']
assert protocol_operation(protocol, context, codes[protocol])() == 'success'
result['protocol_login'] = list(codes)
with smtp_connect(context) as smtp:
try:
smtp.auth('PLAIN', lambda challenge=None: '\0' + ADDRESS + '\0invalid-code')
raise AssertionError('SMTP 错误凭据未被拒绝')
except smtplib.SMTPAuthenticationError as error:
assert error.smtp_code == 535 and error.smtp_error.startswith(b'5.7.8 ')
result['smtp_invalid_credentials'] = 535
result['stage'] = 'smtp'
message = EmailMessage()
message['From'] = 'probe@runtime.invalid'
@ -218,7 +225,10 @@ def main():
result['smtp'] = {'message_id': message_id, 'stored': True, 'owners': 1, 'attachment': True}
result['stage'] = 'overload'
rounds = []
result['overload'] = rounds
for protocol in codes:
round_result = {'protocol': protocol, 'responses': [], 'recovered': False}
rounds.append(round_result)
operations = [protocol_operation(protocol, context, codes[protocol]) for _ in range(4)]
barrier = threading.Barrier(8, timeout=20)
@ -236,14 +246,16 @@ def main():
return {'outcome': operation(), 'seconds': round(time.monotonic() - began, 3)}
with ThreadPoolExecutor(max_workers=8) as pool:
responses = list(pool.map(execute, operations + [http_operation] * 4))
for response in pool.map(execute, operations + [http_operation] * 4):
round_result['responses'].append(response)
responses = round_result['responses']
assert any(item['outcome'] == 'busy' for item in responses[:4]), protocol + ' 未观察到繁忙响应'
assert any(item['outcome'] == 'busy' for item in responses[4:]), 'HTTP 未观察到繁忙响应'
assert any(item['outcome'] == 'success' for item in responses), '所有请求均失败'
assert protocol_operation(protocol, context, codes[protocol])() == 'success'
assert client.success('/api/v1/login', login).get('account') == ACCOUNT
rounds.append({'protocol': protocol, 'responses': responses, 'recovered': True})
result['overload'] = rounds
round_result['recovered'] = True
(root.parent / 'probe-result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
result['stage'] = 'complete'
result['passed'] = True
finally:

View File

@ -23,12 +23,24 @@ def service_state():
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--main-sha', required=True)
parser.add_argument('--binary', type=Path, default=Path('/opt/smail/smail'))
args = parser.parse_args()
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
os.umask(0o027)
live = Path('/opt/smail')
with (live / 'smail').open('rb') as stream:
binary = args.binary.resolve(strict=True)
assert binary == args.binary and binary.is_file()
if binary != live / 'smail':
assert binary.parent.parent == Path('/var/tmp') and binary.parent.name.startswith('smail-review-')
assert binary.stat().st_uid == 0 and binary.stat().st_mode & 0o022 == 0
assert binary.parent.stat().st_uid == 0 and binary.parent.stat().st_mode & 0o022 == 0
with binary.open('rb') as stream:
assert hashlib.file_digest(stream, 'sha256').hexdigest() == args.main_sha
stream.seek(0)
header = stream.read(20)
assert header[:5] == b'\x7fELF\x02' and header[18:20] == b'\x3e\x00'
with (live / 'smail').open('rb') as stream:
live_sha = hashlib.file_digest(stream, 'sha256').hexdigest()
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
assert int(memory['MemAvailable'].split()[0]) >= 272 * 1024, '可用内存不足,暂停隔离验收'
stamp = time.strftime('%Y%m%d-%H%M%S')
@ -40,7 +52,7 @@ def main():
for directory in (root, control, root / 'ssl'):
directory.mkdir(mode=0o750)
shutil.chown(directory, user='smail', group='smail')
shutil.copy2(live / 'smail', root / 'smail')
shutil.copy2(binary, root / 'smail')
(root / 'smail').chmod(0o755)
shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py')
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2',
@ -56,7 +68,8 @@ def main():
before = service_state()
unit = 'smail-runtime-review-' + stamp
probe_unit = 'smail-runtime-probe-' + stamp
result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha, 'before': before}
result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha,
'source': str(binary), 'live_sha256': live_sha, 'before': before}
started = time.strftime('%Y-%m-%dT%H:%M:%S')
try:
subprocess.run([
@ -94,8 +107,9 @@ def main():
], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
(target / 'probe.log').write_text(probe.stdout, encoding='utf-8')
result['probe_code'] = probe.returncode
assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log'
if (target / 'probe-result.json').exists():
result['probe'] = json.loads((target / 'probe-result.json').read_text())
assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log'
assert result['probe']['passed']
with sqlite3.connect(f'file:{live / "data/smail.db"}?mode=ro', uri=True) as database:
message_id = result['probe']['smtp']['message_id']
@ -107,7 +121,9 @@ def main():
subprocess.run(['systemctl', 'stop', unit], check=True)
subprocess.run(['systemctl', 'stop', probe_unit], check=False, stderr=subprocess.DEVNULL)
result['after'] = service_state()
result['live_unchanged'] = result['after'] == before
with (live / 'smail').open('rb') as stream:
result['live_unchanged'] = (result['after'] == before
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
result['integrity'] = {}
for name in ('smail.db', 'smail-state.db'):
path = root / 'data' / name