Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
181 changes: 181 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,183 @@ 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 () => {
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({});
});

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.
});
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
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