mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
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:
parent
9bd497b836
commit
9b57c69c1c
14 changed files with 262 additions and 4 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,6 +15,7 @@
|
|||
/testdata/sent/
|
||||
/testdata/smtp/data/
|
||||
/testdata/smtp/datajunk/
|
||||
/testdata/smtp/sendlimit/data/
|
||||
/testdata/store/data/
|
||||
/testdata/train/
|
||||
/cover.out
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:"-"`
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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
9
testdata/smtp/sendlimit/domains.conf
vendored
Normal 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
9
testdata/smtp/sendlimit/mox.conf
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
DataDir: data
|
||||
User: 1000
|
||||
LogLevel: trace
|
||||
Hostname: mox.example
|
||||
Postmaster:
|
||||
Account: mjl
|
||||
Mailbox: postmaster
|
||||
Listeners:
|
||||
local: nil
|
Loading…
Reference in a new issue