Skip to content

Commit 936abd4

Browse files
authored
fix: User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](GHSA-w54v-hf9p-8856)) (#10172)
1 parent f0f3bbb commit 936abd4

File tree

10 files changed

+266
-1
lines changed

10 files changed

+266
-1
lines changed

spec/EmailVerificationToken.spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,7 @@ describe('Email Verification Token Expiration:', () => {
10851085
emailAdapter: emailAdapter,
10861086
emailVerifyTokenValidityDuration: 5, // 5 seconds
10871087
publicServerURL: 'http://localhost:8378/1',
1088+
emailVerifySuccessOnInvalidEmail: false,
10881089
});
10891090
user.setUsername('no_new_verification_token_once_verified');
10901091
user.setPassword('expiringToken');
@@ -1131,6 +1132,7 @@ describe('Email Verification Token Expiration:', () => {
11311132
emailAdapter: emailAdapter,
11321133
emailVerifyTokenValidityDuration: 5, // 5 seconds
11331134
publicServerURL: 'http://localhost:8378/1',
1135+
emailVerifySuccessOnInvalidEmail: false,
11341136
});
11351137
const response = await request({
11361138
url: 'http://localhost:8378/1/verificationEmailRequest',

spec/SecurityCheckGroups.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ describe('Security Check Groups', () => {
4949
expect(group.checks()[6].checkState()).toBe(CheckState.success);
5050
expect(group.checks()[8].checkState()).toBe(CheckState.success);
5151
expect(group.checks()[9].checkState()).toBe(CheckState.success);
52+
expect(group.checks()[10].checkState()).toBe(CheckState.success);
53+
expect(group.checks()[11].checkState()).toBe(CheckState.success);
5254
});
5355

5456
it('checks fail correctly', async () => {
@@ -67,6 +69,10 @@ describe('Security Check Groups', () => {
6769
graphQLDepth: -1,
6870
graphQLFields: -1,
6971
};
72+
config.passwordPolicy = {
73+
resetPasswordSuccessOnInvalidEmail: false,
74+
};
75+
config.emailVerifySuccessOnInvalidEmail = false;
7076
await reconfigureServer(config);
7177

7278
const group = new CheckGroupServerConfig();
@@ -79,6 +85,8 @@ describe('Security Check Groups', () => {
7985
expect(group.checks()[6].checkState()).toBe(CheckState.fail);
8086
expect(group.checks()[8].checkState()).toBe(CheckState.fail);
8187
expect(group.checks()[9].checkState()).toBe(CheckState.fail);
88+
expect(group.checks()[10].checkState()).toBe(CheckState.fail);
89+
expect(group.checks()[11].checkState()).toBe(CheckState.fail);
8290
});
8391

8492
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {

spec/vulnerabilities.spec.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const request = require('../lib/request');
2+
const Config = require('../lib/Config');
23

34
describe('Vulnerabilities', () => {
45
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
17041705
expect(res.status).toBe(400);
17051706
});
17061707
});
1708+
1709+
describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => {
1710+
let sendVerificationEmail;
1711+
1712+
async function createTestUsers() {
1713+
const user = new Parse.User();
1714+
user.setUsername('testuser');
1715+
user.setPassword('password123');
1716+
user.set('email', 'unverified@example.com');
1717+
await user.signUp();
1718+
1719+
const user2 = new Parse.User();
1720+
user2.setUsername('verifieduser');
1721+
user2.setPassword('password123');
1722+
user2.set('email', 'verified@example.com');
1723+
await user2.signUp();
1724+
const config = Config.get(Parse.applicationId);
1725+
await config.database.update(
1726+
'_User',
1727+
{ username: 'verifieduser' },
1728+
{ emailVerified: true }
1729+
);
1730+
}
1731+
1732+
describe('default (emailVerifySuccessOnInvalidEmail: true)', () => {
1733+
beforeEach(async () => {
1734+
sendVerificationEmail = jasmine.createSpy('sendVerificationEmail');
1735+
await reconfigureServer({
1736+
appName: 'test',
1737+
publicServerURL: 'http://localhost:8378/1',
1738+
verifyUserEmails: true,
1739+
emailAdapter: {
1740+
sendVerificationEmail,
1741+
sendPasswordResetEmail: () => Promise.resolve(),
1742+
sendMail: () => {},
1743+
},
1744+
});
1745+
await createTestUsers();
1746+
});
1747+
it('returns success for non-existent email', async () => {
1748+
const response = await request({
1749+
url: 'http://localhost:8378/1/verificationEmailRequest',
1750+
method: 'POST',
1751+
body: { email: 'nonexistent@example.com' },
1752+
headers: {
1753+
'X-Parse-Application-Id': Parse.applicationId,
1754+
'X-Parse-REST-API-Key': 'rest',
1755+
'Content-Type': 'application/json',
1756+
},
1757+
});
1758+
expect(response.status).toBe(200);
1759+
expect(response.data).toEqual({});
1760+
});
1761+
1762+
it('returns success for already verified email', async () => {
1763+
const response = await request({
1764+
url: 'http://localhost:8378/1/verificationEmailRequest',
1765+
method: 'POST',
1766+
body: { email: 'verified@example.com' },
1767+
headers: {
1768+
'X-Parse-Application-Id': Parse.applicationId,
1769+
'X-Parse-REST-API-Key': 'rest',
1770+
'Content-Type': 'application/json',
1771+
},
1772+
});
1773+
expect(response.status).toBe(200);
1774+
expect(response.data).toEqual({});
1775+
});
1776+
1777+
it('returns success for unverified email', async () => {
1778+
sendVerificationEmail.calls.reset();
1779+
const response = await request({
1780+
url: 'http://localhost:8378/1/verificationEmailRequest',
1781+
method: 'POST',
1782+
body: { email: 'unverified@example.com' },
1783+
headers: {
1784+
'X-Parse-Application-Id': Parse.applicationId,
1785+
'X-Parse-REST-API-Key': 'rest',
1786+
'Content-Type': 'application/json',
1787+
},
1788+
});
1789+
expect(response.status).toBe(200);
1790+
expect(response.data).toEqual({});
1791+
await jasmine.timeout();
1792+
expect(sendVerificationEmail).toHaveBeenCalledTimes(1);
1793+
});
1794+
1795+
it('does not send verification email for non-existent email', async () => {
1796+
sendVerificationEmail.calls.reset();
1797+
await request({
1798+
url: 'http://localhost:8378/1/verificationEmailRequest',
1799+
method: 'POST',
1800+
body: { email: 'nonexistent@example.com' },
1801+
headers: {
1802+
'X-Parse-Application-Id': Parse.applicationId,
1803+
'X-Parse-REST-API-Key': 'rest',
1804+
'Content-Type': 'application/json',
1805+
},
1806+
});
1807+
expect(sendVerificationEmail).not.toHaveBeenCalled();
1808+
});
1809+
1810+
it('does not send verification email for already verified email', async () => {
1811+
sendVerificationEmail.calls.reset();
1812+
await request({
1813+
url: 'http://localhost:8378/1/verificationEmailRequest',
1814+
method: 'POST',
1815+
body: { email: 'verified@example.com' },
1816+
headers: {
1817+
'X-Parse-Application-Id': Parse.applicationId,
1818+
'X-Parse-REST-API-Key': 'rest',
1819+
'Content-Type': 'application/json',
1820+
},
1821+
});
1822+
expect(sendVerificationEmail).not.toHaveBeenCalled();
1823+
});
1824+
});
1825+
1826+
describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => {
1827+
beforeEach(async () => {
1828+
sendVerificationEmail = jasmine.createSpy('sendVerificationEmail');
1829+
await reconfigureServer({
1830+
appName: 'test',
1831+
publicServerURL: 'http://localhost:8378/1',
1832+
verifyUserEmails: true,
1833+
emailVerifySuccessOnInvalidEmail: false,
1834+
emailAdapter: {
1835+
sendVerificationEmail,
1836+
sendPasswordResetEmail: () => Promise.resolve(),
1837+
sendMail: () => {},
1838+
},
1839+
});
1840+
await createTestUsers();
1841+
});
1842+
1843+
it('returns error for non-existent email', async () => {
1844+
const response = await request({
1845+
url: 'http://localhost:8378/1/verificationEmailRequest',
1846+
method: 'POST',
1847+
body: { email: 'nonexistent@example.com' },
1848+
headers: {
1849+
'X-Parse-Application-Id': Parse.applicationId,
1850+
'X-Parse-REST-API-Key': 'rest',
1851+
'Content-Type': 'application/json',
1852+
},
1853+
}).catch(e => e);
1854+
expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND);
1855+
});
1856+
1857+
it('returns error for already verified email', async () => {
1858+
const response = await request({
1859+
url: 'http://localhost:8378/1/verificationEmailRequest',
1860+
method: 'POST',
1861+
body: { email: 'verified@example.com' },
1862+
headers: {
1863+
'X-Parse-Application-Id': Parse.applicationId,
1864+
'X-Parse-REST-API-Key': 'rest',
1865+
'Content-Type': 'application/json',
1866+
},
1867+
}).catch(e => e);
1868+
expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE);
1869+
expect(response.data.error).toBe('Email verified@example.com is already verified.');
1870+
});
1871+
1872+
it('sends verification email for unverified email', async () => {
1873+
sendVerificationEmail.calls.reset();
1874+
await request({
1875+
url: 'http://localhost:8378/1/verificationEmailRequest',
1876+
method: 'POST',
1877+
body: { email: 'unverified@example.com' },
1878+
headers: {
1879+
'X-Parse-Application-Id': Parse.applicationId,
1880+
'X-Parse-REST-API-Key': 'rest',
1881+
'Content-Type': 'application/json',
1882+
},
1883+
});
1884+
await jasmine.timeout();
1885+
expect(sendVerificationEmail).toHaveBeenCalledTimes(1);
1886+
});
1887+
});
1888+
1889+
it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => {
1890+
const invalidValues = [[], {}, 0, 1, '', 'string'];
1891+
for (const value of invalidValues) {
1892+
await expectAsync(
1893+
reconfigureServer({
1894+
appName: 'test',
1895+
publicServerURL: 'http://localhost:8378/1',
1896+
verifyUserEmails: true,
1897+
emailVerifySuccessOnInvalidEmail: value,
1898+
emailAdapter: {
1899+
sendVerificationEmail: () => {},
1900+
sendPasswordResetEmail: () => Promise.resolve(),
1901+
sendMail: () => {},
1902+
},
1903+
})
1904+
).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value');
1905+
}
1906+
});
1907+
});

src/Config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export class Config {
200200
_publicServerURL,
201201
emailVerifyTokenValidityDuration,
202202
emailVerifyTokenReuseIfValid,
203+
emailVerifySuccessOnInvalidEmail,
203204
}) {
204205
const emailAdapter = userController.adapter;
205206
if (verifyUserEmails) {
@@ -209,6 +210,7 @@ export class Config {
209210
publicServerURL: publicServerURL || _publicServerURL,
210211
emailVerifyTokenValidityDuration,
211212
emailVerifyTokenReuseIfValid,
213+
emailVerifySuccessOnInvalidEmail,
212214
});
213215
}
214216
}
@@ -457,11 +459,12 @@ export class Config {
457459
}
458460

459461
if (
460-
passwordPolicy.resetPasswordSuccessOnInvalidEmail &&
462+
passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined &&
461463
typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean'
462464
) {
463465
throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value';
464466
}
467+
465468
}
466469
}
467470

@@ -504,6 +507,7 @@ export class Config {
504507
publicServerURL,
505508
emailVerifyTokenValidityDuration,
506509
emailVerifyTokenReuseIfValid,
510+
emailVerifySuccessOnInvalidEmail,
507511
}) {
508512
if (!emailAdapter) {
509513
throw 'An emailAdapter is required for e-mail verification and password resets.';
@@ -525,6 +529,9 @@ export class Config {
525529
if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) {
526530
throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration';
527531
}
532+
if (emailVerifySuccessOnInvalidEmail !== undefined && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') {
533+
throw 'emailVerifySuccessOnInvalidEmail must be a boolean value';
534+
}
528535
}
529536

530537
static validateFileUploadOptions(fileUpload) {

src/Options/Definitions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ module.exports.ParseServerOptions = {
197197
help: 'Adapter module for email sending',
198198
action: parsers.moduleOrObjectParser,
199199
},
200+
emailVerifySuccessOnInvalidEmail: {
201+
env: 'PARSE_SERVER_EMAIL_VERIFY_SUCCESS_ON_INVALID_EMAIL',
202+
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`.',
203+
action: parsers.booleanParser,
204+
default: true,
205+
},
200206
emailVerifyTokenReuseIfValid: {
201207
env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID',
202208
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`.',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,13 @@ export interface ParseServerOptions {
235235
Requires option `verifyUserEmails: true`.
236236
:DEFAULT: false */
237237
emailVerifyTokenReuseIfValid: ?boolean;
238+
/* 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.
239+
<br><br>
240+
Default is `true`.
241+
<br>
242+
Requires option `verifyUserEmails: true`.
243+
:DEFAULT: true */
244+
emailVerifySuccessOnInvalidEmail: ?boolean;
238245
/* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.
239246
<br><br>
240247
Default is `true`.

src/Routers/UsersRouter.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,13 @@ export class UsersRouter extends ClassesRouter {
547547
);
548548
}
549549

550+
const verifyEmailSuccessOnInvalidEmail = req.config.emailVerifySuccessOnInvalidEmail ?? true;
551+
550552
const results = await req.config.database.find('_User', { email: email }, {}, Auth.maintenance(req.config));
551553
if (!results.length || results.length < 1) {
554+
if (verifyEmailSuccessOnInvalidEmail) {
555+
return { response: {} };
556+
}
552557
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`);
553558
}
554559
const user = results[0];
@@ -557,6 +562,9 @@ export class UsersRouter extends ClassesRouter {
557562
delete user.password;
558563

559564
if (user.emailVerified) {
565+
if (verifyEmailSuccessOnInvalidEmail) {
566+
return { response: {} };
567+
}
560568
throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`);
561569
}
562570

src/Security/CheckGroups/CheckGroupServerConfig.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,30 @@ class CheckGroupServerConfig extends CheckGroup {
151151
}
152152
},
153153
}),
154+
new Check({
155+
title: 'Password reset endpoint user enumeration mitigated',
156+
warning:
157+
'The password reset endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.',
158+
solution:
159+
"Change Parse Server configuration to 'passwordPolicy.resetPasswordSuccessOnInvalidEmail: true'.",
160+
check: () => {
161+
if (config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail === false) {
162+
throw 1;
163+
}
164+
},
165+
}),
166+
new Check({
167+
title: 'Email verification endpoint user enumeration mitigated',
168+
warning:
169+
'The email verification endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.',
170+
solution:
171+
"Change Parse Server configuration to 'emailVerifySuccessOnInvalidEmail: true'.",
172+
check: () => {
173+
if (config.emailVerifySuccessOnInvalidEmail === false) {
174+
throw 1;
175+
}
176+
},
177+
}),
154178
new Check({
155179
title: 'LiveQuery regex timeout enabled',
156180
warning:

0 commit comments

Comments
 (0)