mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) (#10172)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
@@ -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.<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`.',
|
||||
|
||||
@@ -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`.<br><br>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.<br><br>⚠️ 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<MailAdapter>} 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.<br><br>Default is `true`.<br>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.<br><br>Default is `false`.<br>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.<br><br>For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).<br><br>Default is `undefined`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true
|
||||
|
||||
@@ -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.
|
||||
<br><br>
|
||||
Default is `true`.
|
||||
<br>
|
||||
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.
|
||||
<br><br>
|
||||
Default is `true`.
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user