1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 21:52:31 +00:00
Files
go-inbucket/pkg/server/pop3/handler_test.go
T
Antoine Deschênes 9876bb2bd9 fix(pop3): track STLS state per session instead of per server (#626)
The TLS connection state lived on the shared POP3 Server, and Session
reached it through its embedded *Server.  Once any client completed
STLS, every later session saw a non-nil state: CAPA stopped advertising
STLS and the STLS command returned "-ERR A TLS session already agreed
upon." until the server restarted.  Sessions also wrote the shared field
concurrently without synchronization.

Move tlsState to Session.  ForceTLS sessions set it when the session
starts, and STLS sets it on the negotiating session only.

TestSTLSPerSession runs CAPA, STLS and a TLS handshake on two sessions of
one server; it fails against the previous code on the second session.

Signed-off-by: Antoine Deschênes <antoine@antoinedeschenes.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:51:32 -07:00

465 lines
12 KiB
Go

package pop3
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/textproto"
"os"
"path"
"strings"
"testing"
"time"
"github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/inbucket/inbucket/v3/pkg/test"
"github.com/stretchr/testify/require"
)
func TestNoTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, false, false)
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("TLS not enabled but received STLS.")
}
}
}
func TestSTLSWithTLSDisabled(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, false, false)
pipe := setupPOPSession(t, server)
_ = pipe.SetDeadline(time.Now().Add(10 * time.Second))
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Errorf("STLS should have errored: %s", reply)
}
}
func TestStartTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, false)
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
sawTLS := false
for _, r := range replies {
if r == "STLS" {
sawTLS = true
}
}
if !sawTLS {
t.Errorf("TLS enabled but no STLS capability.")
}
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("STLS failed: %s", reply)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c = textproto.NewConn(tlsConn)
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("CAPA failed: %s", reply)
}
_, err = c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
}
func TestDupStartTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, false)
pipe := setupPOPSession(t, server)
_ = pipe.SetDeadline(time.Now().Add(10 * time.Second))
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response includes STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
sawTLS := false
for _, r := range replies {
if r == "STLS" {
sawTLS = true
}
}
if !sawTLS {
t.Errorf("TLS enabled but no STLS capability.")
}
t.Log("Sending first STLS command, expected to succeed")
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("STLS failed: %s", reply)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c = textproto.NewConn(tlsConn)
t.Log("Sending second STLS command, expected to fail")
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Fatalf("STLS failed: %s", reply)
}
// Send STAT to verify handler has not crashed.
if err := c.PrintfLine("STAT"); err != nil {
t.Fatalf("Failed to send STAT; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STAT reply line failed %v", err)
}
if !strings.HasPrefix(reply, "-ERR") {
t.Fatalf("STAT failed: %s", reply)
}
}
// TestSTLSPerSession verifies that STLS state belongs to each session: a
// completed STLS negotiation must not affect later sessions on the same server.
func TestSTLSPerSession(t *testing.T) {
server := setupPOPServer(t, test.NewStore(), true, false)
for i := 1; i <= 2; i++ {
serverConn, clientConn := net.Pipe()
startPOP3Session(t, server, &mockConn{serverConn})
t.Cleanup(func() { _ = clientConn.Close() })
c := textproto.NewConn(clientConn)
readPOP3Greeting(t, c)
playPOP3Script(t, c, []scriptStep{
{"CAPA", []string{"+OK Capability list follows", "TOP", "USER", "UIDL", "IMPLEMENTATION Inbucket", "STLS", "."}},
{"STLS", []string{"+OK Begin TLS Negotiation"}},
})
tlsConn := tls.Client(clientConn, &tls.Config{InsecureSkipVerify: true})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
require.NoErrorf(t, tlsConn.HandshakeContext(ctx), "session %d: TLS handshake", i)
cancel()
playPOP3Script(t, textproto.NewConn(tlsConn), []scriptStep{
{"QUIT", []string{"+OK Goodnight and good luck"}},
})
}
}
func TestForceTLS(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, true)
pipe := setupPOPSession(t, server)
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
}
tlsConn := tls.Client(pipe, tlsConfig)
ctx, toCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
t.Fatalf("TLS handshake failed; %v", err)
}
c := textproto.NewConn(tlsConn)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
// Verify CAPA response does not include STLS.
if err := c.PrintfLine("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies, err := c.ReadDotLines()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("STLS in CAPA in forceTLS mode.")
}
}
}
// RFC 2595 §4: after the +OK response to STLS, a TLS negotiation begins
// immediately and the client must not issue further commands until it
// completes. A failed negotiation is therefore fatal to the session: the
// server must not keep the session running on the dead connection.
func TestSTLSHandshakeFailure(t *testing.T) {
ds := test.NewStore()
server := setupPOPServer(t, ds, true, false)
pipe := setupPOPSession(t, server)
c := textproto.NewConn(pipe)
defer func() {
_ = c.Close()
server.Drain()
}()
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading initial line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("Initial line is not +OK")
}
if err := c.PrintfLine("STLS"); err != nil {
t.Fatalf("Failed to send STLS; %v.", err)
}
reply, err = c.ReadLine()
if err != nil {
t.Fatalf("Reading STLS reply line failed %v", err)
}
if !strings.HasPrefix(reply, "+OK") {
t.Fatalf("STLS failed: %s", reply)
}
// Send bytes that cannot begin a TLS handshake.
if _, err := pipe.Write([]byte("this is not a TLS handshake\r\n")); err != nil {
t.Fatalf("Failed to write garbage; %v.", err)
}
// The server must terminate the session without further responses.
_, err = c.ReadLine()
if err == nil {
t.Fatal("Expected connection close after failed handshake")
}
}
// net.Pipe does not implement deadlines
type mockConn struct {
net.Conn
}
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupPOPServer(t *testing.T, ds storage.Store, tls bool, forceTLS bool) *Server {
t.Helper()
cfg := config.POP3{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
Debug: true,
ForceTLS: forceTLS,
}
if tls {
cert, privKey, err := generateCertificate(t)
if err != nil {
t.Fatalf("Failed to generate x.509 certificate; %v", err)
}
// we have to write these things into files.
cfg.TLSEnabled = true
td := t.TempDir()
certPath := path.Join(td, "cert.pem")
keyPath := path.Join(td, "key.pem")
if err := os.WriteFile(certPath, certToPem(cert), 0700); err != nil {
t.Fatalf("Failed to write cert PEM file; %v", err)
}
if err := os.WriteFile(keyPath, privKeyToPem(privKey), 0700); err != nil {
t.Fatalf("Failed to write privKey PEM file; %v", err)
}
cfg.TLSCert = certPath
cfg.TLSPrivKey = keyPath
}
s, err := NewServer(cfg, ds)
if err != nil {
t.Fatalf("Failed to create server: %v.", err)
}
return s
}
var sessionNum int
func setupPOPSession(t *testing.T, server *Server) net.Conn {
t.Helper()
serverConn, clientConn := net.Pipe()
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(context.Background(), sessionNum, &mockConn{serverConn})
return clientConn
}
func privKeyToPem(privkey *rsa.PrivateKey) []byte {
privkeyBytes := x509.MarshalPKCS1PrivateKey(privkey)
return pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privkeyBytes,
},
)
}
func certToPem(cert []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})
}
func generateCertificate(t *testing.T) ([]byte, *rsa.PrivateKey, error) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
t.Fatalf("Failed to generate key; %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "localhost.local",
},
DNSNames: []string{"localhost", "127.0.0.1", "inbucket.local"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageEmailProtection},
}
cert, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("certificate generation failed; %v", err)
}
return cert, priv, nil
}