Parse Server: Email verification resend page leaks user existence
Description
Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.51 and 9.6.0-alpha.40, the Pages route and legacy PublicAPI route for resending email verification links return distinguishable responses depending on whether the provided username exists and has an unverified email. This allows an unauthenticated attacker to enumerate valid usernames by observing different redirect targets. The existing emailVerifySuccessOnInvalidEmail configuration option, which is enabled by default and protects the API route against this, did not apply to these routes. This issue has been patched in versions 8.6.51 and 9.6.0-alpha.40.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-servernpm | >= 9.0.0, < 9.6.0-alpha.40 | 9.6.0-alpha.40 |
parse-servernpm | < 8.6.51 | 8.6.51 |
Affected products
1- Range: < 8.6.51
Patches
2fbda4cb0c5cbfix: Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) (#10238)
3 files changed · +158 −69
spec/PagesRouter.spec.js+124 −68 modified@@ -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'), - '<html><body>secret</body></html>' - ); - - 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'); }); });
spec/ValidationAndPasswordsReset.spec.js+30 −1 modified@@ -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',
src/Routers/PagesRouter.js+4 −0 modified@@ -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); } );
967aa5773220fix: Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) (#10243)
4 files changed · +181 −1
spec/PagesRouter.spec.js+138 −0 modified@@ -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', () => {
spec/ValidationAndPasswordsReset.spec.js+32 −1 modified@@ -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',
src/Routers/PagesRouter.js+4 −0 modified@@ -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); } );
src/Routers/PublicAPIRouter.js+7 −0 modified@@ -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}`,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-h29g-q5c2-9h4fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33323ghsaADVISORY
- github.com/parse-community/parse-server/commit/967aa57732202009b2389ce9ecb3130d53d657e5ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10238ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/pull/10243ghsax_refsource_MISCWEB
- github.com/parse-community/parse-server/security/advisories/GHSA-h29g-q5c2-9h4fghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.