slow down connections for spammy deliveries, and too many failed authentications, and sleep for 15 seconds before delivering messages by first-time senders

similar to greylisting, but not quite the same: with greylisting you would
always reject the first delivery attempt with a temporary failure. with the
hope that spammers won't retry their deliveries. the spams i've been receiving
seem to be quite consistent though. and we would keep rejecting them anyway.

we slow down the spammy connections to waste some of the resources of a
spammer. this may slow their campaigns down a bit, leaving a bit more time to
take measures.

we do the same with connections that have their 3rd authentication failure,
typically password guess attempts.

when we accept a message by a first-time sender, we sleep for 15 seconds before
actually delivering them. known-good senders don't have to wait. if the message
turns out to be a spammer, at least we've consumed one of their connections,
and they cannot deliver at too high a rate to us because of the max open
connection limit.
This commit is contained in:
Mechiel Lukkien 2023-02-08 21:45:32 +01:00
parent 6623cb435a
commit 9419ee15dd
No known key found for this signature in database
5 changed files with 166 additions and 35 deletions

View file

@ -16,9 +16,9 @@ Mox features:
- Reputation tracking, learning (per user) host- and domain-based reputation from - Reputation tracking, learning (per user) host- and domain-based reputation from
(Non-)Junk/Non-Junk email. (Non-)Junk/Non-Junk email.
- Bayesian spam filtering that learns (per user) from (Non-)Junk email. - Bayesian spam filtering that learns (per user) from (Non-)Junk email.
- Greylisting of servers with no/low reputation and questionable email content. - Slowing down senders with no/low reputation or questionable email content
Temporarily refused emails are available over IMAP in a special mailbox for a (similar to greylisting). Rejected emails are stored in a mailbox called Rejects
short period, helping with misclassified legimate synchronous for a short period, helping with misclassified legimate synchronous
signup/login/transactional emails. signup/login/transactional emails.
- Internationalized email, with unicode names in domains and usernames - Internationalized email, with unicode names in domains and usernames
("localparts"). ("localparts").

View file

@ -130,6 +130,10 @@ func limitersInit() {
} }
} }
// Delay before reads and after 1-byte writes for probably spammers. Tests set this
// to zero.
var badClientDelay = time.Second
// Capabilities (extensions) the server supports. Connections will add a few more, e.g. STARTTLS, LOGINDISABLED, AUTH=PLAIN. // Capabilities (extensions) the server supports. Connections will add a few more, e.g. STARTTLS, LOGINDISABLED, AUTH=PLAIN.
// ENABLE: ../rfc/5161 // ENABLE: ../rfc/5161
// LITERAL+: ../rfc/7888 // LITERAL+: ../rfc/7888
@ -163,6 +167,7 @@ type conn struct {
bw *bufio.Writer // To remote, with TLS added in case of TLS. bw *bufio.Writer // To remote, with TLS added in case of TLS.
tr *moxio.TraceReader // Kept to change trace level when reading/writing cmd/auth/data. tr *moxio.TraceReader // Kept to change trace level when reading/writing cmd/auth/data.
tw *moxio.TraceWriter tw *moxio.TraceWriter
slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy.
lastlog time.Time // For printing time since previous log line. lastlog time.Time // For printing time since previous log line.
tlsConfig *tls.Config // TLS config to use for handshake. tlsConfig *tls.Config // TLS config to use for handshake.
remoteIP net.IP remoteIP net.IP
@ -182,9 +187,10 @@ type conn struct {
searchResult []store.UID searchResult []store.UID
// Only when authenticated. // Only when authenticated.
username string // Full username as used during login. authFailed int // Number of failed auth attempts. For slowing down remote with many failures.
account *store.Account username string // Full username as used during login.
comm *store.Comm // For sending/receiving changes on mailboxes in account, e.g. from messages incoming on smtp, or another imap client. account *store.Account
comm *store.Comm // For sending/receiving changes on mailboxes in account, e.g. from messages incoming on smtp, or another imap client.
mailboxID int64 // Only for StateSelected. mailboxID int64 // Only for StateSelected.
readonly bool // If opened mailbox is readonly. readonly bool // If opened mailbox is readonly.
@ -376,18 +382,40 @@ func (c *conn) unselect() {
c.uids = nil c.uids = nil
} }
func (c *conn) setSlow(on bool) {
if on && !c.slow {
c.log.Debug("connection changed to slow")
} else if !on && c.slow {
c.log.Debug("connection restored to regular pace")
}
c.slow = on
}
// Write makes a connection an io.Writer. It panics for i/o errors. These errors // Write makes a connection an io.Writer. It panics for i/o errors. These errors
// are handled in the connection command loop. // are handled in the connection command loop.
func (c *conn) Write(buf []byte) (int, error) { func (c *conn) Write(buf []byte) (int, error) {
if err := c.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil { chunk := len(buf)
c.log.Errorx("setting write deadline", err) if c.slow {
chunk = 1
} }
n, err := c.conn.Write(buf) var n int
if err != nil { for len(buf) > 0 {
panic(fmt.Errorf("write: %s (%w)", err, errIO)) if err := c.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
c.log.Errorx("setting write deadline", err)
}
nn, err := c.conn.Write(buf[:chunk])
if err != nil {
panic(fmt.Errorf("write: %s (%w)", err, errIO))
}
n += nn
buf = buf[chunk:]
if len(buf) > 0 && badClientDelay > 0 {
mox.Sleep(mox.Context, badClientDelay)
}
} }
return n, err return n, nil
} }
func (c *conn) xtrace(level mlog.Level) func() { func (c *conn) xtrace(level mlog.Level) func() {
@ -406,6 +434,10 @@ var bufpool = moxio.NewBufpool(8, 16*1024)
// read line from connection, not going through line channel. // read line from connection, not going through line channel.
func (c *conn) readline0() (string, error) { func (c *conn) readline0() (string, error) {
if c.slow && badClientDelay > 0 {
mox.Sleep(mox.Context, badClientDelay)
}
d := 30 * time.Minute d := 30 * time.Minute
if c.state == stateNotAuthenticated { if c.state == stateNotAuthenticated {
d = 30 * time.Second d = 30 * time.Second
@ -1367,6 +1399,19 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
// Command: ../rfc/9051:1403 ../rfc/3501:1519 // Command: ../rfc/9051:1403 ../rfc/3501:1519
// Examples: ../rfc/9051:1520 ../rfc/3501:1631 // Examples: ../rfc/9051:1520 ../rfc/3501:1631
// For many failed auth attempts, slow down verification attempts.
if c.authFailed > 3 {
mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*time.Second)
}
c.authFailed++ // Compensated on success.
defer func() {
// On the 3rd failed authentication, start responding slowly. Successful auth will
// cause fast responses again.
if c.authFailed >= 3 {
c.setSlow(true)
}
}()
var authVariant string var authVariant string
authResult := "error" authResult := "error"
defer func() { defer func() {
@ -1442,6 +1487,11 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
authz := string(plain[0]) authz := string(plain[0])
authc := string(plain[1]) authc := string(plain[1])
password := string(plain[2]) password := string(plain[2])
if authz != "" && authz != authc {
xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role")
}
acc, err := store.OpenEmailAuth(authc, password) acc, err := store.OpenEmailAuth(authc, password)
if err != nil { if err != nil {
if errors.Is(err, store.ErrUnknownCredentials) { if errors.Is(err, store.ErrUnknownCredentials) {
@ -1450,13 +1500,8 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
} }
xusercodeErrorf("", "error") xusercodeErrorf("", "error")
} }
if authz != "" && authz != authc {
acc.Close()
xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role")
}
c.account = acc c.account = acc
c.username = authc c.username = authc
authResult = "ok"
case "CRAM-MD5": case "CRAM-MD5":
authVariant = strings.ToLower(authType) authVariant = strings.ToLower(authType)
@ -1521,7 +1566,6 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
c.account = acc c.account = acc
acc = nil // Cancel cleanup. acc = nil // Cancel cleanup.
c.username = addr c.username = addr
authResult = "ok"
case "SCRAM-SHA-1", "SCRAM-SHA-256": case "SCRAM-SHA-1", "SCRAM-SHA-256":
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error? // todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
@ -1601,11 +1645,14 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
c.account = acc c.account = acc
acc = nil // Cancel cleanup. acc = nil // Cancel cleanup.
c.username = ss.Authentication c.username = ss.Authentication
authResult = "ok"
default: default:
xuserErrorf("method not supported") xuserErrorf("method not supported")
} }
c.setSlow(false)
authResult = "ok"
c.authFailed = 0
c.comm = store.RegisterComm(c.account) c.comm = store.RegisterComm(c.account)
c.state = stateAuthenticated c.state = stateAuthenticated
c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities()) c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities())
@ -1636,6 +1683,19 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) {
xusercodeErrorf("PRIVACYREQUIRED", "tls required for login") xusercodeErrorf("PRIVACYREQUIRED", "tls required for login")
} }
// For many failed auth attempts, slow down verification attempts.
if c.authFailed > 3 {
mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*time.Second)
}
c.authFailed++ // Compensated on success.
defer func() {
// On the 3rd failed authentication, start responding slowly. Successful auth will
// cause fast responses again.
if c.authFailed >= 3 {
c.setSlow(true)
}
}()
acc, err := store.OpenEmailAuth(userid, password) acc, err := store.OpenEmailAuth(userid, password)
if err != nil { if err != nil {
authResult = "badcreds" authResult = "badcreds"
@ -1647,6 +1707,8 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) {
} }
c.account = acc c.account = acc
c.username = userid c.username = userid
c.authFailed = 0
c.setSlow(false)
c.comm = store.RegisterComm(acc) c.comm = store.RegisterComm(acc)
c.state = stateAuthenticated c.state = stateAuthenticated
authResult = "ok" authResult = "ok"

View file

@ -23,6 +23,9 @@ import (
func init() { func init() {
sanityChecks = true sanityChecks = true
// Don't slow down tests.
badClientDelay = 0
} }
func tocrlf(s string) string { func tocrlf(s string) string {

View file

@ -93,6 +93,14 @@ func limitersInit() {
} }
} }
var (
// Delay before reads and after 1-byte writes for probably spammers. Zero during tests.
badClientDelay = time.Second
// Delay before accepting message from sender without reputation. Zero during tests.
reputationlessSenderDeliveryDelay = 15 * time.Second
)
type codes struct { type codes struct {
code int code int
secode string // Enhanced code, but without the leading major int from code. secode string // Enhanced code, but without the leading major int from code.
@ -241,6 +249,7 @@ type conn struct {
w *bufio.Writer w *bufio.Writer
tr *moxio.TraceReader // Kept for changing trace level during cmd/auth/data. tr *moxio.TraceReader // Kept for changing trace level during cmd/auth/data.
tw *moxio.TraceWriter tw *moxio.TraceWriter
slow bool // If set, reads are done with a 1 second sleep, and writes are done 1 byte at a time, to keep spammers busy.
lastlog time.Time // Used for printing the delta time since the previous logging for this connection. lastlog time.Time // Used for printing the delta time since the previous logging for this connection.
submission bool // ../rfc/6409:19 applies submission bool // ../rfc/6409:19 applies
tlsConfig *tls.Config tlsConfig *tls.Config
@ -341,28 +350,57 @@ func (c *conn) xtrace(level mlog.Level) func() {
} }
} }
// setSlow marks the connection slow (or now), so reads are done with 3 second
// delay for each read, and writes are done at 1 byte per second, to try to slow
// down spammers.
func (c *conn) setSlow(on bool) {
if on && !c.slow {
c.log.Debug("connection changed to slow")
} else if !on && c.slow {
c.log.Debug("connection restored to regular pace")
}
c.slow = on
}
// Write writes to the connection. It panics on i/o errors, which is handled by the // Write writes to the connection. It panics on i/o errors, which is handled by the
// connection command loop. // connection command loop.
func (c *conn) Write(buf []byte) (int, error) { func (c *conn) Write(buf []byte) (int, error) {
// We set a single deadline for Write and Read. This may be a TLS connection. chunk := len(buf)
// SetDeadline works on the underlying connection. If we wouldn't touch the read if c.slow {
// deadline, and only set the write deadline and do a bunch of writes, the TLS chunk = 1
// library would still have to do reads on the underlying connection, and may reach
// a read deadline that was set for some earlier read.
if err := c.conn.SetDeadline(c.earliestDeadline(30 * time.Second)); err != nil {
c.log.Errorx("setting deadline for write", err)
} }
n, err := c.conn.Write(buf) var n int
if err != nil { for len(buf) > 0 {
panic(fmt.Errorf("write: %s (%w)", err, errIO)) // We set a single deadline for Write and Read. This may be a TLS connection.
// SetDeadline works on the underlying connection. If we wouldn't touch the read
// deadline, and only set the write deadline and do a bunch of writes, the TLS
// library would still have to do reads on the underlying connection, and may reach
// a read deadline that was set for some earlier read.
if err := c.conn.SetDeadline(c.earliestDeadline(30 * time.Second)); err != nil {
c.log.Errorx("setting deadline for write", err)
}
nn, err := c.conn.Write(buf[:chunk])
if err != nil {
panic(fmt.Errorf("write: %s (%w)", err, errIO))
}
n += nn
buf = buf[chunk:]
if len(buf) > 0 && badClientDelay > 0 {
mox.Sleep(mox.Context, badClientDelay)
}
} }
return n, err return n, nil
} }
// Read reads from the connection. It panics on i/o errors, which is handled by the // Read reads from the connection. It panics on i/o errors, which is handled by the
// connection command loop. // connection command loop.
func (c *conn) Read(buf []byte) (int, error) { func (c *conn) Read(buf []byte) (int, error) {
if c.slow && badClientDelay > 0 {
mox.Sleep(mox.Context, badClientDelay)
}
// todo future: make deadline configurable for callers, and through config file? ../rfc/5321:3610 ../rfc/6409:492 // todo future: make deadline configurable for callers, and through config file? ../rfc/5321:3610 ../rfc/6409:492
// See comment about Deadline instead of individual read/write deadlines at Write. // See comment about Deadline instead of individual read/write deadlines at Write.
if err := c.conn.SetDeadline(c.earliestDeadline(30 * time.Second)); err != nil { if err := c.conn.SetDeadline(c.earliestDeadline(30 * time.Second)); err != nil {
@ -819,6 +857,13 @@ func (c *conn) cmdAuth(p *parser) {
mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*time.Second) mox.Sleep(mox.Context, time.Duration(c.authFailed-3)*time.Second)
} }
c.authFailed++ // Compensated on success. c.authFailed++ // Compensated on success.
defer func() {
// On the 3rd failed authentication, start responding slowly. Successful auth will
// cause fast responses again.
if c.authFailed >= 3 {
c.setSlow(true)
}
}()
var authVariant string var authVariant string
authResult := "error" authResult := "error"
@ -903,6 +948,12 @@ func (c *conn) cmdAuth(p *parser) {
authz := string(plain[0]) authz := string(plain[0])
authc := string(plain[1]) authc := string(plain[1])
password := string(plain[2]) password := string(plain[2])
if authz != "" && authz != authc {
authResult = "badcreds"
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role")
}
acc, err := store.OpenEmailAuth(authc, password) acc, err := store.OpenEmailAuth(authc, password)
if err != nil && errors.Is(err, store.ErrUnknownCredentials) { if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
// ../rfc/4954:274 // ../rfc/4954:274
@ -910,13 +961,10 @@ func (c *conn) cmdAuth(p *parser) {
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
} }
xcheckf(err, "verifying credentials") xcheckf(err, "verifying credentials")
if authz != "" && authz != authc {
authResult = "badcreds"
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role")
}
authResult = "ok" authResult = "ok"
c.authFailed = 0 c.authFailed = 0
c.setSlow(false)
c.account = acc c.account = acc
c.username = authc c.username = authc
// ../rfc/4954:276 // ../rfc/4954:276
@ -985,6 +1033,7 @@ func (c *conn) cmdAuth(p *parser) {
authResult = "ok" authResult = "ok"
c.authFailed = 0 c.authFailed = 0
c.setSlow(false)
c.account = acc c.account = acc
acc = nil // Cancel cleanup. acc = nil // Cancel cleanup.
c.username = addr c.username = addr
@ -1068,6 +1117,7 @@ func (c *conn) cmdAuth(p *parser) {
authResult = "ok" authResult = "ok"
c.authFailed = 0 c.authFailed = 0
c.setSlow(false)
c.account = acc c.account = acc
acc = nil // Cancel cleanup. acc = nil // Cancel cleanup.
c.username = ss.Authentication c.username = ss.Authentication
@ -1733,7 +1783,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// Crude attempt to slow down someone trying to guess names. Would work better // Crude attempt to slow down someone trying to guess names. Would work better
// with connection rate limiter. // with connection rate limiter.
mox.Sleep(ctx, 1*time.Second) mox.Sleep(ctx, 5*time.Second)
// todo future: if remote does not look like a properly configured mail system, respond with generic 451 error? to prevent any random internet system from discovering accounts. we could give proper response if spf for ehlo or mailfrom passes. // todo future: if remote does not look like a properly configured mail system, respond with generic 451 error? to prevent any random internet system from discovering accounts. we could give proper response if spf for ehlo or mailfrom passes.
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "no such user(s)") xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "no such user(s)")
@ -2048,6 +2098,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
} else if err != nil { } else if err != nil {
log.Debugx("refusing due to high delivery rate", err) log.Debugx("refusing due to high delivery rate", err)
metricDelivery.WithLabelValues("highrate", "").Inc() metricDelivery.WithLabelValues("highrate", "").Inc()
c.setSlow(true)
addError(rcptAcc, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error()) addError(rcptAcc, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error())
continue continue
} }
@ -2122,6 +2173,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
log.Info("incoming message rejected", mlog.Field("reason", a.reason)) log.Info("incoming message rejected", mlog.Field("reason", a.reason))
metricDelivery.WithLabelValues("reject", a.reason).Inc() metricDelivery.WithLabelValues("reject", a.reason).Inc()
c.setSlow(true)
addError(rcptAcc, a.code, a.secode, a.userError, a.errmsg) addError(rcptAcc, a.code, a.secode, a.userError, a.errmsg)
continue continue
} }
@ -2145,6 +2197,14 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
} }
} }
// If not dmarc or tls report (Seen set above), and this is a first-time sender,
// wait before actually delivering. If this turns out to be a spammer, we've kept
// one of their connections busy.
if !m.Flags.Seen && a.reason == reasonNoBadSignals && reputationlessSenderDeliveryDelay > 0 {
log.Debug("delaying before delivering from sender without reputation", mlog.Field("delay", reputationlessSenderDeliveryDelay))
mox.Sleep(mox.Context, reputationlessSenderDeliveryDelay)
}
acc.WithWLock(func() { acc.WithWLock(func() {
// Gather the message-id before we deliver and the file may be consumed. // Gather the message-id before we deliver and the file may be consumed.
if !parsedMessageID { if !parsedMessageID {

View file

@ -38,6 +38,12 @@ import (
"github.com/mjl-/mox/tlsrptdb" "github.com/mjl-/mox/tlsrptdb"
) )
func init() {
// Don't make tests slow.
badClientDelay = 0
reputationlessSenderDeliveryDelay = 0
}
func tcheck(t *testing.T, err error, msg string) { func tcheck(t *testing.T, err error, msg string) {
if err != nil { if err != nil {
t.Helper() t.Helper()