implement limits on outgoing messages for an account

by default 1000 messages per day, and to max 200 first-time receivers.
i don't think a person would reach those limits. a compromised account abused
by spammers could easily reach that limit. this prevents further damage.

the error message you will get is quite clear, pointing to the configuration
parameter that should be changed.
This commit is contained in:
Mechiel Lukkien 2023-03-28 20:50:36 +02:00
parent 9bd497b836
commit 9b57c69c1c
No known key found for this signature in database
14 changed files with 262 additions and 4 deletions

1
.gitignore vendored
View file

@ -15,6 +15,7 @@
/testdata/sent/
/testdata/smtp/data/
/testdata/smtp/datajunk/
/testdata/smtp/sendlimit/data/
/testdata/store/data/
/testdata/train/
/cover.out

View file

@ -108,8 +108,6 @@ The code is heavily cross-referenced with the RFCs for readability/maintainabili
## Roadmap
- Rate limiting and spam detection for submitted/outgoing messages, to reduce
impact when an account gets compromised.
- Privilege separation, isolating parts of the application to more restricted
sandbox (e.g. new unauthenticated connections).
- DANE and DNSSEC.

View file

@ -243,7 +243,9 @@ type Account struct {
NeutralMailboxRegexp string `sconf:"optional" sconf-doc:"Example: ^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects), and you may wish to add trash depending on how you use it, or leave this empty."`
NotJunkMailboxRegexp string `sconf:"optional" sconf-doc:"Example: .* or an empty string."`
} `sconf:"optional" sconf-doc:"Automatically set $Junk and $NotJunk flags based on mailbox messages are delivered/moved/copied to. Email clients typically have too limited functionality to conveniently set these flags, especially $NonJunk, but they can all move messages to a different mailbox, so this helps them."`
JunkFilter *JunkFilter `sconf:"optional" sconf-doc:"Content-based filtering, using the junk-status of individual messages to rank words in such messages as spam or ham. It is recommended you always set the applicable (non)-junk status on messages, and that you do not empty your Trash because those messages contain valuable ham/spam training information."` // todo: sane defaults for junkfilter
JunkFilter *JunkFilter `sconf:"optional" sconf-doc:"Content-based filtering, using the junk-status of individual messages to rank words in such messages as spam or ham. It is recommended you always set the applicable (non)-junk status on messages, and that you do not empty your Trash because those messages contain valuable ham/spam training information."` // todo: sane defaults for junkfilter
MaxOutgoingMessagesPerDay int `sconf:"optional" sconf-doc:"Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000."`
MaxFirstTimeRecipientsPerDay int `sconf:"optional" sconf-doc:"Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200."`
DNSDomain dns.Domain `sconf:"-"` // Parsed form of Domain.
JunkMailbox *regexp.Regexp `sconf:"-" json:"-"`

View file

@ -594,6 +594,16 @@ describe-static" and "mox config describe-domains":
# in calculating probability reduced. E.g. 1 or 2. (optional)
RareWords: 0
# Maximum number of outgoing messages for this account in a 24 hour window. This
# limits the damage to recipients and the reputation of this mail server in case
# of account compromise. Default 1000. (optional)
MaxOutgoingMessagesPerDay: 0
# Maximum number of first-time recipients in outgoing messages for this account in
# a 24 hour window. This limits the damage to recipients and the reputation of
# this mail server in case of account compromise. Default 200. (optional)
MaxFirstTimeRecipientsPerDay: 0
# Redirect all requests from domain (key) to domain (value). Always redirects to
# HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect. (optional)
WebDomainRedirects:

View file

@ -1481,6 +1481,12 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) {
xcheckf(ctx, err, "setting password")
}
// SetAccountLimits set new limits on outgoing messages for an account.
func (Admin) SetAccountLimits(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int) {
err := mox.AccountLimitsSave(ctx, accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay)
xcheckf(ctx, err, "saving account limits")
}
// ClientConfigDomain returns configurations for email clients, IMAP and
// Submission (SMTP) for the domain.
func (Admin) ClientConfigDomain(ctx context.Context, domain string) mox.ClientConfig {

View file

@ -476,6 +476,7 @@ const account = async (name) => {
const config = await api.Account(name)
let form, fieldset, email
let formSendlimits, fieldsetSendlimits, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay
let formPassword, fieldsetPassword, password, passwordHint
const page = document.getElementById('page')
@ -576,6 +577,42 @@ const account = async (name) => {
),
),
dom.br(),
dom.h2('Send limits'),
formSendlimits=dom.form(
fieldsetSendlimits=dom.fieldset(
dom.label(
style({display: 'inline-block'}),
dom.span('Maximum outgoing messages per day', attr({title: 'Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.'})),
dom.br(),
maxOutgoingMessagesPerDay=dom.input(attr({type: 'number', required: '', value: config.MaxOutgoingMessagesPerDay || 1000})),
),
' ',
dom.label(
style({display: 'inline-block'}),
dom.span('Maximum first-time recipients per day', attr({title: 'Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.'})),
dom.br(),
maxFirstTimeRecipientsPerDay=dom.input(attr({type: 'number', required: '', value: config.MaxFirstTimeRecipientsPerDay || 200})),
),
' ',
dom.button('Save'),
),
async function submit(e) {
e.stopPropagation()
e.preventDefault()
fieldsetSendlimits.disabled = true
try {
await api.SetAccountLimits(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0)
window.alert('Send limits saved.')
} catch (err) {
console.log({err})
window.alert('Error: ' + err.message)
return
} finally {
fieldsetSendlimits.disabled = false
}
},
),
dom.br(),
dom.h2('Set new password'),
formPassword=dom.form(
fieldsetPassword=dom.fieldset(

View file

@ -518,6 +518,31 @@
],
"Returns": []
},
{
"Name": "SetAccountLimits",
"Docs": "SetAccountLimits set new limits on outgoing messages for an account.",
"Params": [
{
"Name": "accountName",
"Typewords": [
"string"
]
},
{
"Name": "maxOutgoingMessagesPerDay",
"Typewords": [
"int32"
]
},
{
"Name": "maxFirstTimeRecipientsPerDay",
"Typewords": [
"int32"
]
}
],
"Returns": []
},
{
"Name": "ClientConfigDomain",
"Docs": "ClientConfigDomain returns configurations for email clients, IMAP and\nSubmission (SMTP) for the domain.",

View file

@ -790,6 +790,42 @@ func DestinationSave(ctx context.Context, account, destName string, newDest conf
return nil
}
// AccountLimitsSave saves new message sending limits for an account.
func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("saving account limits", rerr, mlog.Field("account", account))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
acc, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("account not present")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Accounts = map[string]config.Account{}
for name, a := range c.Accounts {
nc.Accounts[name] = a
}
acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay
acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay
nc.Accounts[account] = acc
if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("account limits saved", mlog.Field("account", account))
return nil
}
// ClientConfig holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfig struct {

View file

@ -28,6 +28,16 @@ groups:
annotations:
summary: http 5xx responses from webserver
- alert: mox-submission-errors
expr: increase(mox_smtpserver_submission_total{result=~".*error"}[1h]) > 0
annotations:
summary: smtp submission errors
- alert: mox-delivery-errors
expr: increase(mox_smtpserver_delivery_total{result=~".*error"}[1h]) > 0
annotations:
summary: smtp delivery errors
# the alerts below can be used to keep a closer eye or when starting to use mox,
# but can be noisy, or you may not be able to prevent them.

View file

@ -1669,6 +1669,71 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
msgPrefix = append(msgPrefix, "Date: "+time.Now().Format(message.RFC5322Z)+"\r\n"...)
}
// Limit damage to the internet and our reputation in case of account compromise by
// limiting the max number of messages sent in a 24 hour window, both total number
// of messages and number of first-time recipients.
err = c.account.DB.Read(func(tx *bstore.Tx) error {
conf, _ := c.account.Conf()
msgmax := conf.MaxOutgoingMessagesPerDay
if msgmax == 0 {
// For human senders, 1000 recipients in a day is quite a lot.
msgmax = 1000
}
rcptmax := conf.MaxFirstTimeRecipientsPerDay
if rcptmax == 0 {
// Human senders may address a new human-sized list of people once in a while. In
// case of a compromise, a spammer will probably try to send to many new addresses.
rcptmax = 200
}
rcpts := map[string]time.Time{}
n := 0
err := bstore.QueryTx[store.Outgoing](tx).FilterGreater("Submitted", time.Now().Add(-24*time.Hour)).ForEach(func(o store.Outgoing) error {
n++
if rcpts[o.Recipient].IsZero() || o.Submitted.Before(rcpts[o.Recipient]) {
rcpts[o.Recipient] = o.Submitted
}
return nil
})
xcheckf(err, "querying message recipients in past 24h")
if n+len(c.recipients) > msgmax {
metricSubmission.WithLabelValues("messagelimiterror").Inc()
xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of messages (%d) over past 24h reached, try increasing per-account setting MaxOutgoingMessagesPerDay", msgmax)
}
// Only check if max first-time recipients is reached if there are enough messages
// to trigger the limit.
if n+len(c.recipients) < rcptmax {
return nil
}
isFirstTime := func(rcpt string, before time.Time) bool {
exists, err := bstore.QueryTx[store.Outgoing](tx).FilterNonzero(store.Outgoing{Recipient: rcpt}).FilterLess("Submitted", before).Exists()
xcheckf(err, "checking in database whether recipient is first-time")
return !exists
}
firsttime := 0
now := time.Now()
for _, rcptAcc := range c.recipients {
r := rcptAcc.rcptTo
if isFirstTime(r.XString(true), now) {
firsttime++
}
}
for r, t := range rcpts {
if isFirstTime(r, t) {
firsttime++
}
}
if firsttime > rcptmax {
metricSubmission.WithLabelValues("recipientlimiterror").Inc()
xsmtpUserErrorf(smtp.C451LocalErr, smtp.SePol7DeliveryUnauth1, "max number of new/first-time recipients (%d) over past 24h reached, try increasing per-account setting MaxFirstTimeRecipientsPerDay", rcptmax)
}
return nil
})
xcheckf(err, "read-only transaction")
// todo future: in a pedantic mode, we can parse the headers, and return an error if rcpt is only in To or Cc header, and not in the non-empty Bcc header. indicates a client that doesn't blind those bcc's.
// Add DKIM signatures.
@ -1763,6 +1828,9 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
}
metricSubmission.WithLabelValues("ok").Inc()
c.log.Info("submitted message delivered", mlog.Field("mailfrom", *c.mailFrom), mlog.Field("rcptto", rcptAcc.rcptTo), mlog.Field("smtputf8", c.smtputf8), mlog.Field("msgsize", msgSize))
err := c.account.DB.Insert(&store.Outgoing{Recipient: rcptAcc.rcptTo.XString(true)})
xcheckf(err, "adding outgoing message")
}
})
@ -1794,6 +1862,9 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
}
metricSubmission.WithLabelValues("ok").Inc()
c.log.Info("message queued for delivery", mlog.Field("mailfrom", *c.mailFrom), mlog.Field("rcptto", rcptAcc.rcptTo), mlog.Field("smtputf8", c.smtputf8), mlog.Field("msgsize", msgSize))
err := c.account.DB.Insert(&store.Outgoing{Recipient: rcptAcc.rcptTo.XString(true)})
xcheckf(err, "adding outgoing message")
}
}
err = dataFile.Close()

View file

@ -922,3 +922,39 @@ func TestNonSMTP(t *testing.T) {
t.Fatalf("connection not closed after bogus command")
}
}
// Test limits on outgoing messages.
func TestLimitOutgoing(t *testing.T) {
ts := newTestServer(t, "../testdata/smtp/sendlimit/mox.conf", dns.MockResolver{})
defer ts.close()
ts.user = "mjl@mox.example"
ts.pass = "testtest"
ts.submission = true
err := ts.acc.DB.Insert(&store.Outgoing{Recipient: "a@other.example", Submitted: time.Now().Add(-24*time.Hour - time.Minute)})
tcheck(t, err, "inserting outgoing/recipient past 24h window")
testSubmit := func(rcptTo string, expErr *smtpclient.Error) {
t.Helper()
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
if err == nil {
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false)
}
var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
t.Fatalf("got err %#v, expected %#v", err, expErr)
}
})
}
// Limits are set to 4 messages a day, 2 first-time recipients.
testSubmit("b@other.example", nil)
testSubmit("c@other.example", nil)
testSubmit("d@other.example", &smtpclient.Error{Code: smtp.C451LocalErr, Secode: smtp.SePol7DeliveryUnauth1}) // Would be 3rd recipient.
testSubmit("b@other.example", nil)
testSubmit("b@other.example", nil)
testSubmit("b@other.example", &smtpclient.Error{Code: smtp.C451LocalErr, Secode: smtp.SePol7DeliveryUnauth1}) // Would be 5th message.
}

View file

@ -370,6 +370,14 @@ type Recipient struct {
Sent time.Time `bstore:"nonzero"`
}
// Outgoing is a message submitted for delivery from the queue. Used to enforce
// maximum outgoing messages.
type Outgoing struct {
ID int64
Recipient string `bstore:"nonzero,index"` // Canonical international address with utf8 domain.
Submitted time.Time `bstore:"nonzero,default now"`
}
// Account holds the information about a user, includings mailboxes, messages, imap subscriptions.
type Account struct {
Name string // Name, according to configuration.
@ -455,7 +463,7 @@ func openAccount(name string) (a *Account, rerr error) {
os.MkdirAll(dir, 0770)
}
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Password{}, Subjectpass{})
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Outgoing{}, Password{}, Subjectpass{})
if err != nil {
return nil, err
}

9
testdata/smtp/sendlimit/domains.conf vendored Normal file
View file

@ -0,0 +1,9 @@
Domains:
mox.example: nil
Accounts:
mjl:
Domain: mox.example
Destinations:
mjl@mox.example: nil
MaxOutgoingMessagesPerDay: 4
MaxFirstTimeRecipientsPerDay: 2

9
testdata/smtp/sendlimit/mox.conf vendored Normal file
View file

@ -0,0 +1,9 @@
DataDir: data
User: 1000
LogLevel: trace
Hostname: mox.example
Postmaster:
Account: mjl
Mailbox: postmaster
Listeners:
local: nil