diff --git a/backend/internal/gopop/pop3.go b/backend/internal/gopop/pop3.go index 2755aa15..ffe7ac61 100644 --- a/backend/internal/gopop/pop3.go +++ b/backend/internal/gopop/pop3.go @@ -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) diff --git a/backend/internal/gopop/pop3_security_test.go b/backend/internal/gopop/pop3_security_test.go index 24618904..cbf1ace6 100644 --- a/backend/internal/gopop/pop3_security_test.go +++ b/backend/internal/gopop/pop3_security_test.go @@ -18,7 +18,8 @@ import ( ) type securityTestAction struct { - quitErr error + 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() }) diff --git a/backend/listen/pop3_server/action.go b/backend/listen/pop3_server/action.go index a73b010d..3cb827a7 100644 --- a/backend/listen/pop3_server/action.go +++ b/backend/listen/pop3_server/action.go @@ -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 客户端登录", diff --git a/backend/listen/pop3_server/capacity_test.go b/backend/listen/pop3_server/capacity_test.go index d417a73c..a51d3662 100644 --- a/backend/listen/pop3_server/capacity_test.go +++ b/backend/listen/pop3_server/capacity_test.go @@ -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) } } diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index e4343142..36a091b0 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -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 diff --git a/backend/listen/smtp_server/capacity_test.go b/backend/listen/smtp_server/capacity_test.go index 5d0ac2af..da9c0d37 100644 --- a/backend/listen/smtp_server/capacity_test.go +++ b/backend/listen/smtp_server/capacity_test.go @@ -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, "") +} diff --git a/test/tools/runtime_probe.py b/test/tools/runtime_probe.py index e13eb6b1..8d0220c5 100644 --- a/test/tools/runtime_probe.py +++ b/test/tools/runtime_probe.py @@ -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: diff --git a/test/tools/runtime_review.py b/test/tools/runtime_review.py index 482e7057..154717c6 100644 --- a/test/tools/runtime_review.py +++ b/test/tools/runtime_review.py @@ -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 + if (target / 'probe-result.json').exists(): + result['probe'] = json.loads((target / 'probe-result.json').read_text()) assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log' - result['probe'] = json.loads((target / 'probe-result.json').read_text()) 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