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