diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 80d05ce34e..b9db1fb715 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -840,6 +840,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}` ); @@ -1041,86 +1104,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/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 62d00275e7..851013c1b7 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, @@ -750,6 +750,35 @@ 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(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toContain('email_verification_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 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); } );