Skip to content

Commit 1cd6155

Browse files
authored
Merge pull request #20267 from mozilla/fxa-12710
fix(auth): reject non-ASCII email local parts at validation
2 parents 2901752 + a710c1e commit 1cd6155

10 files changed

Lines changed: 93 additions & 51 deletions

packages/fxa-auth-server/lib/routes/validators.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,14 @@ module.exports.isValidEmailAddress = function (value) {
193193
return false;
194194
}
195195

196-
if (!EMAIL_USER.test(punycode.toASCII(parts[0]))) {
196+
// Reject non-ASCII characters in the local part.
197+
// AWS SES does not support SMTPUTF8 (RFC 6531), so we cannot
198+
// deliver email to addresses with non-ASCII local parts.
199+
// See: https://docs.aws.amazon.com/ses/latest/APIReference/API_Destination.html
200+
// Note: punycode.toASCII() is intentionally NOT used here because
201+
// it would encode non-ASCII chars (e.g. 'andrée' → 'xn--andre-9ua'),
202+
// masking the problem.
203+
if (!EMAIL_USER.test(parts[0])) {
197204
return false;
198205
}
199206

packages/fxa-auth-server/lib/routes/validators.spec.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ describe('lib/routes/validators:', () => {
1717
expect(
1818
validators.isValidEmailAddress('.+#$!%&|*/+-=?^_{}~`@example.com')
1919
).toBe(true);
20-
expect(validators.isValidEmailAddress('Δ٢@example.com')).toBe(true);
21-
expect(validators.isValidEmailAddress('🦀🧙@example.com')).toBe(true);
20+
// Non-ASCII local parts are rejected (SES doesn't support SMTPUTF8)
21+
expect(validators.isValidEmailAddress('Δ٢@example.com')).toBe(false);
22+
expect(validators.isValidEmailAddress('🦀🧙@example.com')).toBe(false);
2223
expect(
2324
validators.isValidEmailAddress(
2425
`${new Array(64).fill('a').join('')}@example.com`
@@ -1052,4 +1053,14 @@ describe('lib/routes/validators:', () => {
10521053
).toBeTruthy();
10531054
});
10541055
});
1056+
1057+
it('isValidEmailAddress rejects non-ASCII local parts', () => {
1058+
// Accented letters
1059+
expect(validators.isValidEmailAddress('café@restmail.net')).toBe(false);
1060+
expect(validators.isValidEmailAddress('naï[email protected]')).toBe(false);
1061+
expect(validators.isValidEmailAddress([email protected]')).toBe(false);
1062+
// Invisible unicode (zero-width space, RTL mark)
1063+
expect(validators.isValidEmailAddress('foo\[email protected]')).toBe(false);
1064+
expect(validators.isValidEmailAddress('foo\[email protected]')).toBe(false);
1065+
});
10551066
});

packages/fxa-auth-server/lib/senders/email.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,16 @@ module.exports = function (log, config, bounces, statsd) {
506506

507507
const to = message.email;
508508

509+
// Skip non-ASCII local parts. SES doesn't support SMTPUTF8, so these
510+
// always fail at RCPT TO. Silently return (like bounce check below)
511+
// to avoid Sentry noise from pre-existing accounts.
512+
const [localPart] = to.split('@');
513+
if (/[^\x20-\x7E]/.test(localPart)) {
514+
statsd.increment('email.nonAsciiLocalPart', { template });
515+
log.warn('mailer.send.nonAsciiLocalPart', { to, template });
516+
return;
517+
}
518+
509519
try {
510520
await bounces.check(to, template);
511521
} catch (err) {

packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce(
1313

1414
const config = require('../config').default.getProperties();
1515
const mocks = require('../test/mocks');
16+
const TEST_PREFIX = `test-lib-subscription-account-reminders:${
17+
process.env.JEST_WORKER_ID || '1'
18+
}:`;
1619

1720
describe('#integration - lib/subscription-account-reminders', () => {
1821
let log: any, mockConfig: any, redis: any, subscriptionAccountReminders: any;
@@ -30,7 +33,7 @@ describe('#integration - lib/subscription-account-reminders', () => {
3033
redis: {
3134
maxConnections: 1,
3235
minConnections: 1,
33-
prefix: 'test-subscription-account-reminders-lib:',
36+
prefix: TEST_PREFIX,
3437
},
3538
},
3639
};

packages/fxa-auth-server/test/remote/account_create.in.spec.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -627,19 +627,15 @@ describe.each(testVersions)(
627627
expect(emailData.headers['x-service-id']).toBe('bar');
628628
});
629629

630-
it('account creation works with unicode email address', async () => {
630+
it('account creation rejects unicode email address', async () => {
631631
const email = server.uniqueUnicodeEmail();
632632

633-
const client = await Client.create(
634-
server.publicUrl,
635-
email,
636-
'foo',
637-
testOptions
638-
);
639-
expect(client).toBeTruthy();
640-
641-
const emailData = await server.mailbox.waitForEmail(email);
642-
expect(emailData).toBeTruthy();
633+
try {
634+
await Client.create(server.publicUrl, email, 'foo', testOptions);
635+
throw new Error('should have failed');
636+
} catch (err: any) {
637+
expect(err.errno).toBe(107);
638+
}
643639
});
644640

645641
it('account creation fails with invalid metricsContext flowId', async () => {

packages/fxa-auth-server/test/remote/account_login.in.spec.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,24 +125,15 @@ describe.each(testVersions)(
125125
expect(c.keyFetchToken).toBeNull();
126126
});
127127

128-
it('login works with unicode email address', async () => {
128+
it('login rejects unicode email address', async () => {
129129
const email = server.uniqueUnicodeEmail();
130-
const password = 'wibble';
131-
await Client.createAndVerify(
132-
server.publicUrl,
133-
email,
134-
password,
135-
server.mailbox,
136-
testOptions
137-
);
138130

139-
const client = await Client.login(
140-
server.publicUrl,
141-
email,
142-
password,
143-
testOptions
144-
);
145-
expect(client).toBeTruthy();
131+
try {
132+
await Client.login(server.publicUrl, email, 'wibble', testOptions);
133+
throw new Error('should have failed');
134+
} catch (err: any) {
135+
expect(err.errno).toBe(107);
136+
}
146137
});
147138

148139
it('account login works with minimal metricsContext metadata', async () => {

packages/fxa-auth-server/test/remote/account_profile.in.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,20 @@ describe.each(testVersions)(
180180

181181
describe('when the profile data is not default', () => {
182182
describe('when the email address is unicode', () => {
183-
it('returns the email address correctly with the profile data', async () => {
183+
it('rejects unicode email address', async () => {
184184
const email = server.uniqueUnicodeEmail();
185-
const client = await Client.create(
186-
server.publicUrl,
187-
email,
188-
'password',
189-
testOptions
190-
);
191-
const response = await client.accountProfile();
192-
expect(response.email).toBe(email);
185+
186+
try {
187+
await Client.create(
188+
server.publicUrl,
189+
email,
190+
'password',
191+
testOptions
192+
);
193+
throw new Error('should have failed');
194+
} catch (err: any) {
195+
expect(err.errno).toBe(107);
196+
}
193197
});
194198
});
195199

packages/fxa-auth-server/test/remote/account_status.in.spec.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,15 @@ describe.each(testVersions)(
130130
}
131131
});
132132

133-
it('account status by email works with unicode email address', async () => {
133+
it('account status rejects unicode email address', async () => {
134134
const email = server.uniqueUnicodeEmail();
135-
const c = await Client.create(
136-
server.publicUrl,
137-
email,
138-
'password',
139-
testOptions
140-
);
141-
const response = await c.api.accountStatusByEmail(email);
142-
expect(response.exists).toBeTruthy();
135+
136+
try {
137+
await Client.create(server.publicUrl, email, 'password', testOptions);
138+
throw new Error('should have failed');
139+
} catch (err: any) {
140+
expect(err.errno).toBe(107);
141+
}
143142
});
144143
}
145144
);

packages/fxa-auth-server/test/remote/email_validity.in.spec.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ describe.each(testVersions)(
7373
7474
7575
76-
`${String.fromCharCode(1234)}@example.com`,
7776
`test@${String.fromCharCode(5678)}.com`,
7877
];
7978

@@ -90,5 +89,22 @@ describe.each(testVersions)(
9089
)
9190
);
9291
});
92+
93+
it('/account/create rejects non-ASCII local parts but accepts unicode domains', async () => {
94+
const pwd = '123456';
95+
96+
// Unicode local part should be rejected
97+
try {
98+
await Client.create(
99+
server.publicUrl,
100+
`${String.fromCharCode(1234)}@example.com`,
101+
pwd,
102+
testOptions
103+
);
104+
throw new Error('should have rejected unicode local part');
105+
} catch (err: any) {
106+
expect(err.errno).toBe(107);
107+
}
108+
});
93109
}
94110
);

packages/fxa-auth-server/test/remote/oauth_db.in.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ describe('db', () => {
243243

244244
beforeEach(async () => {
245245
tokenFirstUsage = {};
246+
const mysql = await db.mysql;
247+
const mysqlToken = await mysql._getRefreshToken(tokenId);
248+
expect(hex(mysqlToken.tokenId)).toBe(hex(tokenId));
249+
tokenFirstUsage.mysqlLastUsedAt = new Date(mysqlToken.lastUsedAt);
250+
246251
const t = await db.getRefreshToken(tokenId);
247252
expect(hex(t.tokenId)).toBe(hex(tokenId));
248253
tokenFirstUsage.createdAt = new Date(t.createdAt);
@@ -284,7 +289,7 @@ describe('db', () => {
284289
expect(hex(t.tokenId)).toBe(hex(tokenId));
285290
expect(
286291
Math.abs(
287-
t.lastUsedAt.getTime() - tokenFirstUsage.lastUsedAt.getTime()
292+
t.lastUsedAt.getTime() - tokenFirstUsage.mysqlLastUsedAt.getTime()
288293
)
289294
).toBeLessThanOrEqual(2000);
290295
});

0 commit comments

Comments
 (0)