Skip to content

Commit 5d9efb7

Browse files
authored
Merge pull request #19825 from mozilla/uplift-patches
uplift patches to main
2 parents ae929c2 + e0504e0 commit 5d9efb7

3 files changed

Lines changed: 184 additions & 3 deletions

File tree

packages/fxa-auth-server/config/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2317,6 +2317,12 @@ const convictConf = convict({
23172317
env: 'RATE_LIMIT__SKIP_ENDPOINTS',
23182318
format: Array,
23192319
},
2320+
emailAliasNormalization: {
2321+
default: '',
2322+
doc: 'List of email domain configurations for alias normalization. Each entry should have domain, regex, and replace properties. Example: [{domain: "mozilla.com", regex: "\\+[^@]+", replace: ""}]',
2323+
env: 'RATE_LIMIT__EMAIL_ALIAS_NORMALIZATION',
2324+
format: String,
2325+
},
23202326
},
23212327
recoveryPhone: {
23222328
enabled: {

packages/fxa-auth-server/lib/customs.js

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,39 @@ const localizeTimestamp =
1717
});
1818
const serviceName = 'customs';
1919

20+
// Load alias config map once at module startup
21+
function loadAliasConfigsMap() {
22+
const aliasConfigsStr = config.get('rateLimit.emailAliasNormalization') || '';
23+
let aliasConfigs = [];
24+
if (aliasConfigsStr) {
25+
try {
26+
aliasConfigs = JSON.parse(aliasConfigsStr);
27+
} catch (err) {
28+
// If parsing fails, use empty array
29+
aliasConfigs = [];
30+
}
31+
}
32+
return new Map(
33+
aliasConfigs.map((cfg) => [cfg.domain, cfg])
34+
);
35+
}
36+
37+
let aliasConfigsMap = loadAliasConfigsMap();
38+
39+
/**
40+
* Reload alias configs map from configuration (useful for testing).
41+
*/
42+
function reloadAliasConfigsMap() {
43+
aliasConfigsMap = loadAliasConfigsMap();
44+
}
45+
2046
function toOpts(ip, email, uid) {
2147
const opts = {};
2248
if (ip) {
2349
opts.ip = ip;
2450
}
2551
if (email) {
26-
opts.email = email;
52+
opts.email = normalizeEmailForRateLimiting(email);
2753
}
2854
if (uid) {
2955
opts.uid = uid;
@@ -120,7 +146,7 @@ class CustomsClient {
120146
const result = await this.makeRequest('/check', {
121147
...this.sanitizePayload({
122148
ip: request.app.clientAddress,
123-
email,
149+
email: normalizeEmailForRateLimiting(email),
124150
action,
125151

126152
// Payload in this case is additional user related data (ie phone number)
@@ -190,7 +216,7 @@ class CustomsClient {
190216
await this.makeRequest('/passwordReset', {
191217
...this.sanitizePayload({
192218
ip: request.app.clientAddress,
193-
email,
219+
email: normalizeEmailForRateLimiting(email),
194220
}),
195221
});
196222
}
@@ -436,4 +462,52 @@ class CustomsClient {
436462
// #endregion
437463
}
438464

465+
/**
466+
* Normalize email for rate limiting.
467+
* Applies domain-specific regex normalization rules from configuration
468+
* (e.g., removes plus aliases: [email protected] -> [email protected])
469+
* and lowercases the email to ensure consistent rate limiting across
470+
* different representations of the same email address.
471+
*
472+
* @param {string} email - The email address to normalize
473+
* @returns {string} The normalized email address
474+
*/
475+
function normalizeEmailForRateLimiting(email) {
476+
if (!email) {
477+
return email;
478+
}
479+
480+
const lowercaseEmail = email.toLowerCase();
481+
const atIndex = lowercaseEmail.lastIndexOf('@');
482+
if (atIndex === -1) {
483+
// This shouldn't happen... but just in case.
484+
return lowercaseEmail;
485+
}
486+
487+
const localPart = lowercaseEmail.substring(0, atIndex);
488+
const domainPart = lowercaseEmail.substring(atIndex);
489+
const domain = domainPart.substring(1);
490+
491+
const domainConfig = aliasConfigsMap.get(domain);
492+
493+
let normalizedLocal = localPart;
494+
if (domainConfig && domainConfig.regex) {
495+
try {
496+
const regex = new RegExp(domainConfig.regex);
497+
normalizedLocal = localPart.replace(
498+
regex,
499+
domainConfig.replace || ''
500+
);
501+
} catch (err) {
502+
// If regex is invalid, fall back to original local part
503+
normalizedLocal = localPart;
504+
}
505+
}
506+
507+
return normalizedLocal + domainPart;
508+
}
509+
510+
// Export reload function for testing
511+
CustomsClient._reloadAliasConfigsMap = reloadAliasConfigsMap;
512+
439513
module.exports = CustomsClient;

packages/fxa-auth-server/test/local/customs.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const customsServer = nock(CUSTOMS_URL_REAL).defaultReplyHeaders({
1717
'Content-Type': 'application/json',
1818
});
1919
const Customs = require(`../../lib/customs.js`);
20+
const configModule = require('../../config');
2021

2122
describe('Customs', () => {
2223
let customsNoUrl;
@@ -724,6 +725,18 @@ describe('Customs', () => {
724725
mockRateLimit.check = sinon.spy();
725726
mockRateLimit.skip = sinon.spy(() => false);
726727
mockRateLimit.supportsAction = sinon.spy(() => true);
728+
// Stub config.get to return email alias domain configurations for tests
729+
const configGetStub = sandbox.stub(configModule.config, 'get');
730+
configGetStub
731+
.withArgs('rateLimit.emailAliasNormalization')
732+
.returns(
733+
JSON.stringify([
734+
{ domain: 'mozilla.com', regex: '\\+[^@]+', replace: '' },
735+
])
736+
);
737+
configGetStub.callThrough();
738+
// Reload the config map with the stubbed config
739+
Customs._reloadAliasConfigsMap();
727740
});
728741

729742
it('can allow checkAccountStatus with rate-limit lib', async () => {
@@ -821,6 +834,94 @@ describe('Customs', () => {
821834
});
822835
assert.callCount(mockRateLimit.check, 0);
823836
});
837+
838+
it('normalizes emails with plus aliases for configured domains', async () => {
839+
mockRateLimit.check = sandbox.spy(async () => {
840+
return await Promise.resolve(null);
841+
});
842+
843+
const emailWithAlias = '[email protected]';
844+
const normalizedEmail = '[email protected]';
845+
const normalizedIpEmail = `${ip}_${normalizedEmail}`;
846+
847+
await customs.check(request, emailWithAlias, 'accountStatusCheck');
848+
849+
assert.calledWith(
850+
mockRateLimit.check,
851+
'accountStatusCheck',
852+
sinon.match({
853+
ip,
854+
email: normalizedEmail,
855+
ip_email: normalizedIpEmail,
856+
})
857+
);
858+
});
859+
860+
it('normalizes emails with different cases', async () => {
861+
mockRateLimit.check = sandbox.spy(async () => {
862+
return await Promise.resolve(null);
863+
});
864+
865+
const mixedCaseEmail = '[email protected]';
866+
const normalizedEmail = '[email protected]';
867+
const normalizedIpEmail = `${ip}_${normalizedEmail}`;
868+
869+
await customs.check(request, mixedCaseEmail, 'accountStatusCheck');
870+
871+
assert.calledWith(
872+
mockRateLimit.check,
873+
'accountStatusCheck',
874+
sinon.match({
875+
ip,
876+
email: normalizedEmail,
877+
ip_email: normalizedIpEmail,
878+
})
879+
);
880+
});
881+
882+
it('does not remove aliases for non-configured domains', async () => {
883+
mockRateLimit.check = sandbox.spy(async () => {
884+
return await Promise.resolve(null);
885+
});
886+
887+
const emailWithAlias = '[email protected]';
888+
const normalizedEmail = '[email protected]'; // Alias should remain
889+
const normalizedIpEmail = `${ip}_${normalizedEmail}`;
890+
891+
await customs.check(request, emailWithAlias, 'accountStatusCheck');
892+
893+
assert.calledWith(
894+
mockRateLimit.check,
895+
'accountStatusCheck',
896+
sinon.match({
897+
ip,
898+
email: normalizedEmail,
899+
ip_email: normalizedIpEmail,
900+
})
901+
);
902+
});
903+
904+
it('lowercases emails for all domains', async () => {
905+
mockRateLimit.check = sandbox.spy(async () => {
906+
return await Promise.resolve(null);
907+
});
908+
909+
const mixedCaseEmail = '[email protected]';
910+
const normalizedEmail = '[email protected]';
911+
const normalizedIpEmail = `${ip}_${normalizedEmail}`;
912+
913+
await customs.check(request, mixedCaseEmail, 'accountStatusCheck');
914+
915+
assert.calledWith(
916+
mockRateLimit.check,
917+
'accountStatusCheck',
918+
sinon.match({
919+
ip,
920+
email: normalizedEmail,
921+
ip_email: normalizedIpEmail,
922+
})
923+
);
924+
});
824925
});
825926

826927
describe('statsd metrics', () => {

0 commit comments

Comments
 (0)