🔒 Fixed magic link endpoint sending multiple emails

refs https://github.com/TryGhost/Team/issues/2024

Without validation it was possible to send a string of comma separated
email addresses to the endpoint, and an email would be sent to each
address, bypassing any rate limiting.

This bug does not allow for an authentication bypass exploit. It is purely a
spam email concern.

Credit: Sandip Maity <maitysandip925@gmail.com>
This commit is contained in:
Fabien "egg" O'Carroll 2022-10-04 17:17:26 +01:00
parent a631392a4f
commit 28de1720c1
4 changed files with 70 additions and 17 deletions

View File

@ -27,6 +27,15 @@ describe('sendMagicLink', function () {
mockManager.restore(); mockManager.restore();
}); });
it('Errors when passed multiple emails', async function () {
await membersAgent.post('/api/send-magic-link')
.body({
email: 'one@test.com,two@test.com',
emailType: 'signup'
})
.expectStatus(400);
});
it('Creates a valid magic link with tokenData, and without urlHistory', async function () { it('Creates a valid magic link with tokenData, and without urlHistory', async function () {
const email = 'newly-created-user-magic-link-test@test.com'; const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent.post('/api/send-magic-link') await membersAgent.post('/api/send-magic-link')

View File

@ -1,4 +1,9 @@
const {IncorrectUsageError} = require('@tryghost/errors'); const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors');
const {isEmail} = require('@tryghost/validator');
const tpl = require('@tryghost/tpl');
const messages = {
invalidEmail: 'Email is not valid'
};
/** /**
* @typedef { import('nodemailer').Transporter } MailTransporter * @typedef { import('nodemailer').Transporter } MailTransporter
@ -52,6 +57,11 @@ class MagicLink {
* @returns {Promise<{token: Token, info: SentMessageInfo}>} * @returns {Promise<{token: Token, info: SentMessageInfo}>}
*/ */
async sendMagicLink(options) { async sendMagicLink(options) {
if (!isEmail(options.email)) {
throw new BadRequestError({
message: tpl(messages.invalidEmail)
});
}
const token = await this.tokenProvider.create(options.tokenData); const token = await this.tokenProvider.create(options.tokenData);
const type = options.type || 'signin'; const type = options.type || 'signin';

View File

@ -25,6 +25,8 @@
}, },
"dependencies": { "dependencies": {
"@tryghost/errors": "1.2.17", "@tryghost/errors": "1.2.17",
"@tryghost/tpl": "0.1.18",
"@tryghost/validator": "0.1.29",
"jsonwebtoken": "8.5.1" "jsonwebtoken": "8.5.1"
} }
} }

View File

@ -1,4 +1,4 @@
const should = require('should'); const assert = require('assert');
const sinon = require('sinon'); const sinon = require('sinon');
const MagicLink = require('../'); const MagicLink = require('../');
const crypto = require('crypto'); const crypto = require('crypto');
@ -8,10 +8,42 @@ const secret = crypto.randomBytes(64);
describe('MagicLink', function () { describe('MagicLink', function () {
it('Exports a function', function () { it('Exports a function', function () {
should.equal(typeof MagicLink, 'function'); assert.equal(typeof MagicLink, 'function');
}); });
describe('#sendMagicLink', function () { describe('#sendMagicLink', function () {
it('Throws when passed comma separated emails', async function () {
const options = {
tokenProvider: new MagicLink.JWTTokenProvider(secret),
getSigninURL: sandbox.stub().returns('FAKEURL'),
getText: sandbox.stub().returns('SOMETEXT'),
getHTML: sandbox.stub().returns('SOMEHTML'),
getSubject: sandbox.stub().returns('SOMESUBJECT'),
transporter: {
sendMail: sandbox.stub().resolves()
}
};
const service = new MagicLink(options);
const args = {
email: 'one@email.com,two@email.com',
tokenData: {
id: '420'
},
type: 'blazeit',
referrer: 'https://whatever.com'
};
let errored = false;
try {
await service.sendMagicLink(args);
} catch (err) {
errored = true;
} finally {
assert(errored, 'sendMagicLink should error when given comma separated emails');
}
});
it('Sends an email to the user with a link generated from getSigninURL(token, type)', async function () { it('Sends an email to the user with a link generated from getSigninURL(token, type)', async function () {
const options = { const options = {
tokenProvider: new MagicLink.JWTTokenProvider(secret), tokenProvider: new MagicLink.JWTTokenProvider(secret),
@ -35,23 +67,23 @@ describe('MagicLink', function () {
}; };
const {token} = await service.sendMagicLink(args); const {token} = await service.sendMagicLink(args);
should.ok(options.getSigninURL.calledOnce); assert(options.getSigninURL.calledOnce);
should.ok(options.getSigninURL.firstCall.calledWithExactly(token, 'blazeit', 'https://whatever.com')); assert(options.getSigninURL.firstCall.calledWithExactly(token, 'blazeit', 'https://whatever.com'));
should.ok(options.getText.calledOnce); assert(options.getText.calledOnce);
should.ok(options.getText.firstCall.calledWithExactly('FAKEURL', 'blazeit', 'test@example.com')); assert(options.getText.firstCall.calledWithExactly('FAKEURL', 'blazeit', 'test@example.com'));
should.ok(options.getHTML.calledOnce); assert(options.getHTML.calledOnce);
should.ok(options.getHTML.firstCall.calledWithExactly('FAKEURL', 'blazeit', 'test@example.com')); assert(options.getHTML.firstCall.calledWithExactly('FAKEURL', 'blazeit', 'test@example.com'));
should.ok(options.getSubject.calledOnce); assert(options.getSubject.calledOnce);
should.ok(options.getSubject.firstCall.calledWithExactly('blazeit')); assert(options.getSubject.firstCall.calledWithExactly('blazeit'));
should.ok(options.transporter.sendMail.calledOnce); assert(options.transporter.sendMail.calledOnce);
should.equal(options.transporter.sendMail.firstCall.args[0].to, args.email); assert.equal(options.transporter.sendMail.firstCall.args[0].to, args.email);
should.equal(options.transporter.sendMail.firstCall.args[0].subject, options.getSubject.firstCall.returnValue); assert.equal(options.transporter.sendMail.firstCall.args[0].subject, options.getSubject.firstCall.returnValue);
should.equal(options.transporter.sendMail.firstCall.args[0].text, options.getText.firstCall.returnValue); assert.equal(options.transporter.sendMail.firstCall.args[0].text, options.getText.firstCall.returnValue);
should.equal(options.transporter.sendMail.firstCall.args[0].html, options.getHTML.firstCall.returnValue); assert.equal(options.transporter.sendMail.firstCall.args[0].html, options.getHTML.firstCall.returnValue);
}); });
}); });
@ -78,7 +110,7 @@ describe('MagicLink', function () {
const {token} = await service.sendMagicLink(args); const {token} = await service.sendMagicLink(args);
const data = await service.getDataFromToken(token); const data = await service.getDataFromToken(token);
should.deepEqual(data.id, args.tokenData.id); assert.deepEqual(data.id, args.tokenData.id);
}); });
}); });
}); });