diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 82114760af..11a901f399 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1085,6 +1085,7 @@ describe('Email Verification Token Expiration:', () => { emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, }); user.setUsername('no_new_verification_token_once_verified'); user.setPassword('expiringToken'); @@ -1131,6 +1132,7 @@ describe('Email Verification Token Expiration:', () => { emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, }); const response = await request({ url: 'http://localhost:8378/1/verificationEmailRequest', diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 696b7da640..354fed8b0e 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -49,6 +49,8 @@ describe('Security Check Groups', () => { expect(group.checks()[6].checkState()).toBe(CheckState.success); expect(group.checks()[8].checkState()).toBe(CheckState.success); expect(group.checks()[9].checkState()).toBe(CheckState.success); + expect(group.checks()[10].checkState()).toBe(CheckState.success); + expect(group.checks()[11].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -67,6 +69,10 @@ describe('Security Check Groups', () => { graphQLDepth: -1, graphQLFields: -1, }; + config.passwordPolicy = { + resetPasswordSuccessOnInvalidEmail: false, + }; + config.emailVerifySuccessOnInvalidEmail = false; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -79,6 +85,8 @@ describe('Security Check Groups', () => { expect(group.checks()[6].checkState()).toBe(CheckState.fail); expect(group.checks()[8].checkState()).toBe(CheckState.fail); expect(group.checks()[9].checkState()).toBe(CheckState.fail); + expect(group.checks()[10].checkState()).toBe(CheckState.fail); + expect(group.checks()[11].checkState()).toBe(CheckState.fail); }); it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index c38ad99fdf..cd780974b6 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1,4 +1,5 @@ const request = require('../lib/request'); +const Config = require('../lib/Config'); describe('Vulnerabilities', () => { describe('(GHSA-8xq9-g7ch-35hg) Custom object ID allows to acquire role privilege', () => { @@ -1704,3 +1705,203 @@ describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-not expect(res.status).toBe(400); }); }); + +describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; + + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('verifieduser'); + user2.setPassword('password123'); + user2.set('email', 'verified@example.com'); + await user2.signUp(); + const config = Config.get(Parse.applicationId); + await config.database.update( + '_User', + { username: 'verifieduser' }, + { emailVerified: true } + ); + } + + describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + it('returns success for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for unverified email', async () => { + sendVerificationEmail.calls.reset(); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + it('does not send verification email for non-existent email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('does not send verification email for already verified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + + it('returns error for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + }); + + it('returns error for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); + expect(response.data.error).toBe('Email verified@example.com is already verified.'); + }); + + it('sends verification email for unverified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + }); + + it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { + const invalidValues = [[], {}, 0, 1, '', 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: value, + emailAdapter: { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }) + ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); + } + }); +}); diff --git a/src/Config.js b/src/Config.js index 920f72d1e6..c164594457 100644 --- a/src/Config.js +++ b/src/Config.js @@ -200,6 +200,7 @@ export class Config { _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { const emailAdapter = userController.adapter; if (verifyUserEmails) { @@ -209,6 +210,7 @@ export class Config { publicServerURL: publicServerURL || _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }); } } @@ -457,11 +459,12 @@ export class Config { } if ( - passwordPolicy.resetPasswordSuccessOnInvalidEmail && + passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined && typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean' ) { throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value'; } + } } @@ -504,6 +507,7 @@ export class Config { publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; @@ -525,6 +529,9 @@ export class Config { if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) { throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration'; } + if (emailVerifySuccessOnInvalidEmail !== undefined && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') { + throw 'emailVerifySuccessOnInvalidEmail must be a boolean value'; + } } static validateFileUploadOptions(fileUpload) { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1359e1b27f..672325c072 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -197,6 +197,12 @@ module.exports.ParseServerOptions = { help: 'Adapter module for email sending', action: parsers.moduleOrObjectParser, }, + emailVerifySuccessOnInvalidEmail: { + env: 'PARSE_SERVER_EMAIL_VERIFY_SUCCESS_ON_INVALID_EMAIL', + help: 'Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`.', + action: parsers.booleanParser, + default: true, + }, emailVerifyTokenReuseIfValid: { env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', help: 'Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 54a49f5fb8..e076f591aa 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -39,6 +39,7 @@ * @property {Boolean} directAccess Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.

If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.

⚠️ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`. * @property {String} dotNetKey Key for Unity and .Net SDK * @property {Adapter} emailAdapter Adapter module for email sending + * @property {Boolean} emailVerifySuccessOnInvalidEmail Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`. * @property {Boolean} emailVerifyTokenReuseIfValid Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`. * @property {Number} emailVerifyTokenValidityDuration Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.
Requires option `verifyUserEmails: true`. * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true diff --git a/src/Options/index.js b/src/Options/index.js index b8e239e37f..2d0c91f33a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -235,6 +235,13 @@ export interface ParseServerOptions { Requires option `verifyUserEmails: true`. :DEFAULT: false */ emailVerifyTokenReuseIfValid: ?boolean; + /* Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases. +

+ Default is `true`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: true */ + emailVerifySuccessOnInvalidEmail: ?boolean; /* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.

Default is `true`. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index edd1bdae4e..2ca2da2641 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -547,8 +547,13 @@ export class UsersRouter extends ClassesRouter { ); } + const verifyEmailSuccessOnInvalidEmail = req.config.emailVerifySuccessOnInvalidEmail ?? true; + const results = await req.config.database.find('_User', { email: email }, {}, Auth.maintenance(req.config)); if (!results.length || results.length < 1) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`); } const user = results[0]; @@ -557,6 +562,9 @@ export class UsersRouter extends ClassesRouter { delete user.password; if (user.emailVerified) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`); } diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index d8d8e647e6..36b327dc91 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -151,6 +151,30 @@ class CheckGroupServerConfig extends CheckGroup { } }, }), + new Check({ + title: 'Password reset endpoint user enumeration mitigated', + warning: + 'The password reset endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'passwordPolicy.resetPasswordSuccessOnInvalidEmail: true'.", + check: () => { + if (config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), + new Check({ + title: 'Email verification endpoint user enumeration mitigated', + warning: + 'The email verification endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'emailVerifySuccessOnInvalidEmail: true'.", + check: () => { + if (config.emailVerifySuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), new Check({ title: 'LiveQuery regex timeout enabled', warning: diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 49e58cc1df..6a8b1494ac 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -95,6 +95,7 @@ export interface ParseServerOptions { preventSignupWithUnverifiedEmail?: boolean; emailVerifyTokenValidityDuration?: number; emailVerifyTokenReuseIfValid?: boolean; + emailVerifySuccessOnInvalidEmail?: boolean; sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise); accountLockout?: AccountLockoutOptions; passwordPolicy?: PasswordPolicyOptions;