1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 09:37:02 +00:00

Implement STLS for pop3 (#384)

This commit is contained in:
Benson Margulies
2023-09-05 14:28:26 -07:00
committed by GitHub
parent f1dadba1b2
commit 9f0fef3180
10 changed files with 426 additions and 29 deletions

7
.gitignore vendored
View File

@@ -3,6 +3,9 @@
*.a *.a
*.so *.so
# Emacs messiness.
*~
# Folders # Folders
_obj _obj
_test _test
@@ -58,3 +61,7 @@ repl-temp-*
# Test lua files # Test lua files
/inbucket.lua /inbucket.lua
# IntelliJ
.idea
inbucket.iml

View File

@@ -98,6 +98,10 @@ type POP3 struct {
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"` Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"` Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"` Debug bool `ignored:"true"`
TLSEnabled bool `default:"false" desc:"Enable TLS"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
} }
// Web contains the HTTP server configuration. // Web contains the HTTP server configuration.

View File

@@ -115,7 +115,6 @@ func decodedStringEquals(t *testing.T, json interface{}, path string, want strin
// is equivalent to the JavaScript: // is equivalent to the JavaScript:
// //
// o.users[1].name // o.users[1].name
//
func getDecodedPath(o interface{}, path ...string) (interface{}, string) { func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if len(path) == 0 { if len(path) == 0 {
return o, "" return o, ""

View File

@@ -61,7 +61,10 @@ func FullAssembly(conf *config.Root) (*Services, error) {
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter()) rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
webServer := web.NewServer(conf, mmanager, msgHub) webServer := web.NewServer(conf, mmanager, msgHub)
pop3Server := pop3.NewServer(conf.POP3, store) pop3Server, err := pop3.NewServer(conf.POP3, store)
if err != nil {
return nil, err
}
smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost) smtpServer := smtp.NewServer(conf.SMTP, mmanager, addrPolicy, extHost)
return &Services{ return &Services{

View File

@@ -2,6 +2,8 @@ package pop3
import ( import (
"bufio" "bufio"
"context"
"crypto/tls"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -53,6 +55,7 @@ var commands = map[string]bool{
"PASS": true, "PASS": true,
"APOP": true, "APOP": true,
"CAPA": true, "CAPA": true,
"STLS": true,
} }
// Session defines an active POP3 session // Session defines an active POP3 session
@@ -102,11 +105,32 @@ func (s *Session) String() string {
func (s *Server) startSession(id int, conn net.Conn) { func (s *Server) startSession(id int, conn net.Conn) {
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()). logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger() Int("session", id).Logger()
logger.Debug().Msgf("ForceTLS: %t", s.config.ForceTLS)
connToClose := conn
if s.config.ForceTLS {
logger.Debug().Msg("Setting up TLS for ForceTLS")
tlsConn := tls.Server(conn, s.tlsConfig)
toCtx, toCtxCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer toCtxCancel()
if err := tlsConn.HandshakeContext(toCtx); err != nil {
logger.Error().Msgf("TLS handshake failed: %v.", err)
conn.Close()
s.wg.Done()
return
}
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
conn = tlsConn
}
logger.Info().Msg("Starting POP3 session") logger.Info().Msg("Starting POP3 session")
defer func() { defer func() {
if err := conn.Close(); err != nil { logger.Debug().Msg("closing at end of session")
// Closing the tlsConn hangs.
if err := connToClose.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection") logger.Warn().Err(err).Msg("Closing connection")
} }
logger.Debug().Msg("End of session")
s.wg.Done() s.wg.Done()
}() }()
@@ -117,6 +141,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
// This is our command reading loop // This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil { for ssn.state != QUIT && ssn.sendError == nil {
line, err := ssn.readLine() line, err := ssn.readLine()
ssn.logger.Debug().Msgf("read %s", line)
if err == nil { if err == nil {
if cmd, arg, ok := ssn.parseCmd(line); ok { if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands // Check against valid SMTP commands
@@ -139,6 +164,9 @@ func (s *Server) startSession(id int, conn net.Conn) {
ssn.send("USER") ssn.send("USER")
ssn.send("UIDL") ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket") ssn.send("IMPLEMENTATION Inbucket")
if s.tlsConfig != nil && s.tlsState == nil && !s.config.ForceTLS {
ssn.send("STLS")
}
ssn.send(".") ssn.send(".")
continue continue
} }
@@ -193,7 +221,37 @@ func (s *Session) authorizationHandler(cmd string, args []string) {
switch cmd { switch cmd {
case "QUIT": case "QUIT":
s.send("+OK Goodnight and good luck") s.send("+OK Goodnight and good luck")
s.logger.Debug().Msg("Quitting.")
s.enterState(QUIT) s.enterState(QUIT)
case "STLS":
if !s.Server.config.TLSEnabled || s.Server.config.ForceTLS {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("-ERR TLS unavailable on the server")
s.send("-ERR TLS unavailable on the server")
s.ooSeq(cmd)
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("-ERR A TLS session already agreed upon.")
s.send("-ERR A TLS session already agreed upon.")
s.ooSeq(cmd)
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("+OK Begin TLS Negotiation")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
s.logger.Error().Msgf("-ERR TLS handshake failed %v", err)
s.ooSeq(cmd)
}
s.conn = tlsConn
s.reader = bufio.NewReader(tlsConn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.logger.Debug().Msgf("TLS set %v", *s.tlsState)
case "USER": case "USER":
if len(args) > 0 { if len(args) > 0 {
s.user = args[0] s.user = args[0]

View File

@@ -0,0 +1,305 @@
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/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
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.PrintfLine("QUIT")
_, _ = c.ReadLine()
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("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies := []string{}
for true {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
}
replies = append(replies, reply)
}
for _, r := range replies {
if r == "STLS" {
t.Errorf("TLS not enabled but received STLS.")
}
}
}
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.PrintfLine("QUIT")
_, _ = c.ReadLine()
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("CAPA"); err != nil {
t.Fatalf("Failed to send CAPA; %v.", err)
}
replies := []string{}
for true {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
}
replies = append(replies, reply)
}
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)
}
for true {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "." {
break
}
}
}
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.PrintfLine("QUIT")
_, _ = c.ReadLine()
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("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)
}
for true {
reply, err := c.ReadLine()
if err != nil {
t.Fatalf("Reading CAPA line failed %v", err)
}
if reply == "STLS" {
t.Errorf("STLS in CAPA in forceTLS mode.")
}
if reply == "." {
break
}
}
}
// 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,
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(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
}

View File

@@ -2,6 +2,8 @@ package pop3
import ( import (
"context" "context"
"crypto/tls"
"fmt"
"net" "net"
"sync" "sync"
"time" "time"
@@ -18,16 +20,34 @@ type Server struct {
listener net.Listener // TCP listener. listener net.Listener // TCP listener.
wg *sync.WaitGroup // Waitgroup tracking sessions. wg *sync.WaitGroup // Waitgroup tracking sessions.
notify chan error // Notify on fatal error. notify chan error // Notify on fatal error.
tlsConfig *tls.Config // TLS encryption configuration.
tlsState *tls.ConnectionState
} }
// NewServer creates a new, unstarted, POP3 server. // NewServer creates a new, unstarted, POP3 server.
func NewServer(pop3Config config.POP3, store storage.Store) *Server { func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{}
if pop3Config.TLSEnabled {
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
return nil, fmt.Errorf("Failed to configure TLS; %v", err)
// Do not silently turn off Security.
}
slog.Debug().Msg("TLS config available")
} else {
tlsConfig = nil
}
return &Server{ return &Server{
config: pop3Config, config: pop3Config,
store: store, store: store,
wg: new(sync.WaitGroup), wg: new(sync.WaitGroup),
notify: make(chan error, 1), notify: make(chan error, 1),
} tlsConfig: tlsConfig,
}, nil
} }
// Start the server and listen for connections // Start the server and listen for connections
@@ -110,6 +130,7 @@ func (s *Server) serve(ctx context.Context) {
// Drain causes the caller to block until all active POP3 sessions have finished // Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() { func (s *Server) Drain() {
// Wait for sessions to close // Wait for sessions to close
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("waiting for connections to complete.")
s.wg.Wait() s.wg.Wait()
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained") log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
} }

View File

@@ -2,8 +2,8 @@ package webui
import ( import (
"fmt" "fmt"
"os"
"net/http" "net/http"
"os"
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/server/web" "github.com/inbucket/inbucket/pkg/server/web"