Skip to content

Commit 9e2efb8

Browse files
committed
don't allow creating accounts via the website for similar emailaddresses
where the "simplified localpart" is identical to an existing account: lowercased, keeping only the part before a "+" or "-", dots removed. during signups via the website, we simply don't create an account, and provide a hint to users. for issue #27
1 parent af40e8b commit 9e2efb8

File tree

5 files changed

+44
-14
lines changed

5 files changed

+44
-14
lines changed

api.go

+29-5
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func xratemeta(tx *bstore.Tx, user User, signup bool) {
173173
func xcanonicalAddress(email string) string {
174174
addr, err := smtp.ParseAddress(email)
175175
xusercheckf(err, "validating address")
176-
return addr.Pack(true)
176+
return addr.String()
177177
}
178178

179179
// Signup registers a new account. We send an email for users to verify they
@@ -184,6 +184,8 @@ func (API) Signup(ctx context.Context, email string) {
184184

185185
xrate(ratelimitSignup, reqInfo.Request)
186186

187+
emailAddr, err := smtp.ParseAddress(email)
188+
xusercheckf(err, "validating address")
187189
email = xcanonicalAddress(email)
188190

189191
// Make SMTP connection. If it fails, return error to user.
@@ -200,11 +202,15 @@ func (API) Signup(ctx context.Context, email string) {
200202
logCheck(err, "closing smtp connectiong")
201203
}()
202204

203-
user, msg, mailFrom, eightbit, smtputf8, m, err := signup(ctx, email, "", true)
205+
user, msg, mailFrom, eightbit, smtputf8, m, err := signup(ctx, emailAddr, "", true)
204206
if serr, ok := err.(*sherpa.Error); ok {
205207
panic(serr)
206208
}
207209
xcheckf(err, "adding user to database")
210+
if user.ID == 0 {
211+
// We didn't create a new account.
212+
return
213+
}
208214

209215
// Send the message.
210216
submitctx, submitcancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -219,7 +225,7 @@ func (API) Signup(ctx context.Context, email string) {
219225
slog.Info("submitted signup/passwordreset email", "userid", user.ID)
220226
}
221227

222-
func signup(ctx context.Context, email string, origMessageID string, viaWebsite bool) (user User, msg []byte, mailFrom string, eightbit, smtputf8 bool, m Message, err error) {
228+
func signup(ctx context.Context, email smtp.Address, origMessageID string, viaWebsite bool) (user User, msg []byte, mailFrom string, eightbit, smtputf8 bool, m Message, err error) {
223229
var sendID string
224230

225231
// Code below can raise panics with sherpa.Error. Catch them an return as regular error.
@@ -236,7 +242,7 @@ func signup(ctx context.Context, email string, origMessageID string, viaWebsite
236242
}()
237243

238244
err = database.Write(ctx, func(tx *bstore.Tx) error {
239-
user, err = bstore.QueryTx[User](tx).FilterNonzero(User{Email: email}).Get()
245+
user, err = bstore.QueryTx[User](tx).FilterNonzero(User{Email: email.String()}).Get()
240246
if err == bstore.ErrAbsent || err == nil && user.VerifyToken != "" {
241247
if viaWebsite && config.SignupWebsiteDisabled {
242248
return fmt.Errorf("signup via website currently disabled")
@@ -245,6 +251,13 @@ func signup(ctx context.Context, email string, origMessageID string, viaWebsite
245251
return fmt.Errorf("signup via email currently disabled")
246252
}
247253

254+
lp := string(email.Localpart)
255+
lp = strings.ToLower(lp) // Not likely anyone hands out different accounts with different casing only.
256+
lp = strings.SplitN(lp, "+", 2)[0] // user+$any@domain
257+
lp = strings.SplitN(lp, "-", 2)[0] // user-any@domain
258+
lp = strings.ReplaceAll(lp, ".", "") // Gmail.
259+
simplifiedEmail := smtp.Address{Localpart: smtp.Localpart(lp), Domain: email.Domain}
260+
248261
metaUnsubToken := user.MetaUnsubscribeToken
249262
if metaUnsubToken == "" {
250263
metaUnsubToken = xrandomID(16)
@@ -259,7 +272,8 @@ func signup(ctx context.Context, email string, origMessageID string, viaWebsite
259272
}
260273
user = User{
261274
ID: user.ID,
262-
Email: email,
275+
Email: email.String(),
276+
SimplifiedEmail: simplifiedEmail.String(),
263277
VerifyToken: verifyToken,
264278
MetaUnsubscribeToken: metaUnsubToken,
265279
UpdatesUnsubscribeToken: updatesUnsubToken,
@@ -269,6 +283,16 @@ func signup(ctx context.Context, email string, origMessageID string, viaWebsite
269283
xratemeta(tx, user, true)
270284
err = tx.Update(&user)
271285
} else {
286+
if viaWebsite {
287+
exists, err := bstore.QueryTx[User](tx).FilterNonzero(User{SimplifiedEmail: user.SimplifiedEmail}).Exists()
288+
xcheckf(err, "checking if similar address already has account")
289+
if exists {
290+
slog.Info("not allowing creation of duplicate simplified user via website", "email", user.Email, "simplifiedemail", user.SimplifiedEmail)
291+
// We're not giving feedback that the user already exists.
292+
user = User{}
293+
return nil
294+
}
295+
}
272296
user.UpdateInterval = IntervalDay
273297
err = tx.Insert(&user)
274298
}

data.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ type User struct {
2121
ID int64
2222

2323
// Cannot be changed, users must create a new account instead. todo: improve
24-
Email string `bstore:"nonzero,unique"`
24+
Email string `bstore:"nonzero,unique"`
25+
// Like email, but with a simplified localpart to prevent duplicate account signup
26+
// attempts through the website.
27+
SimplifiedEmail string `bstore:"index"`
2528
SaltedHashedPassword string `json:"-"`
2629
PasswordResetToken string `bstore:"index" json:"-"`
2730

imap.go

+7-4
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ func processMessage(imapconn *imapclient.Conn, uid uint32) (problem string, rerr
359359
// todo: if we didn't find dmarc=none, we could try looking up the dmarc record and applying it. perhaps good to again evaluate the dmarc record with the spf/dkim details we found: the dmarc policy may have a setting where it applies to fewer than 100% of the messages. we can probably be more strict.
360360
authres := msg.Header.Get("Authentication-Results")
361361
if authres == "" {
362-
return fmt.Sprintf("missing authentication-results in message, cannot validate from address"), nil
362+
return "missing authentication-results in message, cannot validate from address", nil
363363
}
364364
ar, err := message.ParseAuthResults(authres + "\n")
365365
if err != nil {
@@ -390,7 +390,7 @@ func processMessage(imapconn *imapclient.Conn, uid uint32) (problem string, rerr
390390
good = true
391391
break Methods
392392
case "fail":
393-
return fmt.Sprintf(`message contained a dmarc failure, not responding`), nil
393+
return `message contained a dmarc failure, not responding`, nil
394394
}
395395
case "spf":
396396
if am.Result == "pass" {
@@ -429,16 +429,19 @@ func processMessage(imapconn *imapclient.Conn, uid uint32) (problem string, rerr
429429
}
430430
}
431431
if !good {
432-
return fmt.Sprintf(`"from" address not aligned-dmarc-verified`), nil
432+
return `"from" address not aligned-dmarc-verified`, nil
433433
}
434434

435435
// Message seems legit. Lookup the user. If no account yet, we'll try to create it.
436436
// If user exists, we'll send a password reset. Like the regular signup form.
437437
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
438438
defer cancel()
439-
user, msg, mailFrom, eightbit, smtputf8, m, err := signup(ctx, fromAddr.String(), env.MessageID, false)
439+
user, msg, mailFrom, eightbit, smtputf8, m, err := signup(ctx, fromAddr, env.MessageID, false)
440440
if err != nil {
441441
return fmt.Sprintf("registering signup for user %q: %v", fromAddr.String(), err), nil
442+
} else if user.ID == 0 {
443+
// Should not happen for email-based signup.
444+
return "missing user id after signup", nil
442445
}
443446

444447
// Check if we can send. If not, abort.

index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1113,7 +1113,7 @@ const overview = async () => {
11131113
dom._kids(document.body, page);
11141114
};
11151115
const signedup = (email) => {
1116-
dom._kids(document.body, dom.div(dom._class('page'), dom.h1('Account created'), dom.p("We've sent an email to ", dom.b(email), " with a confirmation link."), dom.p("If the email is not coming in, don't forget to check your spam mailbox. Also, some mail servers employ 'grey listing', holding off first-time deliveries for up to half an hour."), dom.p("Go back ", dom.a(attr.href('#'), 'home', function click() { route(); }), '.')));
1116+
dom._kids(document.body, dom.div(dom._class('page'), dom.h1('Account created'), dom.p(dom.span("If all is well", attr.title('If you already have an account with essentially the same email address (wildcards removed, etc), you can not create another account via the website, only by sending us a signup email.')), ", we've sent an email to ", dom.b(email), " with a confirmation link."), dom.p("If the email is not coming in, don't forget to check your spam mailbox. Also, some mail servers employ 'grey listing', holding off first-time deliveries for up to half an hour."), dom.p("Go back ", dom.a(attr.href('#'), 'home', function click() { route(); }), '.')));
11171117
};
11181118
const signup = (home) => {
11191119
let fieldset;
@@ -1128,7 +1128,7 @@ const signup = (home) => {
11281128
dom.p('Send us an email with "signup for ', home.ServiceName, '" as the subject:'),
11291129
dom.p(style({ marginLeft: '3em' }), dom.a(attr.href('mailto:' + encodeURIComponent(home.SignupAddress) + '?subject=' + encodeURIComponent('signup for ' + home.ServiceName) + '&body=' + encodeURIComponent('sign me up for gopherwatch!')), home.SignupAddress)),
11301130
dom.p(`Any message body will do, it's ignored. You'll get a reply with a link to confirm and set a password, after which we'll automatically log you in. Easy.`),
1131-
home.SignupWebsiteDisabled ? [] : dom.p("Sending us the first email ", dom.span("helps your junk filter realize we're good people.", attr.title(`Because our email address will be a known correspondent in your account. It may also prevent delays in delivery. Hopefully your junk filter will seize the opportunity!`))),
1131+
home.SignupWebsiteDisabled ? [] : dom.p("Sending us the first email ", dom.span("helps your junk filter realize we're good people.", attr.title(`Because our email address will be a known correspondent in your account. It may also prevent delays in delivery. Hopefully your junk filter will seize the opportunity! On top of that, it will also prevent us from being misused into sending messages to unsuspecting people, because we only reply to messages from legitimate senders (spf/dkim/dmarc-verified). For similar reasons, you can only sign up with wildcard email addresses (like user+$anything@domain) via email and not via the website.`))),
11321132
dom.br(),
11331133
], home.SignupWebsiteDisabled ? [] : [
11341134
home.SignupEmailDisabled ? [] : dom.h2('Option 2: Signup through website'),

index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ const signedup = (email: string) => {
546546
dom._kids(document.body,
547547
dom.div(dom._class('page'),
548548
dom.h1('Account created'),
549-
dom.p("We've sent an email to ", dom.b(email), " with a confirmation link."),
549+
dom.p(dom.span("If all is well", attr.title('If you already have an account with essentially the same email address (wildcards removed, etc), you can not create another account via the website and we actually did not send you an email. You can only sign up with those similar addresses through a signup email.')), ", we've sent an email to ", dom.b(email), " with a confirmation link."),
550550
dom.p("If the email is not coming in, don't forget to check your spam mailbox. Also, some mail servers employ 'grey listing', holding off first-time deliveries for up to half an hour."),
551551
dom.p("Go back ", dom.a(attr.href('#'), 'home', function click() { route() }), '.'),
552552
),
@@ -574,7 +574,7 @@ const signup = (home: api.Home) => {
574574
dom.p('Send us an email with "signup for ', home.ServiceName, '" as the subject:'),
575575
dom.p(style({marginLeft: '3em'}), dom.a(attr.href('mailto:'+encodeURIComponent(home.SignupAddress)+'?subject='+encodeURIComponent('signup for '+home.ServiceName) + '&body='+encodeURIComponent('sign me up for gopherwatch!')), home.SignupAddress)),
576576
dom.p(`Any message body will do, it's ignored. You'll get a reply with a link to confirm and set a password, after which we'll automatically log you in. Easy.`),
577-
home.SignupWebsiteDisabled ? [] : dom.p("Sending us the first email ", dom.span("helps your junk filter realize we're good people.", attr.title(`Because our email address will be a known correspondent in your account. It may also prevent delays in delivery. Hopefully your junk filter will seize the opportunity!`))),
577+
home.SignupWebsiteDisabled ? [] : dom.p("Sending us the first email ", dom.span("helps your junk filter realize we're good people.", attr.title(`Because our email address will be a known correspondent in your account. It may also prevent delays in delivery. Hopefully your junk filter will seize the opportunity! On top of that, it will also prevent us from being misused into sending messages to unsuspecting people, because we only reply to messages from legitimate senders (spf/dkim/dmarc-verified). For similar reasons, you can only sign up with wildcard email addresses (like user+$anything@domain) via email and not via the website.`))),
578578
dom.br(),
579579
],
580580

0 commit comments

Comments
 (0)