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
221 changes: 221 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7996,4 +7996,225 @@ describe('Vulnerabilities', () => {
});
}
});

describe('(GHSA-46jj-qw3p-48fc) Error when sending verification or password reset email', () => {
const { resolvingPromise, sleep } = require('../lib/TestUtils');
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
};
const post = (path, body) =>
request({
method: 'POST',
url: `http://localhost:8378/1${path}`,
headers,
body,
});
const signUp = () =>
post('/users', { username: 'user', password: 'password', email: 'user@example.com' });
const error = new Error('mail provider error');

let unhandled;
let loggerErrorSpy;
const onUnhandled = reason => unhandled.push(reason);
beforeEach(() => {
unhandled = [];
process.on('unhandledRejection', onUnhandled);
});
afterEach(() => {
process.removeListener('unhandledRejection', onUnhandled);
});

const reconfigure = async options => {
await reconfigureServer({
appName: 'test',
publicServerURL: 'http://localhost:8378/1',
...options,
});
loggerErrorSpy = spyOn(require('../lib/logger').default, 'error').and.callFake(() => {});
};
const errorLogged = message =>
loggerErrorSpy.calls.allArgs().some(args => args[0] === message);
const expectErrorLogged = async (message, reason) => {
for (let i = 0; i < 100 && !unhandled.length && !errorLogged(message); i++) {
await sleep(10);
}
expect(unhandled).toEqual([]);
expect(loggerErrorSpy).toHaveBeenCalledWith(message, reason);
};

describe('verification email', () => {
const message = 'Failed to send verification email';

it('handles rejection of email adapter on sign-up', async () => {
await reconfigure({
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => Promise.reject(error),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve(),
},
});
const res = await signUp();
expect(res.status).toBe(201);
await expectErrorLogged(message, error);
});

it('handles synchronous error of email adapter on sign-up', async () => {
await reconfigure({
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => {
throw error;
},
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve(),
},
});
const res = await signUp();
expect(res.status).toBe(201);
await expectErrorLogged(message, error);
});

it('handles rejection of email adapter sendMail on sign-up', async () => {
await reconfigure({
verifyUserEmails: true,
emailAdapter: {
sendMail: () => Promise.reject(error),
},
});
const res = await signUp();
expect(res.status).toBe(201);
await expectErrorLogged(message, error);
});

it('handles error of sendUserEmailVerification on sign-up', async () => {
await reconfigure({
verifyUserEmails: true,
sendUserEmailVerification: () => {
throw error;
},
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve(),
},
});
const res = await signUp();
expect(res.status).toBe(201);
await expectErrorLogged(message, error);
});

it('handles user deleted before verification email is sent', async () => {
await reconfigure({
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve(),
},
});
const userController = Config.get('test').userController;
const getUserIfNeeded = userController.getUserIfNeeded.bind(userController);
const userDeleted = resolvingPromise();
spyOn(userController, 'getUserIfNeeded').and.callFake(async user => {
await userDeleted;
return getUserIfNeeded(user);
});
const res = await signUp();
expect(res.status).toBe(201);
await request({
method: 'DELETE',
url: `http://localhost:8378/1/users/${res.data.objectId}`,
headers: { ...headers, 'X-Parse-Master-Key': 'test' },
});
userDeleted.resolve();
await expectErrorLogged(message, undefined);
});

describe('resend', () => {
let firstSend;
beforeEach(async () => {
firstSend = resolvingPromise();
let sendCount = 0;
await reconfigure({
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => {
sendCount++;
if (sendCount === 1) {
firstSend.resolve();
return Promise.resolve();
}
return Promise.reject(error);
},
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve(),
},
});
await signUp();
await firstSend;
});

it('handles rejection of email adapter on verification email request', async () => {
const res = await post('/verificationEmailRequest', { email: 'user@example.com' });
expect(res.status).toBe(200);
expect(res.data).toEqual({});
await expectErrorLogged(message, error);
});

it('handles rejection of email adapter on resend verification email page', async () => {
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
followRedirects: false,
body: { username: 'user' },
});
expect(res.status).toBe(303);
expect(res.text).toContain('email_verification_send_success.html');
await expectErrorLogged(message, error);
});
});
});

describe('password reset email', () => {
const message = 'Failed to send password reset email';

const requestPasswordReset = async emailAdapter => {
await reconfigure({ emailAdapter });
await signUp();
const res = await post('/requestPasswordReset', { email: 'user@example.com' });
expect(res.status).toBe(200);
expect(res.data).toEqual({});
};

it('handles rejection of email adapter', async () => {
await requestPasswordReset({
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.reject(error),
sendMail: () => Promise.resolve(),
});
await expectErrorLogged(message, error);
});

it('handles synchronous error of email adapter', async () => {
await requestPasswordReset({
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
throw error;
},
sendMail: () => Promise.resolve(),
});
await expectErrorLogged(message, error);
});

it('handles rejection of email adapter sendMail', async () => {
await requestPasswordReset({
sendMail: () => Promise.reject(error),
});
await expectErrorLogged(message, error);
});
});
});
});
83 changes: 49 additions & 34 deletions src/Controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import rest from '../rest';
import Parse from 'parse/node';
import AccountLockout from '../AccountLockout';
import Config from '../Config';
import logger from '../logger';

var RestQuery = require('../RestQuery');
var Auth = require('../Auth');
Expand Down Expand Up @@ -150,37 +151,42 @@ export class UserController extends AdaptableController {
return result.results[0];
}

// Never rejects; errors are logged
async sendVerificationEmail(user, req) {
if (!this.shouldVerifyEmails) {
return;
}
const token = encodeURIComponent(user._email_verify_token);
// We may need to fetch the user in case of update email; only use the `fetchedUser`
// from this point onwards; do not use the `user` as it may not contain all fields.
const fetchedUser = await this.getUserIfNeeded(user);
let shouldSendEmail = this.config.sendUserEmailVerification;
if (typeof shouldSendEmail === 'function') {
const response = await Promise.resolve(
this.config.sendUserEmailVerification({
user: Parse.Object.fromJSON({ className: '_User', ...fetchedUser }),
master: req.auth?.isMaster,
})
try {
if (!this.shouldVerifyEmails) {
return;
}
const token = encodeURIComponent(user._email_verify_token);
// We may need to fetch the user in case of update email; only use the `fetchedUser`
// from this point onwards; do not use the `user` as it may not contain all fields.
const fetchedUser = await this.getUserIfNeeded(user);
let shouldSendEmail = this.config.sendUserEmailVerification;
if (typeof shouldSendEmail === 'function') {
const response = await Promise.resolve(
this.config.sendUserEmailVerification({
user: Parse.Object.fromJSON({ className: '_User', ...fetchedUser }),
master: req.auth?.isMaster,
})
);
shouldSendEmail = !!response;
}
if (!shouldSendEmail) {
return;
}
const link = buildEmailLink(this.config.verifyEmailURL, token, this.config);
const options = {
appName: this.config.appName,
link: link,
user: inflate('_User', fetchedUser),
};
sendEmail('verification', () =>
this.adapter.sendVerificationEmail
? this.adapter.sendVerificationEmail(options)
: this.adapter.sendMail(this.defaultVerificationEmail(options))
);
shouldSendEmail = !!response;
}
if (!shouldSendEmail) {
return;
}
const link = buildEmailLink(this.config.verifyEmailURL, token, this.config);
const options = {
appName: this.config.appName,
link: link,
user: inflate('_User', fetchedUser),
};
if (this.adapter.sendVerificationEmail) {
this.adapter.sendVerificationEmail(options);
} else {
this.adapter.sendMail(this.defaultVerificationEmail(options));
} catch (error) {
logger.error('Failed to send verification email', error);
}
}

Expand Down Expand Up @@ -289,11 +295,11 @@ export class UserController extends AdaptableController {
user: inflate('_User', user),
};

if (this.adapter.sendPasswordResetEmail) {
this.adapter.sendPasswordResetEmail(options);
} else {
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
}
sendEmail('password reset', () =>
this.adapter.sendPasswordResetEmail
? this.adapter.sendPasswordResetEmail(options)
: this.adapter.sendMail(this.defaultResetPasswordEmail(options))
);

return Promise.resolve(user);
}
Expand Down Expand Up @@ -369,6 +375,15 @@ function updateUserPassword(user, password, config) {
.then(() => user);
}

// Never rejects; errors are logged
async function sendEmail(name, send) {
try {
await send();
} catch (error) {
logger.error(`Failed to send ${name} email`, error);
}
}

function buildEmailLink(destination, token, config) {
token = `token=${token}`;
if (config.parseFrameURL) {
Expand Down
Loading