Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions spec/SecurityCheckGroups.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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();
Expand All @@ -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 () => {
Expand Down
200 changes: 200 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -1704,3 +1705,202 @@ 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.status).not.toBe(200);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => {
const invalidValues = [[], {}, 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');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
7 changes: 7 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export class Config {
_publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
emailVerifySuccessOnInvalidEmail,
}) {
const emailAdapter = userController.adapter;
if (verifyUserEmails) {
Expand All @@ -209,6 +210,7 @@ export class Config {
publicServerURL: publicServerURL || _publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
emailVerifySuccessOnInvalidEmail,
});
}
}
Expand Down Expand Up @@ -462,6 +464,7 @@ export class Config {
) {
throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value';
}

}
}

Expand Down Expand Up @@ -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.';
Expand All @@ -525,6 +529,9 @@ export class Config {
if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) {
throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration';
}
if (emailVerifySuccessOnInvalidEmail && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') {
throw 'emailVerifySuccessOnInvalidEmail must be a boolean value';
}
}

static validateFileUploadOptions(fileUpload) {
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 is invalid, or `false` if the request should return an error response if the email address is invalid.<br><br>Default is `true`.<br>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.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.',
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 is invalid, or `false` if the request should return an error response if the email address is invalid.
<br><br>
Default is `true`.
<br>
Requires option `verifyUserEmails: true`.
:DEFAULT: true */
emailVerifySuccessOnInvalidEmail: ?boolean;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
/* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.
<br><br>
Default is `true`.
Expand Down
8 changes: 8 additions & 0 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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.`);
}

Expand Down
24 changes: 24 additions & 0 deletions src/Security/CheckGroups/CheckGroupServerConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions types/Options/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface ParseServerOptions {
preventSignupWithUnverifiedEmail?: boolean;
emailVerifyTokenValidityDuration?: number;
emailVerifyTokenReuseIfValid?: boolean;
emailVerifySuccessOnInvalidEmail?: boolean;
sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise<boolean>);
accountLockout?: AccountLockoutOptions;
passwordPolicy?: PasswordPolicyOptions;
Expand Down
Loading