From 9b57c69c1ca7364a04c6d82d34b4bc72ee244200 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Tue, 28 Mar 2023 20:50:36 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + README.md | 2 - config/config.go | 4 +- config/doc.go | 10 ++++ http/admin.go | 6 +++ http/admin.html | 37 +++++++++++++++ http/adminapi.json | 25 ++++++++++ mox-/admin.go | 36 ++++++++++++++ prometheus.rules | 10 ++++ smtpserver/server.go | 71 ++++++++++++++++++++++++++++ smtpserver/server_test.go | 36 ++++++++++++++ store/account.go | 10 +++- testdata/smtp/sendlimit/domains.conf | 9 ++++ testdata/smtp/sendlimit/mox.conf | 9 ++++ 14 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 testdata/smtp/sendlimit/domains.conf create mode 100644 testdata/smtp/sendlimit/mox.conf diff --git a/.gitignore b/.gitignore index f1a45f5..77b66e5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /testdata/sent/ /testdata/smtp/data/ /testdata/smtp/datajunk/ +/testdata/smtp/sendlimit/data/ /testdata/store/data/ /testdata/train/ /cover.out diff --git a/README.md b/README.md index 6b05d5c..86e5a15 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/config.go b/config/config.go index 1a84bd7..d5cd51b 100644 --- a/config/config.go +++ b/config/config.go @@ -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:"-"` diff --git a/config/doc.go b/config/doc.go index c6b40d2..7b2757d 100644 --- a/config/doc.go +++ b/config/doc.go @@ -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: diff --git a/http/admin.go b/http/admin.go index 9fde851..faf09b0 100644 --- a/http/admin.go +++ b/http/admin.go @@ -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 { diff --git a/http/admin.html b/http/admin.html index 656b152..336e362 100644 --- a/http/admin.html +++ b/http/admin.html @@ -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( diff --git a/http/adminapi.json b/http/adminapi.json index 3edce5b..09249bd 100644 --- a/http/adminapi.json +++ b/http/adminapi.json @@ -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.", diff --git a/mox-/admin.go b/mox-/admin.go index efea37a..fb07ad8 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -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 { diff --git a/prometheus.rules b/prometheus.rules index 509aa34..f861833 100644 --- a/prometheus.rules +++ b/prometheus.rules @@ -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. diff --git a/smtpserver/server.go b/smtpserver/server.go index d05df65..f3dc82e 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -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() diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index c0b11ff..556a3da 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -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. +} diff --git a/store/account.go b/store/account.go index 5959109..ba1327c 100644 --- a/store/account.go +++ b/store/account.go @@ -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 } diff --git a/testdata/smtp/sendlimit/domains.conf b/testdata/smtp/sendlimit/domains.conf new file mode 100644 index 0000000..f5d5947 --- /dev/null +++ b/testdata/smtp/sendlimit/domains.conf @@ -0,0 +1,9 @@ +Domains: + mox.example: nil +Accounts: + mjl: + Domain: mox.example + Destinations: + mjl@mox.example: nil + MaxOutgoingMessagesPerDay: 4 + MaxFirstTimeRecipientsPerDay: 2 diff --git a/testdata/smtp/sendlimit/mox.conf b/testdata/smtp/sendlimit/mox.conf new file mode 100644 index 0000000..e1286db --- /dev/null +++ b/testdata/smtp/sendlimit/mox.conf @@ -0,0 +1,9 @@ +DataDir: data +User: 1000 +LogLevel: trace +Hostname: mox.example +Postmaster: + Account: mjl + Mailbox: postmaster +Listeners: + local: nil