From d7f19f80888952a2ec0ead2be92fc0ed6bee10e2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Jun 2026 14:16:23 -0300 Subject: [PATCH] fix: allow unauthenticated access to users.sendConfirmationEmail (#40702) --- .changeset/crisp-taxis-pay.md | 5 ++ apps/meteor/app/api/server/v1/users.ts | 12 ++-- .../server/methods/sendConfirmationEmail.ts | 24 +++----- apps/meteor/tests/end-to-end/api/users.ts | 55 ++++++------------- packages/ddp-client/src/types/methods.ts | 1 - 5 files changed, 34 insertions(+), 63 deletions(-) create mode 100644 .changeset/crisp-taxis-pay.md diff --git a/.changeset/crisp-taxis-pay.md b/.changeset/crisp-taxis-pay.md new file mode 100644 index 00000000000..5ccb2c7abc7 --- /dev/null +++ b/.changeset/crisp-taxis-pay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes `users.sendConfirmationEmail` rejecting unauthenticated requests, which prevented unverified users from resending their verification email from the login screen diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index dc7ed2344be..fbc46b9579a 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -40,6 +40,7 @@ import { regeneratePersonalAccessTokenOfUser } from '../../../../imports/persona import { removePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/removeToken'; import { UserChangedAuditStore } from '../../../../server/lib/auditServerEvents/userChanged'; import { i18n } from '../../../../server/lib/i18n'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { registerUser } from '../../../../server/methods/registerUser'; import { requestDataDownload } from '../../../../server/methods/requestDataDownload'; @@ -1483,7 +1484,7 @@ API.v1 API.v1.post( 'users.sendConfirmationEmail', { - authRequired: true, + authRequired: false, body: isUsersSendConfirmationEmailParamsPOST, rateLimiterOptions: { numRequestsAllowed: 1, @@ -1492,16 +1493,11 @@ API.v1.post( response: { 200: voidSuccessResponse, 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, }, }, async function action() { - const { email } = this.bodyParams; - - if (await sendConfirmationEmail(email)) { - return API.v1.success(); - } - return API.v1.failure(); + void sendConfirmationEmail(this.bodyParams.email).catch((err) => SystemLogger.error({ msg: 'sendConfirmationEmail failed', err })); + return API.v1.success(); }, ); diff --git a/apps/meteor/server/methods/sendConfirmationEmail.ts b/apps/meteor/server/methods/sendConfirmationEmail.ts index 8fa1b01cd23..7eeea1fd78a 100644 --- a/apps/meteor/server/methods/sendConfirmationEmail.ts +++ b/apps/meteor/server/methods/sendConfirmationEmail.ts @@ -1,7 +1,6 @@ import { Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; -import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; export const sendConfirmationEmail = async (to: string): Promise => { @@ -9,14 +8,19 @@ export const sendConfirmationEmail = async (to: string): Promise => { const email = to.trim(); - const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1 } }); - + const user = await Users.findOneByEmailAddress(email, { projection: { emails: 1 } }); if (!user) { return false; } + // Do not re-send a verification mail to an address that is already verified. + const target = user.emails?.find((e) => e.address.toLowerCase() === email.toLowerCase() && !e.verified); + if (!target) { + return false; + } + try { - Accounts.sendVerificationEmail(user._id, email); + Accounts.sendVerificationEmail(user._id, target.address); return true; } catch (error: any) { throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { @@ -25,15 +29,3 @@ export const sendConfirmationEmail = async (to: string): Promise => { }); } }; - -DDPRateLimiter.addRule( - { - type: 'method', - name: 'sendConfirmationEmail', - userId() { - return true; - }, - }, - 5, - 60000, -); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index f23afe8a6e4..cd407c02760 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -4022,44 +4022,23 @@ describe('[Users]', () => { }); describe('[/users.sendConfirmationEmail]', () => { - it('should send email to user (return success), when is a valid email', (done) => { - void request - .post(api('users.sendConfirmationEmail')) - .set(credentials) - .send({ - email: adminEmail, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('should not send email to user(return error), when is a invalid email', (done) => { - void request - .post(api('users.sendConfirmationEmail')) - .set(credentials) - .send({ - email: 'invalidEmail', - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('should return 401 when not authenticated', async () => { - await request - .post(api('users.sendConfirmationEmail')) - .expect('Content-Type', 'application/json') - .expect(401) - .expect((res: Response) => { - expect(res.body).to.have.property('status', 'error'); - }); + [ + { description: 'authenticated + known email', auth: true, email: adminEmail }, + { description: 'unauthenticated + known email', auth: false, email: adminEmail }, + { description: 'unauthenticated + unknown email', auth: false, email: 'nobody@example.invalid' }, + ].forEach(({ description, auth, email }) => { + it(`should return 200 success for ${description}`, async () => { + const req = request.post(api('users.sendConfirmationEmail')).send({ email }); + if (auth) { + req.set(credentials); + } + await req + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); }); it('should return 400 when body is empty', async () => { diff --git a/packages/ddp-client/src/types/methods.ts b/packages/ddp-client/src/types/methods.ts index 066f5e5001e..a9d2ae36f03 100644 --- a/packages/ddp-client/src/types/methods.ts +++ b/packages/ddp-client/src/types/methods.ts @@ -1,7 +1,6 @@ // eslint-disable-next-line @typescript-eslint/naming-convention export interface ServerMethods { resetPassword(token: string, password: string): { token: string }; - sendConfirmationEmail(to: string): boolean; checkRegistrationSecretURL(hash: string): boolean; }