Skip to content

Commit ce4985f

Browse files
authored
Merge pull request #18344 from mozilla/fxa-11019
Add custom rate limiting for sms confirm and setup
2 parents 2c9ebdd + a3dd31f commit ce4985f

8 files changed

Lines changed: 215 additions & 22 deletions

File tree

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ enum RecoveryPhoneStatus {
3131

3232
export type Customs = {
3333
check: (req: AuthRequest, email: string, action: string) => Promise<void>;
34+
checkAuthenticated: (
35+
req: AuthRequest,
36+
uid: string,
37+
action: string
38+
) => Promise<void>;
3439
};
3540

3641
class RecoveryPhoneHandler {
@@ -102,7 +107,7 @@ class RecoveryPhoneHandler {
102107
if (!email) {
103108
throw AppError.invalidToken();
104109
}
105-
await this.customs.check(request, email, 'recoveryPhoneCreate');
110+
await this.customs.checkAuthenticated(request, uid, 'recoveryPhoneCreate');
106111

107112
try {
108113
const result = await this.recoveryPhoneService.setupPhoneNumber(
@@ -158,7 +163,11 @@ class RecoveryPhoneHandler {
158163
throw AppError.invalidToken();
159164
}
160165

161-
await this.customs.check(request, email, 'recoveryPhoneConfirmCode');
166+
await this.customs.checkAuthenticated(
167+
request,
168+
uid,
169+
'recoveryPhoneConfirmCode'
170+
);
162171

163172
let success = false;
164173
try {

packages/fxa-auth-server/test/local/routes/recovery-phone.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('/recovery_phone', () => {
3333
let mockMailer;
3434
const mockCustoms = {
3535
check: sandbox.fake(),
36+
checkAuthenticated: sandbox.fake(),
3637
};
3738
const mockGlean = {
3839
twoStepAuthPhoneCode: {
@@ -177,9 +178,12 @@ describe('/recovery_phone', () => {
177178
assert.equal(mockGlean.twoStepAuthPhoneCode.sent.callCount, 1);
178179
assert.equal(mockGlean.twoStepAuthPhoneCode.sendError.callCount, 0);
179180

180-
assert.equal(mockCustoms.check.callCount, 1);
181-
assert.equal(mockCustoms.check.getCall(0).args[1], email);
182-
assert.equal(mockCustoms.check.getCall(0).args[2], 'recoveryPhoneCreate');
181+
assert.equal(mockCustoms.checkAuthenticated.callCount, 1);
182+
assert.equal(mockCustoms.checkAuthenticated.getCall(0).args[1], uid);
183+
assert.equal(
184+
mockCustoms.checkAuthenticated.getCall(0).args[2],
185+
'recoveryPhoneCreate'
186+
);
183187
});
184188

185189
it('indicates failure sending sms', async () => {

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,64 @@ module.exports = function (fs, path, url, convict) {
475475
},
476476
},
477477
},
478+
recoveryPhoneCreateCodeRules: {
479+
actions: {
480+
doc:
481+
'Array of actions that this rule should be applied to. A user should only be able to initiate ' +
482+
'a recovery phone setup X times a day.',
483+
default: ['recoveryPhoneCreate'],
484+
format: Array,
485+
},
486+
limits: {
487+
max: {
488+
doc: 'max actions during `period` that can occur before rate limit is applied',
489+
format: 'nat',
490+
default: 9,
491+
env: 'RECOVERY_PHONE_SETUP_CODE_RULE_MAX',
492+
},
493+
periodMs: {
494+
doc: 'period needed before rate limit is reset',
495+
format: 'duration',
496+
default: '1 day',
497+
env: 'RECOVERY_PHONE_SETUP_CODE_RULE_PERIOD_MS',
498+
},
499+
rateLimitIntervalMs: {
500+
doc: 'how long rate limit is applied',
501+
format: 'duration',
502+
default: '2 hours',
503+
env: 'RECOVERY_PHONE_SETUP_CODE_RULE_LIMIT_INTERVAL_MS',
504+
},
505+
},
506+
},
507+
recoveryPhoneConfirmCodeRules: {
508+
actions: {
509+
doc:
510+
'Array of actions that this rule should be applied to. A user should only be able to initiate ' +
511+
'a recovery phone confirm X times a day.',
512+
default: ['recoveryPhoneConfirmCode'],
513+
format: Array,
514+
},
515+
limits: {
516+
max: {
517+
doc: 'max actions during `period` that can occur before rate limit is applied',
518+
format: 'nat',
519+
default: 6,
520+
env: 'RECOVERY_PHONE_CONFIRM_CODE_RULE_MAX',
521+
},
522+
periodMs: {
523+
doc: 'period needed before rate limit is reset',
524+
format: 'duration',
525+
default: '1 day',
526+
env: 'RECOVERY_PHONE_CONFIRM_CODE_RULE_PERIOD_MS',
527+
},
528+
rateLimitIntervalMs: {
529+
doc: 'how long rate limit is applied',
530+
format: 'duration',
531+
default: '2 hours',
532+
env: 'RECOVERY_PHONE_CONFIRM_CODE_RULE_LIMIT_INTERVAL_MS',
533+
},
534+
},
535+
},
478536
},
479537
dataflow: {
480538
enabled: {

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,10 @@ module.exports = async function createServer(config, log) {
7878
statsd
7979
);
8080

81-
const checkUserDefinedRateLimitRules = require('./user_defined_rules')(
82-
config,
83-
fetchRecord,
84-
setRecords
85-
);
81+
const {
82+
checkUserDefinedRateLimitRulesByIpEmail,
83+
checkUserDefinedRateLimitRulesByUid,
84+
} = require('./user_defined_rules')(config, fetchRecord, setRecords);
8685

8786
dataflow(config, log, fetchRecords, setRecord);
8887

@@ -418,7 +417,12 @@ module.exports = async function createServer(config, log) {
418417
return fetchRecords({ ip, email, phoneNumber })
419418
.then(checkRecords)
420419
.then((result) => {
421-
return checkUserDefinedRateLimitRules(result, action, email, ip);
420+
return checkUserDefinedRateLimitRulesByIpEmail(
421+
result,
422+
action,
423+
email,
424+
ip
425+
);
422426
})
423427
.then((result) => {
424428
return result;
@@ -454,8 +458,15 @@ module.exports = async function createServer(config, log) {
454458
}
455459

456460
return fetchRecords({ uid })
457-
.then(({ uidRecord }) => {
458-
var retryAfter = uidRecord.addCount(action, uid);
461+
.then(async ({ uidRecord }) => {
462+
var result = uidRecord.addCount(action, uid);
463+
const { retryAfter } = await checkUserDefinedRateLimitRulesByUid(
464+
{
465+
retryAfter: result,
466+
},
467+
action,
468+
uid
469+
);
459470
return setRecords(uidRecord).then(function () {
460471
return {
461472
block: retryAfter > 0,

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,7 @@ module.exports = function (config, fetchRecord, setRecords) {
2121
});
2222
});
2323

24-
return async function checkUserDefinedRateLimitRules(
25-
result,
26-
action,
27-
email,
28-
ip
29-
) {
24+
async function checkUserDefinedRateLimitRulesByKey(result, action) {
3025
// Get all the user defined rules that might apply to this action
3126
const checkRules = computedRules.get(action);
3227

@@ -35,10 +30,12 @@ module.exports = function (config, fetchRecord, setRecords) {
3530
return result;
3631
}
3732

33+
const keys = [...arguments].slice(2);
34+
3835
const retries = [];
3936
await Promise.all(
4037
checkRules.map(async (ruleName) => {
41-
const recordKey = ruleName + ':' + utils.createHashHex(email, ip);
38+
const recordKey = ruleName + ':' + utils.createHashHex(...keys);
4239
const record = await fetchRecord(
4340
recordKey,
4441
(object) => new Record(object, configuredRules[ruleName])
@@ -60,5 +57,23 @@ module.exports = function (config, fetchRecord, setRecords) {
6057
}
6158

6259
return result;
60+
}
61+
62+
async function checkUserDefinedRateLimitRulesByIpEmail(
63+
result,
64+
action,
65+
ip,
66+
email
67+
) {
68+
return checkUserDefinedRateLimitRulesByKey(result, action, ip, email);
69+
}
70+
71+
async function checkUserDefinedRateLimitRulesByUid(result, action, uid) {
72+
return checkUserDefinedRateLimitRulesByKey(result, action, uid);
73+
}
74+
75+
return {
76+
checkUserDefinedRateLimitRulesByIpEmail,
77+
checkUserDefinedRateLimitRulesByUid,
6378
};
6479
};

packages/fxa-customs-server/test/remote/user_defined_rules.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const TestServer = require('../test_server');
66
const Promise = require('bluebird');
77
const restifyClients = Promise.promisifyAll(require('restify-clients'));
88
const mcHelper = require('../cache-helper');
9+
Promise.promisifyAll(mcHelper, { multiArgs: true });
910

1011
function randomEmail() {
1112
return Math.floor(Math.random() * 10000) + '@email.com';
@@ -18,13 +19,25 @@ function randomIp() {
1819
return [getSubnet(), getSubnet(), getSubnet(), getSubnet()].join('.');
1920
}
2021

22+
function randomUid() {
23+
return Math.floor(Math.random() * 10000) + '';
24+
}
25+
2126
const config = require('../../lib/config').getProperties();
2227
config.userDefinedRateLimitRules.totpCodeRules.limits.periodMs = 1000;
2328
config.userDefinedRateLimitRules.totpCodeRules.limits.rateLimitIntervalMs = 1000;
2429
config.userDefinedRateLimitRules.tokenCodeRules.limits.max = 2;
2530
config.userDefinedRateLimitRules.tokenCodeRules.limits.periodMs = 1000;
2631
config.userDefinedRateLimitRules.tokenCodeRules.limits.rateLimitIntervalMs = 1000;
2732

33+
config.userDefinedRateLimitRules.recoveryPhoneCreateCodeRules.limits.max = 5;
34+
config.userDefinedRateLimitRules.recoveryPhoneCreateCodeRules.limits.periodMs = 5000;
35+
config.userDefinedRateLimitRules.recoveryPhoneCreateCodeRules.limits.rateLimitIntervalMs = 1000;
36+
37+
config.userDefinedRateLimitRules.recoveryPhoneConfirmCodeRules.limits.max = 5;
38+
config.userDefinedRateLimitRules.recoveryPhoneConfirmCodeRules.limits.periodMs = 5000;
39+
config.userDefinedRateLimitRules.recoveryPhoneConfirmCodeRules.limits.rateLimitIntervalMs = 1000;
40+
2841
const ACTIONS = ['verifyTotpCode', 'verifyTokenCode'];
2942

3043
const testServer = new TestServer(config);
@@ -136,6 +149,91 @@ ACTIONS.forEach((action) => {
136149
});
137150
});
138151

152+
const recoveryPhoneActions = [
153+
'recoveryPhoneCreate',
154+
'recoveryPhoneConfirmCode',
155+
];
156+
recoveryPhoneActions.forEach((action) => {
157+
test(`clear everything for ${action}`, async (t) => {
158+
try {
159+
await mcHelper.clearEverythingAsync();
160+
t.pass('cleared redis');
161+
} catch (err) {
162+
t.fail(err);
163+
} finally {
164+
t.end();
165+
}
166+
});
167+
168+
test('/checkAuthenticated `' + action + '` by uid', async (t) => {
169+
const uid = randomUid();
170+
const ip = randomIp();
171+
// Send requests until throttled
172+
let [req, res, obj] = await client.postAsync('/checkAuthenticated', {
173+
ip,
174+
action,
175+
uid,
176+
});
177+
t.equal(res.statusCode, 200, 'returns a 200');
178+
t.equal(obj.block, false, 'not rate limited');
179+
180+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
181+
ip,
182+
action,
183+
uid,
184+
});
185+
t.equal(res.statusCode, 200, 'returns a 200');
186+
t.equal(obj.block, false, 'not rate limited');
187+
188+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
189+
ip,
190+
action,
191+
uid,
192+
});
193+
t.equal(res.statusCode, 200, 'returns a 200');
194+
t.equal(obj.block, false, 'not rate limited');
195+
196+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
197+
ip,
198+
action,
199+
uid,
200+
});
201+
t.equal(res.statusCode, 200, 'returns a 200');
202+
t.equal(obj.block, false, 'not rate limited');
203+
204+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
205+
ip,
206+
action,
207+
uid,
208+
});
209+
t.equal(res.statusCode, 200, 'returns a 200');
210+
t.equal(obj.block, false, 'not rate limited');
211+
212+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
213+
ip,
214+
action,
215+
uid,
216+
});
217+
t.equal(res.statusCode, 200, 'returns a 200');
218+
t.equal(obj.block, true, 'rate limited');
219+
t.equal(obj.retryAfter, 1, 'rate limit retry amount');
220+
221+
// Wait for limit to expire
222+
await Promise.delay(1010);
223+
224+
[req, res, obj] = await client.postAsync('/checkAuthenticated', {
225+
ip,
226+
action,
227+
uid,
228+
});
229+
t.equal(res.statusCode, 200, 'returns a 200');
230+
t.equal(obj.block, false, 'not rate limited');
231+
232+
t.pass(req);
233+
t.end();
234+
});
235+
});
236+
139237
test('teardown', async function (t) {
140238
await testServer.stop();
141239
t.end();

packages/fxa-customs-server/test/test_reputation_server.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ async function init(config) {
6969
path: '/mostRecentViolation/{ip}',
7070
handler: async (req) => {
7171
var ip = req.params.ip;
72-
console.log('delete req', req.url);
7372
delete mostRecentViolationByIp[ip];
7473
return {};
7574
},

packages/fxa-customs-server/test/test_reputation_server_stub.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ server.get('/mostRecentViolation/:ip', function (req, res, next) {
3838
// This is not a real route in iprepd, and is only used by the tests
3939
server.del('/mostRecentViolation/:ip', function (req, res, next) {
4040
var ip = req.params.ip;
41-
console.log('delete req', req.url);
4241
delete mostRecentViolationByIp[ip];
4342
res.send(200);
4443

0 commit comments

Comments
 (0)