MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Package abuseguard automatically blacklists IPs that rack up too many failed
|
||||
// SMTP/IMAP auth attempts, and rejects connections from already-blacklisted IPs before
|
||||
// the SMTP/IMAP banner is ever sent. Deliberately separate from the web admin/webmail
|
||||
// login lockout (internal/webui/ratelimit.go) and from the relay-authorization
|
||||
// whitelist (esrv_whitelisted_ips) — see the [Security] section of settings.ini.
|
||||
package abuseguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
// RecordFailureAndMaybeBlacklist should be called after every failed SMTP AUTH or IMAP
|
||||
// login. It counts recent failures from ip and blacklists it once the configured
|
||||
// threshold is hit. Fails open (does nothing) on a DB error rather than blocking auth
|
||||
// over a transient issue.
|
||||
func RecordFailureAndMaybeBlacklist(database *db.DB, cfg *ini.File, logger *toolbox.Logger, ip string) {
|
||||
if ip == "" || cfg == nil {
|
||||
return
|
||||
}
|
||||
sec := cfg.Section("Security")
|
||||
if !sec.Key("abuse_detection_enabled").MustBool(true) {
|
||||
return
|
||||
}
|
||||
if whitelisted, err := database.IsIPAbuseWhitelisted(ip); err != nil || whitelisted {
|
||||
return
|
||||
}
|
||||
|
||||
threshold := sec.Key("abuse_failure_threshold").MustInt(8)
|
||||
windowMinutes := sec.Key("abuse_detection_window_minutes").MustInt(10)
|
||||
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
|
||||
|
||||
n, err := database.CountFailedAuthAttemptsByIP(ip, since)
|
||||
if err != nil || n < threshold {
|
||||
return
|
||||
}
|
||||
|
||||
baseHours := sec.Key("abuse_blacklist_base_hours").MustInt(12)
|
||||
maxHours := sec.Key("abuse_blacklist_max_hours").MustInt(168)
|
||||
reason := "automatic: too many failed SMTP/IMAP auth attempts"
|
||||
if err := database.BlacklistIP(ip, reason, baseHours, maxHours); err != nil && logger != nil {
|
||||
logger.Error("abuseguard: failed to blacklist %s: %v", ip, err)
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warning("abuseguard: blacklisted %s after %d failed attempts in %dm", ip, n, windowMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
// guardedListener wraps a net.Listener so Accept() silently drops connections from
|
||||
// blacklisted IPs (never returning them to the caller) before any protocol banner is
|
||||
// written, and keeps looping rather than returning an error.
|
||||
type guardedListener struct {
|
||||
net.Listener
|
||||
database *db.DB
|
||||
logger *toolbox.Logger
|
||||
}
|
||||
|
||||
// GuardListener wraps inner so every accepted connection is checked against the IP
|
||||
// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller
|
||||
// ever sees it.
|
||||
func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener {
|
||||
return &guardedListener{Listener: inner, database: database, logger: logger}
|
||||
}
|
||||
|
||||
func (g *guardedListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
conn, err := g.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, _, splitErr := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if splitErr != nil {
|
||||
host = conn.RemoteAddr().String()
|
||||
}
|
||||
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
|
||||
return conn, nil
|
||||
}
|
||||
blocked, bErr := g.database.IsIPBlacklisted(host)
|
||||
if bErr != nil {
|
||||
return conn, nil // fail open on a DB error
|
||||
}
|
||||
if !blocked {
|
||||
return conn, nil
|
||||
}
|
||||
if g.logger != nil {
|
||||
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package abuseguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func testCfg(t *testing.T, threshold int) *ini.File {
|
||||
t.Helper()
|
||||
cfg := ini.Empty()
|
||||
sec, _ := cfg.NewSection("Security")
|
||||
sec.NewKey("abuse_detection_enabled", "true")
|
||||
sec.NewKey("abuse_failure_threshold", strconv.Itoa(threshold))
|
||||
sec.NewKey("abuse_detection_window_minutes", "10")
|
||||
sec.NewKey("abuse_blacklist_base_hours", "12")
|
||||
sec.NewKey("abuse_blacklist_max_hours", "168")
|
||||
return cfg
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func TestRecordFailureAndMaybeBlacklistTripsThreshold(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
cfg := testCfg(t, 3)
|
||||
const ip = "203.0.113.50"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
|
||||
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
|
||||
}
|
||||
if blocked, _ := database.IsIPBlacklisted(ip); blocked {
|
||||
t.Fatal("should not be blacklisted before threshold")
|
||||
}
|
||||
|
||||
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
|
||||
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
|
||||
|
||||
blocked, err := database.IsIPBlacklisted(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !blocked {
|
||||
t.Fatal("expected IP to be blacklisted after hitting the threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFailureAndMaybeBlacklistSkipsWhitelisted(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
cfg := testCfg(t, 2)
|
||||
const ip = "203.0.113.51"
|
||||
if err := database.AddAbuseWhitelist(ip, "trusted"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
|
||||
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
|
||||
}
|
||||
|
||||
if blocked, _ := database.IsIPBlacklisted(ip); blocked {
|
||||
t.Fatal("whitelisted IP should never be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeListener yields exactly one already-open in-memory connection pair, then EOF-like
|
||||
// closed errors, letting GuardListener's Accept loop be tested without real sockets.
|
||||
type fakeListener struct {
|
||||
conns chan net.Conn
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeListener(conns ...net.Conn) *fakeListener {
|
||||
ch := make(chan net.Conn, len(conns))
|
||||
for _, c := range conns {
|
||||
ch <- c
|
||||
}
|
||||
return &fakeListener{conns: ch, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (f *fakeListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-f.conns:
|
||||
return c, nil
|
||||
case <-f.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
func (f *fakeListener) Close() error { close(f.done); return nil }
|
||||
func (f *fakeListener) Addr() net.Addr { return dummyAddr{} }
|
||||
|
||||
type dummyAddr struct{}
|
||||
|
||||
func (dummyAddr) Network() string { return "tcp" }
|
||||
func (dummyAddr) String() string { return "0.0.0.0:0" }
|
||||
|
||||
func TestGuardListenerRejectsBlacklistedIP(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
const blockedIP = "198.51.100.77"
|
||||
if err := database.AddManualBlacklistEntry(blockedIP, "test", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blockedConn, blockedPeer := net.Pipe()
|
||||
defer blockedPeer.Close()
|
||||
|
||||
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
|
||||
guarded := GuardListener(inner, database, nil)
|
||||
|
||||
go func() {
|
||||
guarded.Accept()
|
||||
inner.Close()
|
||||
}()
|
||||
|
||||
// The blocked connection's peer end should observe the connection close rather
|
||||
// than any protocol banner, since GuardListener closes it before returning it.
|
||||
buf := make([]byte, 1)
|
||||
if _, err := blockedPeer.Read(buf); err == nil {
|
||||
t.Fatal("expected blocked connection to be closed by GuardListener, got readable data instead")
|
||||
}
|
||||
}
|
||||
|
||||
type addrOverrideConn struct {
|
||||
net.Conn
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func (c *addrOverrideConn) RemoteAddr() net.Addr { return c.remote }
|
||||
|
||||
type hostPortAddr string
|
||||
|
||||
func (hostPortAddr) Network() string { return "tcp" }
|
||||
func (a hostPortAddr) String() string { return string(a) + ":12345" }
|
||||
Reference in New Issue
Block a user