diff --git a/config/config.go b/config/config.go index 4a014c1..100b115 100644 --- a/config/config.go +++ b/config/config.go @@ -108,14 +108,22 @@ type Listener struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 993."` } `sconf:"optional" sconf-doc:"IMAP over TLS for reading email, by email applications. Requires a TLS config."` + AccountHTTP struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 80."` + } `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets."` + AccountHTTPS struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 80."` + } `sconf:"optional" sconf-doc:"Account web interface listener for HTTPS. Requires a TLS config."` AdminHTTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 80."` - } `sconf:"optional" sconf-doc:"Admin web interface, for administrators and regular users wanting to change their password."` + } `sconf:"optional" sconf-doc:"Admin web interface, for managing domains, accounts, etc. Served at /admin/. Preferrably only enable on non-public IPs."` AdminHTTPS struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 443."` - } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config."` + } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only enable on non-public IPs."` MetricsHTTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 8010."` diff --git a/config/doc.go b/config/doc.go index 73d590e..8717f71 100644 --- a/config/doc.go +++ b/config/doc.go @@ -176,15 +176,31 @@ describe-static" and "mox config describe-domains": # Default 993. (optional) Port: 0 - # Admin web interface, for administrators and regular users wanting to change - # their password. (optional) + # Account web interface, for email users wanting to change their accounts, e.g. + # set new password, set new delivery rulesets. (optional) + AccountHTTP: + Enabled: false + + # Default 80. (optional) + Port: 0 + + # Account web interface listener for HTTPS. Requires a TLS config. (optional) + AccountHTTPS: + Enabled: false + + # Default 80. (optional) + Port: 0 + + # Admin web interface, for managing domains, accounts, etc. Served at /admin/. + # Preferrably only enable on non-public IPs. (optional) AdminHTTP: Enabled: false # Default 80. (optional) Port: 0 - # Admin web interface listener for HTTPS. Requires a TLS config. (optional) + # Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only + # enable on non-public IPs. (optional) AdminHTTPS: Enabled: false diff --git a/http/account.go b/http/account.go index ef4bc71..a739ebf 100644 --- a/http/account.go +++ b/http/account.go @@ -4,11 +4,12 @@ import ( "context" "encoding/base64" "errors" - "fmt" "io" + "net" "net/http" "os" "strings" + "time" _ "embed" @@ -40,48 +41,75 @@ func init() { xlog.Fatalx("creating sherpa prometheus collector", err) } - accountSherpaHandler, err = sherpa.NewHandler("/account/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) + accountSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) if err != nil { xlog.Fatalx("sherpa handler", err) } } // Account exports web API functions for the account web interface. All its -// methods are exported under /account/api/. Function calls require valid HTTP +// methods are exported under /api/. Function calls require valid HTTP // Authentication credentials of a user. type Account struct{} -func accountHandle(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) - log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) - var accountName string +// check http basic auth, returns account name if valid, and writes http response +// and returns empty string otherwise. +func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) string { authResult := "error" + start := time.Now() + var addr *net.TCPAddr defer func() { metrics.AuthenticationInc("httpaccount", "httpbasic", authResult) + if authResult == "ok" && addr != nil { + mox.LimiterFailedAuth.Reset(addr.IP, start) + } }() - // todo: should probably add a cache here instead of looking up password in database all the time, just like in admin.go + + var err error + addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr) + if err != nil { + log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr)) + } + if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) { + metrics.AuthenticationRatelimitedInc("httpaccount") + http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests) + return "" + } + + // store.OpenEmailAuth has an auth cache, so we don't bcrypt for every auth attempt. if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") { } else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil { - log.Infox("parsing base64", err) + log.Debugx("parsing base64", err) } else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 { - log.Info("bad user:pass form") + log.Debug("bad user:pass form") } else if acc, err := store.OpenEmailAuth(t[0], t[1]); err != nil { if errors.Is(err, store.ErrUnknownCredentials) { authResult = "badcreds" } - log.Infox("open account", err) + log.Errorx("open account", err) } else { - accountName = acc.Name authResult = "ok" + accName := acc.Name + acc.Close() + return accName } - if accountName == "" { - w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`) - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintln(w, "http 401 - unauthorized - mox account - login with email address and password") + // note: browsers don't display the realm to prevent users getting confused by malicious realm messages. + w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`) + http.Error(w, "http 401 - unauthorized - mox account - login with email address and password", http.StatusUnauthorized) + return "" +} + +func accountHandle(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) + log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) + + accName := checkAccountAuth(ctx, log, w, r) + if accName == "" { + // Response already sent. return } - if r.Method == "GET" && r.URL.Path == "/account/" { + if r.Method == "GET" && r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache; max-age=0") // We typically return the embedded admin.html, but during development it's handy @@ -95,7 +123,7 @@ func accountHandle(w http.ResponseWriter, r *http.Request) { } return } - accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accountName))) + accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accName))) } type ctxKey string diff --git a/http/accountapi.json b/http/accountapi.json index 1fed71c..8a29dcb 100644 --- a/http/accountapi.json +++ b/http/accountapi.json @@ -1,6 +1,6 @@ { "Name": "Account", - "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /account/api/. Function calls require valid HTTP\nAuthentication credentials of a user.", + "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /api/. Function calls require valid HTTP\nAuthentication credentials of a user.", "Functions": [ { "Name": "SetPassword", diff --git a/http/admin.go b/http/admin.go index 4c16caf..9e3a67f 100644 --- a/http/admin.go +++ b/http/admin.go @@ -108,21 +108,46 @@ func init() { // check whether authentication from the config (passwordfile with bcrypt hash) // matches the authorization header "authHdr". we don't care about any username. -func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { +// on (auth) failure, a http response is sent and false returned. +func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWriter, r *http.Request) bool { log := xlog.WithContext(ctx) + respondAuthFail := func() bool { + // note: browsers don't display the realm to prevent users getting confused by malicious realm messages. + w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`) + http.Error(w, "http 401 - unauthorized - mox admin - login with empty username and admin password", http.StatusUnauthorized) + return false + } + authResult := "error" + start := time.Now() + var addr *net.TCPAddr defer func() { metrics.AuthenticationInc("httpadmin", "httpbasic", authResult) + if authResult == "ok" && addr != nil { + mox.LimiterFailedAuth.Reset(addr.IP, start) + } }() - if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" { + var err error + addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr) + if err != nil { + log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr)) + } + if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) { + metrics.AuthenticationRatelimitedInc("httpadmin") + http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests) return false } + + authHdr := r.Header.Get("Authorization") + if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" { + return respondAuthFail() + } buf, err := os.ReadFile(passwordfile) if err != nil { log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile)) - return false + return respondAuthFail() } passwordhash := strings.TrimSpace(string(buf)) authCache.Lock() @@ -133,15 +158,15 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { } auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic ")) if err != nil { - return false + return respondAuthFail() } t := strings.SplitN(string(auth), ":", 2) if len(t) != 2 || len(t[1]) < 8 { - return false + return respondAuthFail() } if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil { authResult = "badcreds" - return false + return respondAuthFail() } authCache.lastSuccessHash = passwordhash authCache.lastSuccessAuth = authHdr @@ -151,10 +176,8 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { func adminHandle(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) - if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), r.Header.Get("Authorization")) { - w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`) - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintln(w, "http 401 - unauthorized - mox admin - login with empty username and admin password") + if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), w, r) { + // Response already sent. return } diff --git a/http/admin_test.go b/http/admin_test.go index c83c3d7..f4cd43d 100644 --- a/http/admin_test.go +++ b/http/admin_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "net" + "net/http/httptest" "os" "testing" "time" @@ -19,7 +20,12 @@ func TestAdminAuth(t *testing.T) { test := func(passwordfile, authHdr string, expect bool) { t.Helper() - ok := checkAdminAuth(context.Background(), passwordfile, authHdr) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/ignored", nil) + if authHdr != "" { + r.Header.Add("Authorization", authHdr) + } + ok := checkAdminAuth(context.Background(), passwordfile, w, r) if ok != expect { t.Fatalf("got %v, expected %v", ok, expect) } diff --git a/http/web.go b/http/web.go index 5d9da03..473b8b0 100644 --- a/http/web.go +++ b/http/web.go @@ -71,17 +71,28 @@ func ListenAndServe() { ensureServe(true, 443, "acme-tls-alpn01") } + if l.AccountHTTP.Enabled { + srv := ensureServe(false, config.Port(l.AccountHTTP.Port, 80), "account-http") + srv.mux.HandleFunc("/", safeHeaders(accountHandle)) + } + if l.AccountHTTPS.Enabled { + srv := ensureServe(true, config.Port(l.AccountHTTP.Port, 443), "account-https") + srv.mux.HandleFunc("/", safeHeaders(accountHandle)) + } + if l.AdminHTTP.Enabled { srv := ensureServe(false, config.Port(l.AdminHTTP.Port, 80), "admin-http") - srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + if !l.AccountHTTP.Enabled { + srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + } srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) - srv.mux.HandleFunc("/account/", safeHeaders(accountHandle)) } if l.AdminHTTPS.Enabled { srv := ensureServe(true, config.Port(l.AdminHTTPS.Port, 443), "admin-https") - srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + if !l.AccountHTTP.Enabled { + srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + } srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) - srv.mux.HandleFunc("/account/", safeHeaders(accountHandle)) } if l.MetricsHTTP.Enabled { srv := ensureServe(false, config.Port(l.MetricsHTTP.Port, 8010), "metrics-http") @@ -174,6 +185,7 @@ func ListenAndServe() { } } +// Only used when the account page is not active on the same listener. func adminIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -183,27 +195,7 @@ func adminIndex(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } - - const html = ` - - - mox - - - - -

mox

-
/account/, for regular login
-
/admin/, for adminstrators
- -` - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(html)) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) } func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) { diff --git a/imapserver/server.go b/imapserver/server.go index 5d29b55..5a5eb8d 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -661,6 +661,7 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x // If remote IP/network resulted in too many authentication failures, refuse to serve. if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { + metrics.AuthenticationRatelimitedInc("imap") c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP)) c.writelinef("* BYE too many auth failures") return diff --git a/metrics/auth.go b/metrics/auth.go index 9c7ba46..0a2494b 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -6,7 +6,7 @@ import ( ) var ( - metricAuthentication = promauto.NewCounterVec( + metricAuth = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "mox_authentication_total", Help: "Authentication attempts and results.", @@ -18,8 +18,22 @@ var ( "result", // ok, baduser, badpassword, badcreds, error, aborted }, ) + + metricAuthRatelimited = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "mox_authentication_ratelimited_total", + Help: "Authentication attempts that were refused due to rate limiting.", + }, + []string{ + "kind", // submission, imap, httpaccount, httpadmin + }, + ) ) func AuthenticationInc(kind, variant, result string) { - metricAuthentication.WithLabelValues(kind, variant, result).Inc() + metricAuth.WithLabelValues(kind, variant, result).Inc() +} + +func AuthenticationRatelimitedInc(kind string) { + metricAuthRatelimited.WithLabelValues(kind).Inc() } diff --git a/mox-/config.go b/mox-/config.go index 465463f..defb011 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -475,7 +475,7 @@ func PrepareStaticConfig(ctx context.Context, configFile string, config *Config, if l.TLS.ACMEConfig != nil { l.TLS.ACMEConfig.MinVersion = minVersion } - } else if l.IMAPS.Enabled || l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS || l.AdminHTTPS.Enabled || l.AutoconfigHTTPS.Enabled || l.MTASTSHTTPS.Enabled { + } else if l.IMAPS.Enabled || l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS || l.AccountHTTPS.Enabled || l.AdminHTTPS.Enabled || l.AutoconfigHTTPS.Enabled || l.MTASTSHTTPS.Enabled { addErrorf("listener %q requires TLS, but does not specify tls config", name) } if l.AutoconfigHTTPS.Enabled && (!l.IMAP.Enabled && !l.IMAPS.Enabled || !l.Submission.Enabled && !l.Submissions.Enabled) { diff --git a/quickstart.go b/quickstart.go index 57f5b45..6af2fb6 100644 --- a/quickstart.go +++ b/quickstart.go @@ -382,6 +382,7 @@ This likely means one of two things: IPs: privateListenerIPs, Hostname: "localhost", } + internal.AccountHTTP.Enabled = true internal.AdminHTTP.Enabled = true internal.MetricsHTTP.Enabled = true diff --git a/smtpserver/server.go b/smtpserver/server.go index 94aed31..0585252 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -580,6 +580,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C // If remote IP/network resulted in too many authentication failures, refuse to serve. if submission && !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { + metrics.AuthenticationRatelimitedInc("submission") c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP)) c.writecodeline(smtp.C421ServiceUnavail, smtp.SePol7Other0, "too many auth failures", nil) return diff --git a/store/account.go b/store/account.go index af67311..c0c88c7 100644 --- a/store/account.go +++ b/store/account.go @@ -131,7 +131,7 @@ func (c *CRAMMD5) UnmarshalBinary(buf []byte) error { // Password holds credentials in various forms, for logging in with SMTP/IMAP. type Password struct { - Hash string // bcrypt hash for IMAP LOGIN and SASL PLAIN authentication. + Hash string // bcrypt hash for IMAP LOGIN, SASL PLAIN and HTTP basic authentication. CRAMMD5 CRAMMD5 // For SASL CRAM-MD5. SCRAMSHA1 SCRAM // For SASL SCRAM-SHA-1. SCRAMSHA256 SCRAM // For SASL SCRAM-SHA-256.