This commit is contained in:
parent
c52ea24663
commit
b47f366435
@ -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)
|
||||
|
||||
@ -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() })
|
||||
|
||||
@ -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 客户端登录",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, "")
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user