Merge pull request #16883 from mozilla/fxa-9485-otp-customs

feat(customs): add rate limiting for password reset OTPs
This commit is contained in:
Barry Chen 2024-05-08 10:54:08 -05:00 коммит произвёл GitHub
Родитель 5b524e715e 179994e179
Коммит 7408010048
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
11 изменённых файлов: 281 добавлений и 8 удалений

Просмотреть файл

@ -531,8 +531,7 @@ module.exports = function (
};
const email = payload.email;
// TODO FXA-9485
// await customs.check(request, email, 'passwordForgotSendOtp');
await customs.check(request, email, 'passwordForgotSendOtp');
request.validateMetricsContext();

Просмотреть файл

@ -64,6 +64,8 @@ const SMS_SENDING_ACTION = {
// are not associated with an email address or uid.
const ACCOUNT_ACCESS_ACTION = new Set(['consumeSigninCode']);
const RESET_PASSWORD_OTP_SENDING_ACTION = { passwordForgotSendOtp: true };
module.exports = {
isPasswordCheckingAction: function (action) {
return PASSWORD_CHECKING_ACTION[action];
@ -88,4 +90,8 @@ module.exports = {
isAccountAccessAction(action) {
return ACCOUNT_ACCESS_ACTION.has(action);
},
isResetPasswordOtpSendingAction(action) {
return RESET_PASSWORD_OTP_SENDING_ACTION[action];
},
};

Просмотреть файл

@ -173,6 +173,26 @@ module.exports = function (fs, path, url, convict) {
format: 'nat',
env: 'MAX_ACCOUNT_ACCESS',
},
passwordResetOtpLimits: {
maxPasswordResetOtpEmails: {
doc: 'Number of OTP email for an account email or from an IP can request before rate limiting',
default: 5,
format: 'nat',
env: 'PASSWORD_RESET_OTP_EMAIL_LIMIT',
},
passwordResetOtpEmailRequestWindowSeconds: {
doc: 'Number of seconds when the max number of OTP email requests is allowed',
default: 600,
format: 'nat',
env: 'PASSWORD_RESET_OTP_EMAIL_REQUEST_WINDOW_SECONDS',
},
passwordResetOtpRateLimitIntervalSeconds: {
doc: 'Number of seconds to wait until password reset OTP requests are allowed again',
default: 1800,
format: 'nat',
env: 'PASSWORD_RESET_OTP_EMAIL_RATE_LIMIT_SECONDS',
},
},
},
cache: {
recordLifetimeSeconds: {

Просмотреть файл

@ -29,6 +29,7 @@ module.exports = function (limits, now) {
rec.lf = object.lf || rec.lf; // timestamps of when login failed
rec.pr = object.pr; // timestamp of the last password reset
rec.ub = object.ub || rec.ub;
rec.os = object.os || []; // timpstamp of password reset OTP email request
return rec;
};
@ -189,9 +190,11 @@ module.exports = function (limits, now) {
this.pr = now();
};
EmailRecord.prototype.retryAfter = function () {
EmailRecord.prototype.retryAfter = function (rlIntervalMs) {
const intervalMs = rlIntervalMs || limits.rateLimitIntervalMs;
var rateLimitAfter = Math.ceil(
((this.rl || 0) + limits.rateLimitIntervalMs - now()) / 1000
((this.rl || 0) + intervalMs - now()) / 1000
);
var banAfter = Math.ceil(
((this.bk || 0) + limits.blockIntervalMs - now()) / 1000
@ -229,6 +232,22 @@ module.exports = function (limits, now) {
this.lf = this.lf.slice(i + 1);
};
EmailRecord.prototype.addPasswordResetOtp = function () {
this.os.push(now());
};
EmailRecord.prototype.trimPasswordResetOtps = function (now) {
this.os = this.os.filter(
(otpReqTime) =>
otpReqTime > now - limits.passwordResetOtpEmailRequestWindowMs
);
};
EmailRecord.prototype.isOverPasswordResetOtpLimit = function () {
this.trimPasswordResetOtps(now());
return this.os.length > limits.maxPasswordResetOtpEmails;
};
EmailRecord.prototype.update = function (action, unblock) {
// Reject immediately if they've been explicitly blocked.
if (this.isBlocked()) {
@ -290,6 +309,22 @@ module.exports = function (limits, now) {
return this.retryAfter();
}
if (actions.isResetPasswordOtpSendingAction(action)) {
const otpEmailRateLimited = !!(
this.rl &&
now() - this.rl < limits.passwordResetOtpEmailRateLimitIntervalMs
);
if (otpEmailRateLimited || this.shouldBlock()) {
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
}
this.addPasswordResetOtp();
if (this.isOverPasswordResetOtpLimit()) {
this.rateLimit();
this.os = [];
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
}
}
// Everything else is allowed through.
return 0;
};

Просмотреть файл

@ -28,6 +28,7 @@ module.exports = function (limits, now) {
rec.sms = object.sms || []; // timestamp+sms when sms sent
rec.aa = object.aa || []; // timestamp when account access was attempted
rec.rl = object.rl; // timestamp when the IP address was rate-limited
rec.os = object.os || []; // timpstamp of password reset OTP email request
return rec;
};
@ -74,6 +75,22 @@ module.exports = function (limits, now) {
this.lf = this._trim(now, this.lf, limits.maxBadLoginsPerIp);
};
IpRecord.prototype.addPasswordResetOtp = function () {
this.os.push(now());
};
IpRecord.prototype.trimPasswordResetOtps = function (now) {
this.os = this.os.filter(
(otpReqTime) =>
otpReqTime > now - limits.passwordResetOtpEmailRequestWindowMs
);
};
IpRecord.prototype.isOverPasswordResetOtpLimit = function () {
this.trimPasswordResetOtps(now());
return this.os.length > limits.maxPasswordResetOtpEmails;
};
IpRecord.prototype.isOverVerifyCodes = function () {
this.trimVerifyCodes(now());
// Limit based on number of unique emails accessed by this IP.
@ -224,9 +241,11 @@ module.exports = function (limits, now) {
this.aa = [];
};
IpRecord.prototype.retryAfter = function () {
IpRecord.prototype.retryAfter = function (rlIntervalMs) {
const intervalMs = rlIntervalMs || limits.ipRateLimitBanDurationMs;
var rateLimitAfter = Math.ceil(
((this.rl || 0) + limits.ipRateLimitBanDurationMs - now()) / 1000
((this.rl || 0) + intervalMs - now()) / 1000
);
var banAfter = Math.ceil(
((this.bk || 0) + limits.blockIntervalMs - now()) / 1000
@ -291,6 +310,22 @@ module.exports = function (limits, now) {
}
}
if (actions.isResetPasswordOtpSendingAction(action)) {
const otpEmailRateLimited = !!(
this.rl &&
now() - this.rl < limits.passwordResetOtpEmailRateLimitIntervalMs
);
if (otpEmailRateLimited || this.shouldBlock()) {
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
}
this.addPasswordResetOtp();
if (this.isOverPasswordResetOtpLimit()) {
this.rateLimit();
this.os = [];
return this.retryAfter(limits.passwordResetOtpEmailRateLimitIntervalMs);
}
}
return this.retryAfter();
};

Просмотреть файл

@ -46,6 +46,15 @@ module.exports = (config, Settings, log) => {
this.smsRateLimitIntervalSeconds = this.smsRateLimit.limitIntervalSeconds;
this.smsRateLimitIntervalMs = this.smsRateLimitIntervalSeconds * 1000;
this.maxAccountAccess = settings.maxAccountAccess;
this.passwordResetOtpLimits = settings.passwordResetOtpLimits || {};
this.maxPasswordResetOtpEmails =
settings.passwordResetOtpLimits.maxPasswordResetOtpEmails;
this.passwordResetOtpEmailRequestWindowMs =
settings.passwordResetOtpLimits
.passwordResetOtpEmailRequestWindowSeconds * 1000;
this.passwordResetOtpEmailRateLimitIntervalMs =
settings.passwordResetOtpLimits
.passwordResetOtpRateLimitIntervalSeconds * 1000;
return this;
}

Просмотреть файл

@ -41,6 +41,15 @@ var config = {
Number(process.env.UID_RATE_LIMIT_BAN_DURATION_SECONDS) || 60 * 15,
maxChecks: Number(process.env.UID_RATE_LIMIT) || 3,
},
passwordResetOtpLimits: {
maxPasswordResetOtpEmails:
Number(process.env.PASSWORD_RESET_OTP_EMAIL_LIMIT) || 5,
passwordResetOtpEmailRequestWindowSeconds:
Number(process.env.PASSWORD_RESET_OTP_EMAIL_REQUEST_WINDOW_SECONDS) ||
600,
passwordResetOtpRateLimitIntervalSeconds:
Number(process.env.PASSWORD_RESET_OTP_EMAIL_RATE_LIMIT_SECONDS) || 1800,
},
},
requestChecks: {
treatEveryoneWithSuspicion: false,

Просмотреть файл

@ -5,8 +5,9 @@
var test = require('tap').test;
var emailRecord = require('../../lib/email_record');
let nowTimestamp;
function now() {
return 1000; // old school
return nowTimestamp || 1000; // old school
}
function simpleEmailRecord() {
@ -18,6 +19,9 @@ function simpleEmailRecord() {
maxEmails: 2,
maxUnblockAttempts: 2,
maxBadLoginsPerEmail: 2,
maxPasswordResetOtpEmails: 2,
passwordResetOtpEmailRequestWindowMs: 2000,
passwordResetOtpEmailRateLimitIntervalMs: 5000,
};
return new (emailRecord(limits, now))();
}
@ -174,6 +178,7 @@ test('retryAfter works', function (t) {
t.equal(er.retryAfter(), 0, 'just expired blocks can be retried immediately');
er.rl = 6000;
t.equal(er.retryAfter(), 1, 'unexpired blocks can be retried in a bit');
t.equal(er.retryAfter(10000), 6, 'optional rate limit interval');
delete er.rl;
t.equal(er.retryAfter(), 0, 'unblocked records can be retried now');
@ -265,6 +270,18 @@ test('update works', function (t) {
t.equal(er.update('accountCreate'), 2, 'email action is blocked');
t.equal(er.update('accountLogin'), 2, 'non-email action is blocked');
er = simpleEmailRecord();
er.os = [];
nowTimestamp = 1001;
er.update('passwordForgotSendOtp');
nowTimestamp = 1003;
let res = er.update('passwordForgotSendOtp');
t.equal(res === 0, true, 'account is not rate limited');
nowTimestamp = 2001;
res = er.update('passwordForgotSendOtp');
t.equal(res > 0, true, 'account is rate limited');
nowTimestamp = null;
t.end();
});

Просмотреть файл

@ -18,7 +18,10 @@ function simpleIpRecord() {
ipRateLimitBanDurationMs: 1000,
maxBadLoginsPerIp: 3,
maxAccountStatusCheck: 1,
badLoginErrnoWeights: { '102': 2 },
badLoginErrnoWeights: { 102: 2 },
maxPasswordResetOtpEmails: 2,
passwordResetOtpEmailRequestWindowMs: 2000,
passwordResetOtpEmailRateLimitIntervalMs: 5000,
};
return new (ipRecord(limits, now))();
}
@ -99,6 +102,23 @@ test('retryAfter block works', function (t) {
t.end();
});
test('retryAfter rate limiting with optional interval', function (t) {
const ir = simpleIpRecord();
t.equal(ir.retryAfter(), 0, 'unblocked records can be retried now');
ir.rl = 240 * 1000;
t.equal(
ir.retryAfter(),
1,
'unblocked records can be retried after default interval'
);
t.equal(
ir.retryAfter(5000),
5,
'unblocked records can be retried after given interval'
);
t.end();
});
test('parse works', function (t) {
var ir = simpleIpRecord();
t.equal(ir.shouldBlock(), false, 'original object is not blocked');
@ -139,6 +159,19 @@ test('action accountStatusCheck rate-limit works', function (t) {
t.end();
});
test('action passwordForgotSendOtp rate-limit works', function (t) {
const ir = simpleIpRecord();
ir.os = [];
let res = ir.update('passwordForgotSendOtp', 'test1@example.com');
t.equal(res, 0, 'rate-limit not exceeded');
res = ir.update('passwordForgotSendOtp', 'test1@example.com');
t.equal(res, 0, 'rate-limit not exceeded using same email');
res = ir.update('passwordForgotSendOtp', 'test2@example.com');
t.equal(res, 5, 'rate-limit exceeded using different email');
t.end();
});
test('getMinLifetimeMS works', function (t) {
var limits = {
blockIntervalMs: 10,

Просмотреть файл

@ -14,6 +14,11 @@ const config = {
limitIntervalSeconds: 1800,
maxSms: 5,
},
passwordResetOtpLimits: {
maxPasswordResetOtpEmails: 5,
passwordResetOtpEmailRequestWindowSeconds: 600,
passwordResetOtpRateLimitIntervalSeconds: 1800,
},
},
requestChecks: {
treatEveryoneWithSuspicion: true,

Просмотреть файл

@ -0,0 +1,105 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const test = require('tap').test;
const TestServer = require('../test_server');
const Promise = require('bluebird');
const restifyClients = Promise.promisifyAll(require('restify-clients'));
const mcHelper = require('../cache-helper');
const testUtils = require('../utils');
const config = require('../../lib/config').getProperties();
config.limits.rateLimitIntervalSeconds = 1;
config.limits.ipRateLimitIntervalSeconds = 1;
config.limits.passwordResetOtpLimits.maxPasswordResetOtpEmails = 2;
config.limits.passwordResetOtpLimits.passwordResetOtpRateLimitIntervalSeconds = 2;
const testServer = new TestServer(config);
const client = restifyClients.createJsonClient({
url: 'http://localhost:' + config.listen.port,
});
const action = 'passwordForgotSendOtp';
Promise.promisifyAll(client, { multiArgs: true });
test('startup', async function (t) {
await testServer.start();
t.type(testServer.server, 'object', 'test server was started');
t.end();
});
test('clear everything', function (t) {
mcHelper.clearEverything(function (err) {
t.notOk(err, 'no errors were returned');
t.end();
});
});
test('/check passwordForgotSendOtp by email', async (t) => {
const email = testUtils.randomEmail();
let ip = testUtils.randomIp();
let response = await client.postAsync('/check', { ip, email, action });
let [_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, false, 'not rate limited');
// same email different ip
ip = testUtils.randomIp();
response = await client.postAsync('/check', { ip, email, action });
[_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, false, 'not rate limited');
ip = testUtils.randomIp();
response = await client.postAsync('/check', { ip, email, action });
// eslint-disable-next-line no-unused-vars
[_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, true, 'rate limited');
t.equal(obj.retryAfter, 2, 'rate limit retry amount');
});
test('clear everything', function (t) {
mcHelper.clearEverything(function (err) {
t.notOk(err, 'no errors were returned');
t.end();
});
});
test('/check passwordForgotSendOtp by ip address', async (t) => {
const ip = testUtils.randomIp();
let email = testUtils.randomEmail();
let response = await client.postAsync('/check', { ip, email, action });
let [_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, false, 'not rate limited');
// same ip different email
email = testUtils.randomEmail();
response = await client.postAsync('/check', { ip, email, action });
[_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, false, 'not rate limited');
email = testUtils.randomEmail();
response = await client.postAsync('/check', { ip, email, action });
// eslint-disable-next-line no-unused-vars
[_, res, obj] = response;
t.equal(res.statusCode, 200, 'returns a 200');
t.equal(obj.block, true, 'rate limited');
t.equal(obj.retryAfter, 2, 'rate limit retry amount');
});
test('teardown', async function (t) {
await testServer.stop();
t.end();
});