mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
prevent unicode-confusion in password by applying PRECIS, and username/email address by applying unicode NFC normalization
an é (e with accent) can also be written as e+\u0301. the first form is NFC, the second NFD. when logging in, we transform usernames (email addresses) to NFC. so both forms will be accepted. if a client is using NFD, they can log in too. for passwords, we apply the PRECIS "opaquestring", which (despite the name) transforms the value too: unicode spaces are replaced with ascii spaces. the string is also normalized to NFC. PRECIS may reject confusing passwords when you set a password.
This commit is contained in:
@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
@ -962,8 +963,6 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
xsmtpUserErrorf(smtp.C503BadCmdSeq, smtp.SeProto5BadCmdOrSeq1, "authentication not allowed during mail transaction")
|
||||
}
|
||||
|
||||
// todo future: we may want to normalize usernames and passwords, see stringprep in ../rfc/4013:38 and possibly newer mechanisms (though they are opt-in and that may not have happened yet).
|
||||
|
||||
// If authentication fails due to missing derived secrets, we don't hold it against
|
||||
// the connection. There is no way to indicate server support for an authentication
|
||||
// mechanism, but that a mechanism won't work for an account.
|
||||
@ -1071,8 +1070,8 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
if len(plain) != 3 {
|
||||
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "auth data should have 3 nul-separated tokens, got %d", len(plain))
|
||||
}
|
||||
authz := string(plain[0])
|
||||
authc := string(plain[1])
|
||||
authz := norm.NFC.String(string(plain[0]))
|
||||
authc := norm.NFC.String(string(plain[1]))
|
||||
password := string(plain[2])
|
||||
|
||||
if authz != "" && authz != authc {
|
||||
@ -1115,6 +1114,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
// I-D says maximum length must be 64 bytes. We allow more, for long user names
|
||||
// (domains).
|
||||
username := string(xreadInitial())
|
||||
username = norm.NFC.String(username)
|
||||
|
||||
// Again, client should ignore the challenge, we send the same as the example in
|
||||
// the I-D.
|
||||
@ -1156,7 +1156,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
if len(t) != 2 || len(t[1]) != 2*md5.Size {
|
||||
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response")
|
||||
}
|
||||
addr := t[0]
|
||||
addr := norm.NFC.String(t[0])
|
||||
c.log.Debug("cram-md5 auth", slog.String("address", addr))
|
||||
acc, _, err := store.OpenEmail(c.log, addr)
|
||||
if err != nil {
|
||||
@ -1245,13 +1245,14 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
c0 := xreadInitial()
|
||||
ss, err := scram.NewServer(h, c0, cs, channelBindingRequired)
|
||||
xcheckf(err, "starting scram")
|
||||
c.log.Debug("scram auth", slog.String("authentication", ss.Authentication))
|
||||
acc, _, err := store.OpenEmail(c.log, ss.Authentication)
|
||||
authc := norm.NFC.String(ss.Authentication)
|
||||
c.log.Debug("scram auth", slog.String("authentication", authc))
|
||||
acc, _, err := store.OpenEmail(c.log, authc)
|
||||
if err != nil {
|
||||
// todo: we could continue scram with a generated salt, deterministically generated
|
||||
// from the username. that way we don't have to store anything but attackers cannot
|
||||
// learn if an account exists. same for absent scram saltedpassword below.
|
||||
c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
|
||||
c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
|
||||
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
|
||||
}
|
||||
defer func() {
|
||||
@ -1268,7 +1269,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
err := acc.DB.Read(context.TODO(), func(tx *bstore.Tx) error {
|
||||
password, err := bstore.QueryTx[store.Password](tx).Get()
|
||||
if err == bstore.ErrAbsent {
|
||||
c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
|
||||
c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
|
||||
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
|
||||
}
|
||||
xcheckf(err, "fetching credentials")
|
||||
@ -1282,8 +1283,8 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
}
|
||||
if len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0 {
|
||||
missingDerivedSecrets = true
|
||||
c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", ss.Authentication))
|
||||
c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
|
||||
c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", slog.String("address", authc))
|
||||
c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
|
||||
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
|
||||
}
|
||||
return nil
|
||||
@ -1302,7 +1303,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
c.readline() // Should be "*" for cancellation.
|
||||
if errors.Is(err, scram.ErrInvalidProof) {
|
||||
authResult = "badcreds"
|
||||
c.log.Info("failed authentication attempt", slog.String("username", ss.Authentication), slog.Any("remote", c.remoteIP))
|
||||
c.log.Info("failed authentication attempt", slog.String("username", authc), slog.Any("remote", c.remoteIP))
|
||||
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials")
|
||||
}
|
||||
xcheckf(err, "server final")
|
||||
@ -1317,7 +1318,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||
c.setSlow(false)
|
||||
c.account = acc
|
||||
acc = nil // Cancel cleanup.
|
||||
c.username = ss.Authentication
|
||||
c.username = authc
|
||||
// ../rfc/4954:276
|
||||
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
||||
|
||||
|
@ -97,6 +97,9 @@ type testserver struct {
|
||||
tlspkix bool
|
||||
}
|
||||
|
||||
const password0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces.
|
||||
const password1 = "tést " // PRECIS normalized, with NFC.
|
||||
|
||||
func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *testserver {
|
||||
limitersInit() // Reset rate limiters.
|
||||
|
||||
@ -116,7 +119,7 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test
|
||||
var err error
|
||||
ts.acc, err = store.OpenAccount(log, "mjl")
|
||||
tcheck(t, err, "open account")
|
||||
err = ts.acc.SetPassword(log, "testtest")
|
||||
err = ts.acc.SetPassword(log, password0)
|
||||
tcheck(t, err, "set password")
|
||||
ts.switchStop = store.Switchboard()
|
||||
err = queue.Init()
|
||||
@ -285,9 +288,14 @@ func TestSubmission(t *testing.T) {
|
||||
},
|
||||
}
|
||||
for _, fn := range authfns {
|
||||
testAuth(fn, "mjl@mox.example", "test", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad (short) password.
|
||||
testAuth(fn, "mjl@mox.example", "testtesttest", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad password.
|
||||
testAuth(fn, "mjl@mox.example", "testtest", nil)
|
||||
testAuth(fn, "mjl@mox.example", "test", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad (short) password.
|
||||
testAuth(fn, "mjl@mox.example", password0+"test", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad password.
|
||||
testAuth(fn, "mjl@mox.example", password0, nil)
|
||||
testAuth(fn, "mjl@mox.example", password1, nil)
|
||||
testAuth(fn, "móx@mox.example", password0, nil)
|
||||
testAuth(fn, "móx@mox.example", password1, nil)
|
||||
testAuth(fn, "mo\u0301x@mox.example", password0, nil)
|
||||
testAuth(fn, "mo\u0301x@mox.example", password1, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -370,12 +378,12 @@ func TestDelivery(t *testing.T) {
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
recipients := []string{
|
||||
"mjl@mox.example",
|
||||
"o@mox.example", // ascii o, as configured
|
||||
"\u2126@mox.example", // ohm sign, as configured
|
||||
"ω@mox.example", // lower-case omega, we match case-insensitively and this is the lowercase of ohm (!)
|
||||
"\u03a9@mox.example", // capital omega, also lowercased to omega.
|
||||
"tést@mox.example", // NFC
|
||||
"te\u0301st@mox.example", // not NFC, but normalized as tést@, see https://go.dev/blog/normalization
|
||||
"o@mox.example", // ascii o, as configured
|
||||
"\u2126@mox.example", // ohm sign, as configured
|
||||
"ω@mox.example", // lower-case omega, we match case-insensitively and this is the lowercase of ohm (!)
|
||||
"\u03a9@mox.example", // capital omega, also lowercased to omega.
|
||||
"móx@mox.example", // NFC
|
||||
"mo\u0301x@mox.example", // not NFC, but normalized as móx@, see https://go.dev/blog/normalization
|
||||
}
|
||||
|
||||
for _, rcptTo := range recipients {
|
||||
@ -1258,7 +1266,7 @@ func TestLimitOutgoing(t *testing.T) {
|
||||
defer ts.close()
|
||||
|
||||
ts.user = "mjl@mox.example"
|
||||
ts.pass = "testtest"
|
||||
ts.pass = password0
|
||||
ts.submission = true
|
||||
|
||||
err := ts.acc.DB.Insert(ctxbg, &store.Outgoing{Recipient: "a@other.example", Submitted: time.Now().Add(-24*time.Hour - time.Minute)})
|
||||
@ -1413,7 +1421,7 @@ func TestDKIMSign(t *testing.T) {
|
||||
|
||||
ts.submission = true
|
||||
ts.user = "mjl@mox.example"
|
||||
ts.pass = "testtest"
|
||||
ts.pass = password0
|
||||
|
||||
n := 0
|
||||
testSubmit := func(mailFrom, msgFrom string) {
|
||||
@ -1541,7 +1549,7 @@ func TestRequireTLS(t *testing.T) {
|
||||
ts.submission = true
|
||||
ts.requiretls = true
|
||||
ts.user = "mjl@mox.example"
|
||||
ts.pass = "testtest"
|
||||
ts.pass = password0
|
||||
|
||||
no := false
|
||||
yes := true
|
||||
@ -1692,7 +1700,7 @@ func TestFutureRelease(t *testing.T) {
|
||||
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{})
|
||||
ts.tlsmode = smtpclient.TLSSkip
|
||||
ts.user = "mjl@mox.example"
|
||||
ts.pass = "testtest"
|
||||
ts.pass = password0
|
||||
ts.submission = true
|
||||
defer ts.close()
|
||||
|
||||
|
Reference in New Issue
Block a user