Skip to content

Commit 1f7f3e7

Browse files
authored
🔒 Prevented homograph attacks with punycode domains on member signin (#24457)
ref https://linear.app/ghost/issue/ENG-2462 Added normalization to email addresses for member signup / signin to prevent homograph attacks with punycode domains. This change should be backwards compatible with existing members using emails with unicode characters
1 parent abc1ff7 commit 1f7f3e7

File tree

5 files changed

+175
-11
lines changed

5 files changed

+175
-11
lines changed

‎ghost/core/core/server/services/members/members-api/controllers/RouterController.js

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const sanitizeHtml = require('sanitize-html');
44
const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureError, NotFoundError} = require('@tryghost/errors');
55
const errors = require('@tryghost/errors');
66
const {isEmail} = require('@tryghost/validator');
7+
const normalizeEmail = require('../utils/normalize-email');
78

89
const messages = {
910
emailRequired: 'Email is required.',
@@ -585,6 +586,23 @@ module.exports = class RouterController {
585586
});
586587
}
587588

589+
// Normalize email to prevent homograph attacks
590+
let normalizedEmail;
591+
592+
try {
593+
normalizedEmail = normalizeEmail(email);
594+
595+
if (normalizedEmail !== email) {
596+
logging.info(`Email normalized from ${email} to ${normalizedEmail} for magic link`);
597+
}
598+
} catch (err) {
599+
logging.error(`Failed to normalize [${email}]: ${err.message}`);
600+
601+
throw new errors.BadRequestError({
602+
message: tpl(messages.invalidEmail)
603+
});
604+
}
605+
588606
if (honeypot) {
589607
logging.warn('Honeypot field filled, this is likely a bot');
590608

@@ -606,9 +624,9 @@ module.exports = class RouterController {
606624

607625
try {
608626
if (emailType === 'signup' || emailType === 'subscribe') {
609-
await this._handleSignup(req, referrer);
627+
await this._handleSignup(req, normalizedEmail, referrer);
610628
} else {
611-
await this._handleSignin(req, referrer);
629+
await this._handleSignin(req, normalizedEmail, referrer);
612630
}
613631

614632
res.writeHead(201);
@@ -626,7 +644,7 @@ module.exports = class RouterController {
626644
}
627645
}
628646

629-
async _handleSignup(req, referrer = null) {
647+
async _handleSignup(req, normalizedEmail, referrer = null) {
630648
if (!this._allowSelfSignup()) {
631649
if (this._settingsCache.get('members_signup_access') === 'paid') {
632650
throw new errors.BadRequestError({
@@ -640,14 +658,14 @@ module.exports = class RouterController {
640658
}
641659

642660
const blockedEmailDomains = this._settingsCache.get('all_blocked_email_domains');
643-
const emailDomain = req.body.email.split('@')[1]?.toLowerCase();
661+
const emailDomain = normalizedEmail.split('@')[1]?.toLowerCase();
644662
if (emailDomain && blockedEmailDomains.includes(emailDomain)) {
645663
throw new errors.BadRequestError({
646664
message: tpl(messages.blockedEmailDomain)
647665
});
648666
}
649667

650-
const {email, emailType} = req.body;
668+
const {emailType} = req.body;
651669

652670
const tokenData = {
653671
labels: req.body.labels,
@@ -657,13 +675,13 @@ module.exports = class RouterController {
657675
attribution: await this._memberAttributionService.getAttribution(req.body.urlHistory)
658676
};
659677

660-
return await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer});
678+
return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
661679
}
662680

663-
async _handleSignin(req, referrer = null) {
664-
const {email, emailType} = req.body;
681+
async _handleSignin(req, normalizedEmail, referrer = null) {
682+
const {emailType} = req.body;
665683

666-
const member = await this._memberRepository.get({email});
684+
const member = await this._memberRepository.get({email: normalizedEmail});
667685

668686
if (!member) {
669687
throw new errors.BadRequestError({
@@ -672,7 +690,7 @@ module.exports = class RouterController {
672690
}
673691

674692
const tokenData = {};
675-
return await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer});
693+
return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
676694
}
677695

678696
/**
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const punycode = require('punycode/');
2+
3+
/**
4+
* Normalizes email addresses by converting Unicode domains to ASCII (punycode)
5+
* This prevents homograph attacks where Unicode characters are used to spoof
6+
* domains
7+
*
8+
* @param {string} email The email address to normalize
9+
* @returns {string} The normalized email address
10+
* @throws {Error} When punycode conversion fails
11+
*/
12+
function normalizeEmail(email) {
13+
if (!email || typeof email !== 'string') {
14+
return null;
15+
}
16+
17+
const atIndex = email.lastIndexOf('@');
18+
19+
if (atIndex === -1) {
20+
return email;
21+
}
22+
23+
const localPart = email.substring(0, atIndex);
24+
const domainPart = email.substring(atIndex + 1);
25+
26+
const asciiDomain = punycode.toASCII(domainPart);
27+
28+
return `${localPart}@${asciiDomain}`;
29+
}
30+
31+
module.exports = normalizeEmail;

‎ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`sendMagicLink Homograph attack prevention should prevent homograph attacks by normalizing unicode domains 1: [body] 1`] = `
4+
Object {
5+
"errors": Array [
6+
Object {
7+
"code": null,
8+
"context": null,
9+
"details": null,
10+
"ghostErrorCode": null,
11+
"help": null,
12+
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
13+
"message": "No member exists with this e-mail address. Please sign up first.",
14+
"property": null,
15+
"type": "BadRequestError",
16+
},
17+
],
18+
}
19+
`;
20+
321
exports[`sendMagicLink Throws an error when logging in to a email that does not exist (invite only) 1: [body] 1`] = `
422
Object {
523
"errors": Array [

‎ghost/core/test/e2e-api/members/send-magic-link.test.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,5 +413,49 @@ describe('sendMagicLink', function () {
413413
.expectStatus(201);
414414
});
415415
});
416-
});
417416

417+
describe('Homograph attack prevention', function () {
418+
it('should prevent homograph attacks by normalizing unicode domains', async function () {
419+
const asciiEmail = '[email protected]';
420+
421+
await membersAgent.post('/api/send-magic-link')
422+
.body({
423+
email: asciiEmail,
424+
emailType: 'signup'
425+
})
426+
.expectStatus(201);
427+
428+
const unicodeEmail = 'user@exаmple.com'; // Using Cyrillic 'а'
429+
430+
await membersAgent.post('/api/send-magic-link')
431+
.body({
432+
email: unicodeEmail,
433+
emailType: 'signin'
434+
})
435+
.expectStatus(400)
436+
.matchBodySnapshot({
437+
errors: [{
438+
id: anyErrorId,
439+
message: 'No member exists with this e-mail address. Please sign up first.'
440+
}]
441+
});
442+
});
443+
444+
it('should normalize unicode domains for signup', async function () {
445+
const unicodeEmail = 'user@tëst.com';
446+
447+
await membersAgent.post('/api/send-magic-link')
448+
.body({
449+
email: unicodeEmail,
450+
emailType: 'signup'
451+
})
452+
.expectStatus(201);
453+
454+
const mail = mockManager.assert.sentEmail({
455+
to: '[email protected]' // Punycode version
456+
});
457+
458+
should.exist(mail);
459+
});
460+
});
461+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const should = require('should');
2+
const normalizeEmail = require('../../../../../../../core/server/services/members/members-api/utils/normalize-email');
3+
4+
describe('normalizeEmail', function () {
5+
it('should normalize unicode domains to punycode', function () {
6+
normalizeEmail('test@еxample.com').should.equal('[email protected]');
7+
normalizeEmail('user@tëst.org').should.equal('[email protected]');
8+
normalizeEmail('foo@bär.baz').should.equal('[email protected]');
9+
});
10+
11+
it('should preserve ASCII domains unchanged', function () {
12+
normalizeEmail('[email protected]').should.equal('[email protected]');
13+
normalizeEmail('[email protected]').should.equal('[email protected]');
14+
normalizeEmail('[email protected]').should.equal('[email protected]');
15+
});
16+
17+
it('should preserve unicode in the local part of the email', function () {
18+
normalizeEmail('ü[email protected]').should.equal('ü[email protected]');
19+
normalizeEmail('të[email protected]').should.equal('të[email protected]');
20+
normalizeEmail('用户@example.com').should.equal('用户@example.com');
21+
});
22+
23+
it('should handle already punycoded domains', function () {
24+
normalizeEmail('[email protected]').should.equal('[email protected]');
25+
normalizeEmail('[email protected]').should.equal('[email protected]');
26+
});
27+
28+
it('should preserve the case of the email address', function () {
29+
normalizeEmail('[email protected]').should.equal('[email protected]');
30+
normalizeEmail('[email protected]').should.equal('[email protected]');
31+
});
32+
33+
it('should handle edge cases gracefully', function () {
34+
should.not.exist(normalizeEmail(null));
35+
should.not.exist(normalizeEmail(undefined));
36+
should.not.exist(normalizeEmail(''));
37+
normalizeEmail('invalid-email').should.equal('invalid-email');
38+
normalizeEmail('@example.com').should.equal('@example.com');
39+
normalizeEmail('user@').should.equal('user@');
40+
});
41+
42+
it('should handle non-string inputs', function () {
43+
should.not.exist(normalizeEmail(123));
44+
should.not.exist(normalizeEmail({}));
45+
should.not.exist(normalizeEmail([]));
46+
should.not.exist(normalizeEmail(true));
47+
});
48+
49+
it('should handle multiple @ symbols by using the last one', function () {
50+
normalizeEmail('user@[email protected]').should.equal('user@[email protected]');
51+
normalizeEmail('user@name@tëst.com').should.equal('user@[email protected]');
52+
});
53+
});

0 commit comments

Comments
 (0)