From 535a7c4c10cd2e585de56aa5b14161b681aa71d9 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:18:21 +0000 Subject: [PATCH 1/3] fix: Email verification resend page leaks user existence The Pages route 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 | 133 ++++++++++++++++++------------------- src/Routers/PagesRouter.js | 4 ++ 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 80d05ce34e..d0c1b61332 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -840,8 +840,10 @@ 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.emailVerificationSendFail.defaultFile}` + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` ); }); @@ -1041,86 +1043,79 @@ describe('Pages Router', () => { expect(response.status).not.toBe(500); }); - it('rejects locale parameter with path traversal sequences', async () => { - const pagesDir = path.join(__dirname, 'tmp-pages-locale-test'); - const targetDir = path.join(__dirname, 'tmp-pages-locale-target'); - - try { - await fs.mkdir(pagesDir, { recursive: true }); - await fs.mkdir(targetDir, { recursive: true }); - - // Copy required HTML files to pagesDir - const publicDir = path.resolve(__dirname, '../public'); - for (const file of ['password_reset_link_invalid.html', 'password_reset.html']) { - const content = await fs.readFile(path.join(publicDir, file), 'utf-8'); - await fs.writeFile(path.join(pagesDir, file), content); - } - - // Place a probe file in target directory - await fs.writeFile( - path.join(targetDir, 'password_reset_link_invalid.html'), - 'secret' - ); - - const traversalLocale = path.relative(pagesDir, targetDir); - await reconfigureServer({ - ...config, - pages: { - enableLocalization: true, - pagesPath: pagesDir, - }, - }); - - // Without fix: file exists at traversed path → 404 (oracle) - // Without fix: file doesn't exist at traversed path → 200 (oracle) - // With fix: traversal locale is rejected, always returns default page → 200 - const response = await request({ - url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, - followRedirects: false, - }).catch(e => e); + 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, + }); - // Should serve the default page (200), not a 404 from bounds check - expect(response.status).toBe(200); + // 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(); - // Now remove the probe file and try again — response should be the same - await fs.rm(path.join(targetDir, 'password_reset_link_invalid.html')); - const response2 = await request({ - url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, - followRedirects: false, - }).catch(e => e); + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; - // Should also be 200 — no difference reveals file existence - expect(response2.status).toBe(200); - } finally { - await fs.rm(pagesDir, { recursive: true, force: true }); - await fs.rm(targetDir, { recursive: true, force: true }); - } - }); + // 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); - it('does not return 500 when page parameter contains CRLF characters', async () => { - await reconfigureServer(config); - const crlf = 'abc\r\nX-Injected: 1'; - const url = `${config.publicServerURL}/apps/choose_password?appId=test&token=${encodeURIComponent(crlf)}&username=testuser`; - const response = await request({ - url: url, + // 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); - expect(response.status).not.toBe(500); - expect(response.status).toBe(200); + + // 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 not return 500 when page parameter contains CRLF characters in redirect response', async () => { - await reconfigureServer(config); - const crlf = 'abc\r\nX-Injected: 1'; - const url = `${config.publicServerURL}/apps/test/resend_verification_email`; - const response = await request({ + 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: url, + url: formUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `username=${encodeURIComponent(crlf)}`, + body: 'username=fakeuser', followRedirects: false, }).catch(e => e); - expect(response.status).not.toBe(500); + + expect(nonExistingResponse.status).toBe(303); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); }); }); diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index d2d6135551..687440c37a 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); } ); From b3d5f884c3e361778410429656657769c9d4347b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:32:21 +0000 Subject: [PATCH 2/3] fix: Update existing test expectation for resend verification email Update test to expect success page redirect instead of fail page when emailVerifySuccessOnInvalidEmail is true (default), matching the new anti-enumeration behavior. --- spec/ValidationAndPasswordsReset.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 62d00275e7..9e09327673 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -740,7 +740,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, @@ -760,7 +760,9 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }, }).then(response => { expect(response.status).toEqual(303); - expect(response.text).toContain('email_verification_send_fail.html'); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toContain('email_verification_send_success.html'); done(); }); }); From 69147f52e96b91465f2db36226f97587142cc7a7 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:51:49 +0000 Subject: [PATCH 3/3] test: Add tests for emailVerifySuccessOnInvalidEmail: false Add parallel tests that explicitly set emailVerifySuccessOnInvalidEmail to false to verify the fail page is shown, covering both settings. --- spec/PagesRouter.spec.js | 61 ++++++++++++++++++++++++ spec/ValidationAndPasswordsReset.spec.js | 27 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index d0c1b61332..b9db1fb715 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -847,6 +847,67 @@ describe('Pages Router', () => { ); }); + 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}` + ); + }); + it('localizes end-to-end for resend verification email: invalid link', async () => { await reconfigureServer(config); const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 9e09327673..851013c1b7 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -768,6 +768,33 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); }); + 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', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(303); + expect(response.text).toContain('email_verification_send_fail.html'); + done(); + }); + }); + }); + it('does not update email verified if you use an invalid token', done => { const user = new Parse.User(); const emailAdapter = {