From bdbb3d29737f39c812f49e962b5b313e8b5ff2ff Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:24:20 +0000 Subject: [PATCH] fix: Email verification resend leaks user existence The Pages and PublicAPI routes for resend_verification_email redirected to different pages on success vs failure, allowing user enumeration. Now respects the emailVerifySuccessOnInvalidEmail option (default true) to always redirect to the success page, matching the API route behavior. --- spec/PagesRouter.spec.js | 138 +++++++++++++++++++++++ spec/ValidationAndPasswordsReset.spec.js | 33 +++++- src/Routers/PagesRouter.js | 4 + src/Routers/PublicAPIRouter.js | 7 ++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 313b58c13a..4864ceb42d 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -852,6 +852,69 @@ describe('Pages Router', () => { followRedirects: false, }); expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page always redirects to the success page to prevent user enumeration + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` + ); + }); + + it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => { + config.emailVerifySuccessOnInvalidEmail = false; + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + await jasmine.timeout(); + + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => + Promise.reject('failed to resend verification email') + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: false, the resend page + // redirects to the fail page expect(formResponse.text).toContain( `/${locale}/${pages.emailVerificationSendFail.defaultFile}` ); @@ -1002,6 +1065,81 @@ describe('Pages Router', () => { await fs.rm(baseDir, { recursive: true, force: true }); } }); + + it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: true, + emailAdapter, + }); + + // Create a user with unverified email + const user = new Parse.User(); + user.setUsername('realuser'); + user.setPassword('password123'); + user.setEmail('real@example.com'); + await user.signUp(); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for existing unverified user + const existingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=realuser', + followRedirects: false, + }).catch(e => e); + + // Resend for non-existing user + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + // Both should redirect to the same page (success) to prevent enumeration + expect(existingResponse.status).toBe(303); + expect(nonExistingResponse.status).toBe(303); + expect(existingResponse.headers.location).toContain('email_verification_send_success'); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_success'); + }); + + it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter, + }); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for non-existing user should redirect to fail page + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + expect(nonExistingResponse.status).toBe(303); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); + }); }); describe('custom route', () => { diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 3f6d4048c5..2292526c55 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -747,7 +747,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); }); - it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => { + it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, @@ -757,6 +757,37 @@ describe('Custom Pages, Email Verification, Password Reset', () => { sendMail: () => {}, }, publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(302); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html' + ); + done(); + }); + }); + }); + + it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => { + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', }).then(() => { request({ url: 'http://localhost:8378/1/apps/test/resend_verification_email', diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 79ced65e03..7b6ddf8b6c 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -120,12 +120,16 @@ export class PagesRouter extends PromiseRouter { } const userController = config.userController; + const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true; return userController.resendVerificationEmail(username, req, token).then( () => { return this.goToPage(req, pages.emailVerificationSendSuccess); }, () => { + if (suppressError) { + return this.goToPage(req, pages.emailVerificationSendSuccess); + } return this.goToPage(req, pages.emailVerificationSendFail); } ); diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 2ec993f390..95574b8f4b 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -71,6 +71,7 @@ export class PublicAPIRouter extends PromiseRouter { } const userController = config.userController; + const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true; return userController.resendVerificationEmail(username, req, token).then( () => { @@ -80,6 +81,12 @@ export class PublicAPIRouter extends PromiseRouter { }); }, () => { + if (suppressError) { + return Promise.resolve({ + status: 302, + location: `${config.linkSendSuccessURL}`, + }); + } return Promise.resolve({ status: 302, location: `${config.linkSendFailURL}`,