Skip to content

Commit fbda4cb

Browse files
authored
fix: Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) (#10238)
1 parent e941b15 commit fbda4cb

File tree

3 files changed

+158
-69
lines changed

3 files changed

+158
-69
lines changed

spec/PagesRouter.spec.js

Lines changed: 124 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,69 @@ describe('Pages Router', () => {
840840
followRedirects: false,
841841
});
842842
expect(formResponse.status).toEqual(303);
843+
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
844+
// page always redirects to the success page to prevent user enumeration
845+
expect(formResponse.text).toContain(
846+
`/${locale}/${pages.emailVerificationSendSuccess.defaultFile}`
847+
);
848+
});
849+
850+
it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => {
851+
config.emailVerifySuccessOnInvalidEmail = false;
852+
await reconfigureServer(config);
853+
const sendVerificationEmail = spyOn(
854+
config.emailAdapter,
855+
'sendVerificationEmail'
856+
).and.callThrough();
857+
const user = new Parse.User();
858+
user.setUsername('exampleUsername');
859+
user.setPassword('examplePassword');
860+
user.set('email', 'mail@example.com');
861+
await user.signUp();
862+
await jasmine.timeout();
863+
864+
const link = sendVerificationEmail.calls.all()[0].args[0].link;
865+
const linkWithLocale = new URL(link);
866+
linkWithLocale.searchParams.append(pageParams.locale, exampleLocale);
867+
linkWithLocale.searchParams.set(pageParams.token, 'invalidToken');
868+
869+
const linkResponse = await request({
870+
url: linkWithLocale.toString(),
871+
followRedirects: false,
872+
});
873+
expect(linkResponse.status).toBe(200);
874+
875+
const appId = linkResponse.headers['x-parse-page-param-appid'];
876+
const locale = linkResponse.headers['x-parse-page-param-locale'];
877+
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
878+
await jasmine.timeout();
879+
880+
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
881+
expect(appId).toBeDefined();
882+
expect(locale).toBe(exampleLocale);
883+
expect(publicServerUrl).toBeDefined();
884+
expect(invalidVerificationPagePath).toMatch(
885+
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
886+
);
887+
888+
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
889+
Promise.reject('failed to resend verification email')
890+
);
891+
892+
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
893+
const formResponse = await request({
894+
url: formUrl,
895+
method: 'POST',
896+
body: {
897+
locale,
898+
username: 'exampleUsername',
899+
},
900+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
901+
followRedirects: false,
902+
});
903+
expect(formResponse.status).toEqual(303);
904+
// With emailVerifySuccessOnInvalidEmail: false, the resend page
905+
// redirects to the fail page
843906
expect(formResponse.text).toContain(
844907
`/${locale}/${pages.emailVerificationSendFail.defaultFile}`
845908
);
@@ -1041,86 +1104,79 @@ describe('Pages Router', () => {
10411104
expect(response.status).not.toBe(500);
10421105
});
10431106

1044-
it('rejects locale parameter with path traversal sequences', async () => {
1045-
const pagesDir = path.join(__dirname, 'tmp-pages-locale-test');
1046-
const targetDir = path.join(__dirname, 'tmp-pages-locale-target');
1047-
1048-
try {
1049-
await fs.mkdir(pagesDir, { recursive: true });
1050-
await fs.mkdir(targetDir, { recursive: true });
1051-
1052-
// Copy required HTML files to pagesDir
1053-
const publicDir = path.resolve(__dirname, '../public');
1054-
for (const file of ['password_reset_link_invalid.html', 'password_reset.html']) {
1055-
const content = await fs.readFile(path.join(publicDir, file), 'utf-8');
1056-
await fs.writeFile(path.join(pagesDir, file), content);
1057-
}
1058-
1059-
// Place a probe file in target directory
1060-
await fs.writeFile(
1061-
path.join(targetDir, 'password_reset_link_invalid.html'),
1062-
'<html><body>secret</body></html>'
1063-
);
1064-
1065-
const traversalLocale = path.relative(pagesDir, targetDir);
1066-
await reconfigureServer({
1067-
...config,
1068-
pages: {
1069-
enableLocalization: true,
1070-
pagesPath: pagesDir,
1071-
},
1072-
});
1073-
1074-
// Without fix: file exists at traversed path → 404 (oracle)
1075-
// Without fix: file doesn't exist at traversed path → 200 (oracle)
1076-
// With fix: traversal locale is rejected, always returns default page → 200
1077-
const response = await request({
1078-
url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`,
1079-
followRedirects: false,
1080-
}).catch(e => e);
1107+
it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => {
1108+
const emailAdapter = {
1109+
sendVerificationEmail: () => {},
1110+
sendPasswordResetEmail: () => {},
1111+
sendMail: () => {},
1112+
};
1113+
await reconfigureServer({
1114+
...config,
1115+
verifyUserEmails: true,
1116+
emailVerifySuccessOnInvalidEmail: true,
1117+
emailAdapter,
1118+
});
10811119

1082-
// Should serve the default page (200), not a 404 from bounds check
1083-
expect(response.status).toBe(200);
1120+
// Create a user with unverified email
1121+
const user = new Parse.User();
1122+
user.setUsername('realuser');
1123+
user.setPassword('password123');
1124+
user.setEmail('real@example.com');
1125+
await user.signUp();
10841126

1085-
// Now remove the probe file and try again — response should be the same
1086-
await fs.rm(path.join(targetDir, 'password_reset_link_invalid.html'));
1087-
const response2 = await request({
1088-
url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`,
1089-
followRedirects: false,
1090-
}).catch(e => e);
1127+
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
10911128

1092-
// Should also be 200 — no difference reveals file existence
1093-
expect(response2.status).toBe(200);
1094-
} finally {
1095-
await fs.rm(pagesDir, { recursive: true, force: true });
1096-
await fs.rm(targetDir, { recursive: true, force: true });
1097-
}
1098-
});
1129+
// Resend for existing unverified user
1130+
const existingResponse = await request({
1131+
method: 'POST',
1132+
url: formUrl,
1133+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1134+
body: 'username=realuser',
1135+
followRedirects: false,
1136+
}).catch(e => e);
10991137

1100-
it('does not return 500 when page parameter contains CRLF characters', async () => {
1101-
await reconfigureServer(config);
1102-
const crlf = 'abc\r\nX-Injected: 1';
1103-
const url = `${config.publicServerURL}/apps/choose_password?appId=test&token=${encodeURIComponent(crlf)}&username=testuser`;
1104-
const response = await request({
1105-
url: url,
1138+
// Resend for non-existing user
1139+
const nonExistingResponse = await request({
1140+
method: 'POST',
1141+
url: formUrl,
1142+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1143+
body: 'username=fakeuser',
11061144
followRedirects: false,
11071145
}).catch(e => e);
1108-
expect(response.status).not.toBe(500);
1109-
expect(response.status).toBe(200);
1146+
1147+
// Both should redirect to the same page (success) to prevent enumeration
1148+
expect(existingResponse.status).toBe(303);
1149+
expect(nonExistingResponse.status).toBe(303);
1150+
expect(existingResponse.headers.location).toContain('email_verification_send_success');
1151+
expect(nonExistingResponse.headers.location).toContain('email_verification_send_success');
11101152
});
11111153

1112-
it('does not return 500 when page parameter contains CRLF characters in redirect response', async () => {
1113-
await reconfigureServer(config);
1114-
const crlf = 'abc\r\nX-Injected: 1';
1115-
const url = `${config.publicServerURL}/apps/test/resend_verification_email`;
1116-
const response = await request({
1154+
it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => {
1155+
const emailAdapter = {
1156+
sendVerificationEmail: () => {},
1157+
sendPasswordResetEmail: () => {},
1158+
sendMail: () => {},
1159+
};
1160+
await reconfigureServer({
1161+
...config,
1162+
verifyUserEmails: true,
1163+
emailVerifySuccessOnInvalidEmail: false,
1164+
emailAdapter,
1165+
});
1166+
1167+
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
1168+
1169+
// Resend for non-existing user should redirect to fail page
1170+
const nonExistingResponse = await request({
11171171
method: 'POST',
1118-
url: url,
1172+
url: formUrl,
11191173
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1120-
body: `username=${encodeURIComponent(crlf)}`,
1174+
body: 'username=fakeuser',
11211175
followRedirects: false,
11221176
}).catch(e => e);
1123-
expect(response.status).not.toBe(500);
1177+
1178+
expect(nonExistingResponse.status).toBe(303);
1179+
expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail');
11241180
});
11251181
});
11261182

spec/ValidationAndPasswordsReset.spec.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
740740
});
741741
});
742742

743-
it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => {
743+
it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => {
744744
reconfigureServer({
745745
appName: 'emailing app',
746746
verifyUserEmails: true,
@@ -750,6 +750,35 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
750750
sendMail: () => {},
751751
},
752752
publicServerURL: 'http://localhost:8378/1',
753+
}).then(() => {
754+
request({
755+
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
756+
method: 'POST',
757+
followRedirects: false,
758+
body: {
759+
username: 'sadfasga',
760+
},
761+
}).then(response => {
762+
expect(response.status).toEqual(303);
763+
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
764+
// page redirects to success to prevent user enumeration
765+
expect(response.text).toContain('email_verification_send_success.html');
766+
done();
767+
});
768+
});
769+
});
770+
771+
it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => {
772+
reconfigureServer({
773+
appName: 'emailing app',
774+
verifyUserEmails: true,
775+
emailVerifySuccessOnInvalidEmail: false,
776+
emailAdapter: {
777+
sendVerificationEmail: () => Promise.resolve(),
778+
sendPasswordResetEmail: () => Promise.resolve(),
779+
sendMail: () => {},
780+
},
781+
publicServerURL: 'http://localhost:8378/1',
753782
}).then(() => {
754783
request({
755784
url: 'http://localhost:8378/1/apps/test/resend_verification_email',

src/Routers/PagesRouter.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,16 @@ export class PagesRouter extends PromiseRouter {
120120
}
121121

122122
const userController = config.userController;
123+
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
123124

124125
return userController.resendVerificationEmail(username, req, token).then(
125126
() => {
126127
return this.goToPage(req, pages.emailVerificationSendSuccess);
127128
},
128129
() => {
130+
if (suppressError) {
131+
return this.goToPage(req, pages.emailVerificationSendSuccess);
132+
}
129133
return this.goToPage(req, pages.emailVerificationSendFail);
130134
}
131135
);

0 commit comments

Comments
 (0)