From 82a62005f86c3dfbe24330a605279fe2038eea3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 28 Jul 2026 10:35:12 +0200 Subject: [PATCH] Harden email verification resend against abuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-address confirmation-email cooldown and disable the resend/forgot-password submit buttons while the mutation is in flight so callers cannot flood the mail queue or double-submit. Signed-off-by: Émile Ré --- .../src/pages/iam/auth/ForgotPasswordPage.tsx | 11 ++++--- .../iam/auth/ResendVerificationEmailPage.tsx | 11 ++++--- packages/emails/emails.go | 4 +-- pkg/coredata/email.go | 33 +++++++++++++++++++ pkg/iam/account_service.go | 19 +++++++++++ 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/console/src/pages/iam/auth/ForgotPasswordPage.tsx b/apps/console/src/pages/iam/auth/ForgotPasswordPage.tsx index 91de4d8f4..cdcf0a4d3 100644 --- a/apps/console/src/pages/iam/auth/ForgotPasswordPage.tsx +++ b/apps/console/src/pages/iam/auth/ForgotPasswordPage.tsx @@ -56,11 +56,12 @@ export default function ForgotPasswordPage() { }, }); - const [sendInstructions] = useMutation( - sendInstructionsMutation, - ); + const [sendInstructions, isSendingInstructions] + = useMutation(sendInstructionsMutation); const onSubmit = handleSubmit(({ email }) => { + if (isSendingInstructions) return; + sendInstructions({ variables: { input: { email }, @@ -154,9 +155,9 @@ export default function ForgotPasswordPage() { diff --git a/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx index f41d8827c..660b17534 100644 --- a/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx +++ b/apps/console/src/pages/iam/auth/ResendVerificationEmailPage.tsx @@ -57,11 +57,12 @@ export default function ResendVerificationEmailPage() { }, }); - const [resendVerificationEmail] = useMutation( - resendVerificationEmailMutation, - ); + const [resendVerificationEmail, isResending] + = useMutation(resendVerificationEmailMutation); const onSubmit = handleSubmit(({ email }) => { + if (isResending) return; + resendVerificationEmail({ variables: { input: { email }, @@ -155,9 +156,9 @@ export default function ResendVerificationEmailPage() { diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 1077897cf..302b6ba95 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -100,7 +100,7 @@ func NewPresenter(baseURL string, fullName string) *Presenter { } const ( - subjectConfirmEmail = "Confirm your email address" + SubjectConfirmEmail = "Confirm your email address" subjectPasswordReset = "Reset your password" subjectInvitation = "Invitation to join %s on Probo" subjectDocumentApproval = "Action Required – Please review and approve %s compliance documents" @@ -185,7 +185,7 @@ func (p *Presenter) RenderConfirmEmail(ctx context.Context, confirmationURLPath textBody, htmlBody, err = renderEmail(confirmEmailTextTemplate, confirmEmailHTMLTemplate, data) - return subjectConfirmEmail, textBody, htmlBody, err + return SubjectConfirmEmail, textBody, htmlBody, err } func (p *Presenter) RenderPasswordReset(ctx context.Context, resetPasswordURLPath string, resetPasswordToken string) (subject string, textBody string, htmlBody *string, err error) { diff --git a/pkg/coredata/email.go b/pkg/coredata/email.go index f6341dceb..7471c9b17 100644 --- a/pkg/coredata/email.go +++ b/pkg/coredata/email.go @@ -319,6 +319,39 @@ WHERE id = @id return err } +// ExistsByRecipientSubjectCreatedAfter reports whether an email with the given +// recipient and subject was created at or after createdAfter. +func ExistsByRecipientSubjectCreatedAfter( + ctx context.Context, + conn pg.Querier, + recipientEmail mail.Addr, + subject string, + createdAfter time.Time, +) (bool, error) { + q := ` +SELECT EXISTS ( + SELECT 1 + FROM emails + WHERE recipient_email = @recipient_email + AND subject = @subject + AND created_at >= @created_after +) +` + + args := pgx.StrictNamedArgs{ + "recipient_email": recipientEmail.String(), + "subject": subject, + "created_after": createdAfter, + } + + var exists bool + if err := conn.QueryRow(ctx, q, args).Scan(&exists); err != nil { + return false, fmt.Errorf("cannot check recent email: %w", err) + } + + return exists, nil +} + func ResetStaleProcessingEmails( ctx context.Context, conn pg.Querier, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 2ccd08cd6..8fb6520c2 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -76,6 +76,11 @@ var SupportedIdentityLocales = []string{ const ( TokenTypeEmailConfirmation = "email_confirmation" + + // emailConfirmationResendCooldown is the minimum time between confirmation + // emails for the same address. Resend requests inside this window succeed + // without enqueueing another message (anti-enumeration + anti-abuse). + emailConfirmationResendCooldown = time.Minute ) func NewAccountService(svc *Service) *AccountService { @@ -243,6 +248,20 @@ func (s AccountService) ResendVerificationEmail(ctx context.Context, email mail. return nil // Don't leak information about already-verified identities } + recent, err := coredata.ExistsByRecipientSubjectCreatedAfter( + ctx, + tx, + identity.EmailAddress, + emails.SubjectConfirmEmail, + time.Now().Add(-emailConfirmationResendCooldown), + ) + if err != nil { + return fmt.Errorf("cannot check recent confirmation email: %w", err) + } + if recent { + return nil // Cooldown: avoid flooding the recipient / mail queue + } + confirmationToken, err := statelesstoken.NewToken( s.tokenSecret, TokenTypeEmailConfirmation,