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

add reject from origin domain feature (#375)

Add a new feature to be able to reject email *from* specific domains.

Co-authored-by: Cyril DUPONT <cyd@9bis.com>
This commit is contained in:
Cyd
2023-08-26 20:05:20 +02:00
committed by GitHub
parent 7c13a98ad2
commit 06ec140e72
9 changed files with 127 additions and 38 deletions

View File

@@ -74,21 +74,22 @@ type Lua struct {
// SMTP contains the SMTP server configuration. // SMTP contains the SMTP server configuration.
type SMTP struct { type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"` Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"` Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"` MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"` MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"` DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"` AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"` RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"` DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"` StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"` DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"` RejectOriginDomains []string `desc:"Domains to reject mail from"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"` Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"` TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"` TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
Debug bool `ignored:"true"` TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
} }
// POP3 contains the POP3 server configuration. // POP3 contains the POP3 server configuration.
@@ -128,6 +129,7 @@ func Process() (*Root, error) {
stringutil.SliceToLower(c.SMTP.RejectDomains) stringutil.SliceToLower(c.SMTP.RejectDomains)
stringutil.SliceToLower(c.SMTP.StoreDomains) stringutil.SliceToLower(c.SMTP.StoreDomains)
stringutil.SliceToLower(c.SMTP.DiscardDomains) stringutil.SliceToLower(c.SMTP.DiscardDomains)
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
return c, err return c, err
} }

View File

@@ -19,7 +19,7 @@ import (
type Manager interface { type Manager interface {
Deliver( Deliver(
to *policy.Recipient, to *policy.Recipient,
from string, from *policy.Origin,
recipients []*policy.Recipient, recipients []*policy.Recipient,
prefix string, prefix string,
content []byte, content []byte,
@@ -43,7 +43,7 @@ type StoreManager struct {
// Deliver submits a new message to the store. // Deliver submits a new message to the store.
func (s *StoreManager) Deliver( func (s *StoreManager) Deliver(
to *policy.Recipient, to *policy.Recipient,
from string, from *policy.Origin,
recipients []*policy.Recipient, recipients []*policy.Recipient,
prefix string, prefix string,
source []byte, source []byte,
@@ -56,7 +56,8 @@ func (s *StoreManager) Deliver(
} }
fromaddr, err := env.AddressList("From") fromaddr, err := env.AddressList("From")
if err != nil || len(fromaddr) == 0 { if err != nil || len(fromaddr) == 0 {
fromaddr = []*mail.Address{{Address: from}} fromaddr = make([]*mail.Address, 1)
fromaddr[0] = &from.Address
} }
toaddr, err := env.AddressList("To") toaddr, err := env.AddressList("To")
if err != nil { if err != nil {

View File

@@ -22,9 +22,10 @@ func TestManagerEmitsMessageStoredEvent(t *testing.T) {
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1) listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
// Attempt to deliver a message to generate event. // Attempt to deliver a message to generate event.
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
if _, err := sm.Deliver( if _, err := sm.Deliver(
&policy.Recipient{}, &policy.Recipient{},
"from@example.com", origin,
[]*policy.Recipient{}, []*policy.Recipient{},
"prefix", "prefix",
[]byte("From: from@example.com\n\ntest email"), []byte("From: from@example.com\n\ntest email"),

View File

@@ -71,6 +71,21 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
}, nil }, nil
} }
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
// not To headers.
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
}
return &Origin{
Address: mail.Address{Address: address},
addrPolicy: a,
LocalPart: local,
Domain: domain,
}, nil
}
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain. // ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
func (a *Addressing) ShouldAcceptDomain(domain string) bool { func (a *Addressing) ShouldAcceptDomain(domain string) bool {
domain = strings.ToLower(domain) domain = strings.ToLower(domain)
@@ -99,6 +114,12 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool {
return false return false
} }
// ShouldAcceptOriginDomain indicates if Inbucket accept mail from the specified domain.
func (a *Addressing) ShouldAcceptOriginDomain(domain string) bool {
domain = strings.ToLower(domain)
return !stringutil.SliceContains(a.Config.SMTP.RejectOriginDomains, domain)
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part. // ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines // An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696. // in RFC3696.

20
pkg/policy/origin.go Normal file
View File

@@ -0,0 +1,20 @@
package policy
import (
"net/mail"
)
// Origin represents a potential email origin, allows policies for it to be queried.
type Origin struct {
mail.Address
addrPolicy *Addressing
// LocalPart is the part of the address before @, including +extension.
LocalPart string
// Domain is the part of the address after @.
Domain string
}
// ShouldAccept returns true if Inbucket should accept mail from this origin.
func (o *Origin) ShouldAccept() bool {
return o.addrPolicy.ShouldAcceptOriginDomain(o.Domain)
}

View File

@@ -106,7 +106,7 @@ type Session struct {
sendError error // Last network send error. sendError error // Last network send error.
state State // Session state machine. state State // Session state machine.
reader *bufio.Reader // Buffered reading for TCP conn. reader *bufio.Reader // Buffered reading for TCP conn.
from string // Sender from MAIL command. from *policy.Origin // Sender from MAIL command.
recipients []*policy.Recipient // Recipients from RCPT commands. recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger. logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout. debug bool // Print network traffic to stdout.
@@ -384,7 +384,10 @@ func (s *Session) readyHandler(cmd string, arg string) {
return return
} }
from := m[1] from := m[1]
s.logger.Debug().Msgf("Mail sender is %v", from)
localpart, domain, err := policy.ParseEmailAddress(from) localpart, domain, err := policy.ParseEmailAddress(from)
s.logger.Debug().Msgf("Origin domain is %v", domain)
if from != "" && err != nil { if from != "" && err != nil {
s.send("501 Bad sender address syntax") s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err) s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
@@ -424,7 +427,19 @@ func (s *Session) readyHandler(cmd string, arg string) {
if extResult == nil || *extResult { if extResult == nil || *extResult {
// Permitted by extension, or none had an opinion. // Permitted by extension, or none had an opinion.
s.from = from origin, err := s.addrPolicy.ParseOrigin(from)
if err != nil {
s.send("501 Bad origin address syntax")
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
return
}
s.from = origin
if !s.from.ShouldAccept() {
s.send("501 Unauthorized domain")
s.logger.Warn().Msgf("Bad domain sender %s", domain)
return
}
s.logger.Info().Msgf("Mail from: %v", from) s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from)) s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL) s.enterState(MAIL)
@@ -601,6 +616,7 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) { func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
s.logger.Debug().Msgf("Line received: %v", line)
// Find length of command or entire line. // Find length of command or entire line.
hasArg := true hasArg := true
@@ -649,7 +665,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
func (s *Session) reset() { func (s *Session) reset() {
s.enterState(READY) s.enterState(READY)
s.from = "" s.from = nil
s.recipients = nil s.recipients = nil
} }

View File

@@ -92,7 +92,7 @@ func TestEmptyEnvelope(t *testing.T) {
// Test out some empty envelope without blanks // Test out some empty envelope without blanks
script := []scriptStep{ script := []scriptStep{
{"HELO localhost", 250}, {"HELO localhost", 250},
{"MAIL FROM:<>", 250}, {"MAIL FROM:<>", 501},
} }
if err := playSession(t, server, script); err != nil { if err := playSession(t, server, script); err != nil {
t.Error(err) t.Error(err)
@@ -101,7 +101,7 @@ func TestEmptyEnvelope(t *testing.T) {
// Test out some empty envelope with blanks // Test out some empty envelope with blanks
script = []scriptStep{ script = []scriptStep{
{"HELO localhost", 250}, {"HELO localhost", 250},
{"MAIL FROM: <>", 250}, {"MAIL FROM: <>", 501},
} }
if err := playSession(t, server, script); err != nil { if err := playSession(t, server, script); err != nil {
t.Error(err) t.Error(err)
@@ -198,6 +198,31 @@ func TestReadyStateValidCommands(t *testing.T) {
} }
} }
// Test invalid domains in READY state.
func TestReadyStateRejectedDomains(t *testing.T) {
ds := test.NewStore()
server := setupSMTPServer(ds, extension.NewHost())
tests := []scriptStep{
{"MAIL FROM: <john@validdomain.com>", 250},
{"MAIL FROM: <john@invalidomain.com>", 501},
}
for _, tc := range tests {
t.Run(tc.send, func(t *testing.T) {
defer server.Drain()
script := []scriptStep{
{"HELO localhost", 250},
tc,
{"QUIT", 221}}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
})
}
}
// Test invalid commands in READY state. // Test invalid commands in READY state.
func TestReadyStateInvalidCommands(t *testing.T) { func TestReadyStateInvalidCommands(t *testing.T) {
ds := test.NewStore() ds := test.NewStore()
@@ -557,6 +582,7 @@ func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
MaxMessageBytes: 5000, MaxMessageBytes: 5000,
DefaultAccept: true, DefaultAccept: true,
RejectDomains: []string{"deny.com"}, RejectDomains: []string{"deny.com"},
RejectOriginDomains: []string{"invalidomain.com"},
Timeout: 5, Timeout: 5,
}, },
} }

View File

@@ -36,13 +36,14 @@ func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err
POP3Listener: root.POP3.Addr, POP3Listener: root.POP3.Addr,
WebListener: root.Web.Addr, WebListener: root.Web.Addr,
SMTPConfig: jsonSMTPConfig{ SMTPConfig: jsonSMTPConfig{
Addr: root.SMTP.Addr, Addr: root.SMTP.Addr,
DefaultAccept: root.SMTP.DefaultAccept, DefaultAccept: root.SMTP.DefaultAccept,
AcceptDomains: root.SMTP.AcceptDomains, AcceptDomains: root.SMTP.AcceptDomains,
RejectDomains: root.SMTP.RejectDomains, RejectDomains: root.SMTP.RejectDomains,
DefaultStore: root.SMTP.DefaultStore, DefaultStore: root.SMTP.DefaultStore,
StoreDomains: root.SMTP.StoreDomains, StoreDomains: root.SMTP.StoreDomains,
DiscardDomains: root.SMTP.DiscardDomains, DiscardDomains: root.SMTP.DiscardDomains,
RejectOriginDomains: root.SMTP.RejectOriginDomains,
}, },
StorageConfig: jsonStorageConfig{ StorageConfig: jsonStorageConfig{
MailboxMsgCap: root.Storage.MailboxMsgCap, MailboxMsgCap: root.Storage.MailboxMsgCap,

View File

@@ -10,13 +10,14 @@ type jsonServerConfig struct {
} }
type jsonSMTPConfig struct { type jsonSMTPConfig struct {
Addr string `json:"addr"` Addr string `json:"addr"`
DefaultAccept bool `json:"default-accept"` DefaultAccept bool `json:"default-accept"`
AcceptDomains []string `json:"accept-domains"` AcceptDomains []string `json:"accept-domains"`
RejectDomains []string `json:"reject-domains"` RejectDomains []string `json:"reject-domains"`
DefaultStore bool `json:"default-store"` DefaultStore bool `json:"default-store"`
StoreDomains []string `json:"store-domains"` StoreDomains []string `json:"store-domains"`
DiscardDomains []string `json:"discard-domains"` DiscardDomains []string `json:"discard-domains"`
RejectOriginDomains []string `json:"reject-origin-domains"`
} }
type jsonStorageConfig struct { type jsonStorageConfig struct {