packages/fxa-customs-server/lib/ip_record.js (300 lines of code) (raw):

/* 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/. */ var actions = require('./actions'); // Keep track of events related to just IP addresses module.exports = function (limits, now) { now = now || Date.now; var ERRNO_THROTTLED = 114; function IpRecord() { this.lf = []; this.vc = []; this.as = []; } IpRecord.parse = function (object) { var rec = new IpRecord(); object = object || {}; rec.bk = object.bk; // timestamp when the IP address was blocked rec.su = object.su; // timestamp when the IP address was suspected rec.di = object.di; // timestamp when the IP address was disabled rec.lf = object.lf || []; // timestamp+email+errno when failed login attempts occurred rec.vc = object.vc || []; // timestamp+email when code verifications occurred rec.as = object.as || []; // timestamp+email when account status checks occurred 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 rec.ov = object.ov || []; // timpstamp of password reset OTP verification request return rec; }; IpRecord.prototype.getMinLifetimeMS = function () { return Math.max( limits.blockIntervalMs, limits.ipRateLimitIntervalMs, limits.ipRateLimitBanDurationMs ); }; IpRecord.prototype.isOverBadLogins = function () { this.trimBadLogins(now()); // IPs are limited based on the number of unique email // addresses they access. Sum the highest-weighted // bad-login event for each user account to determine // the overall bad-logins score. var weights = {}; this.lf.forEach(function (info) { var user = info.u; var errno = info.e; weights[user] = Math.max( limits.badLoginErrnoWeights[errno] || 1, weights[user] || 0 ); }); var total = 0; Object.keys(weights).forEach(function (user) { total += weights[user]; }); return total > limits.maxBadLoginsPerIp; }; IpRecord.prototype.addBadLogin = function (info) { info = info || {}; var t = now(); var email = info.email || ''; var errno = info.errno || 999; this.trimBadLogins(t); this.lf.push({ t: t, e: Number(errno), u: email }); }; IpRecord.prototype.trimBadLogins = function (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.addPasswordResetOtpVerification = function () { this.ov.push(now()); }; IpRecord.prototype.trimPasswordResetOtpVerifications = function (now) { this.ov = this.ov.filter( (otpVerificationTime) => otpVerificationTime > now - limits.passwordResetOtpVerificationBlockWindowMs ); }; IpRecord.prototype.isOverPasswordResetOtpVerificationRateLimit = function () { const thisIsNow = now(); return ( this.ov.filter( (otpVerificationTime) => otpVerificationTime > thisIsNow - limits.passwordResetOtpVerificationRateLimitWindowMs ).length >= limits.maxPasswordResetOtpVerificationRateLimit ); }; IpRecord.prototype.isOverPasswordResetOtpVerificationBlockLimit = function () { this.trimPasswordResetOtpVerifications(now()); return this.ov.length >= limits.maxPasswordResetOtpVerificationBlockLimit; }; IpRecord.prototype.isOverVerifyCodes = function () { this.trimVerifyCodes(now()); // Limit based on number of unique emails accessed by this IP. var count = 0; var seen = {}; this.vc.forEach(function (info) { if (!(info.u in seen)) { count += 1; seen[info.u] = true; } }); return count > limits.maxVerifyCodes; }; IpRecord.prototype.addVerifyCode = function (info) { info = info || {}; var t = now(); var email = info.email || ''; this.trimVerifyCodes(t); this.vc.push({ t: t, u: email }); }; IpRecord.prototype.trimVerifyCodes = function (now) { this.vc = this._trim(now, this.vc, limits.maxVerifyCodes); }; IpRecord.prototype.isOverAccountStatusCheck = function () { this.trimAccountStatus(now()); // Limit based on number of unique emails checked by this IP. var count = 0; var seen = {}; this.as.forEach(function (info) { if (!(info.u in seen)) { count += 1; seen[info.u] = true; } }); return count > limits.maxAccountStatusCheck; }; IpRecord.prototype.addAccountStatusCheck = function (info) { info = info || {}; var t = now(); var email = info.email || ''; this.trimAccountStatus(t); this.as.push({ t: t, u: email }); }; IpRecord.prototype.trimAccountStatus = function (now) { this.as = this._trim(now, this.as, limits.maxAccountStatusCheck); }; IpRecord.prototype.isOverSmsLimit = function () { this.trimSmsRequests(now()); return this.sms.length > limits.maxSms; }; IpRecord.prototype.addSmsRequest = function () { this.sms.push(now()); }; IpRecord.prototype.trimSmsRequests = function (now) { if (this.sms.length === 0) { return; } // xs is naturally ordered from oldest to newest // and we only need to keep up to limits.maxSms + 1 var i = this.sms.length - 1; var n = 0; var hit = this.sms[i]; // Remove non-numbers and expired entries from list while (hit > now - limits.ipRateLimitIntervalMs && n <= limits.maxSms) { hit = this.sms[--i]; n++; } this.sms = this.sms.slice(i + 1); }; IpRecord.prototype.addAccountAccess = function () { this.aa.push(now()); }; IpRecord.prototype.isOverAccountAccessLimit = function () { return this.aa.length > limits.maxAccountAccess; }; IpRecord.prototype._trim = function (now, items, maxUnique) { if (items.length === 0) { return items; } // the list is naturally ordered from oldest to newest, // and we only need to keep data for up to maxUnique + 1 unique emails. var i = items.length - 1; var n = 0; var seen = {}; var item = items[i]; while (item.t > now - limits.ipRateLimitIntervalMs && n <= maxUnique) { if (!(item.u in seen)) { seen[item.u] = true; n++; } item = items[--i]; if (i === -1) { break; } } return items.slice(i + 1); }; IpRecord.prototype.shouldBlock = function () { return this.isBlocked() || this.isDisabled() || this.isRateLimited(); }; IpRecord.prototype.isBlocked = function () { return !!(this.bk && now() - this.bk < limits.blockIntervalMs); }; IpRecord.prototype.isSuspected = function () { return !!(this.su && now() - this.su < limits.suspectIntervalMs); }; IpRecord.prototype.isDisabled = function () { return !!(this.di && now() - this.di < limits.disableIntervalMs); }; IpRecord.prototype.isRateLimited = function () { return !!(this.rl && now() - this.rl < limits.ipRateLimitBanDurationMs); }; IpRecord.prototype.block = function () { this.bk = now(); }; IpRecord.prototype.suspect = function () { this.su = now(); }; IpRecord.prototype.disable = function () { this.di = now(); }; IpRecord.prototype.rateLimit = function () { this.rl = now(); this.as = []; this.sms = []; this.aa = []; }; IpRecord.prototype.passwordReset = function () { // reset the OTP timestamps so the user can start another password reset // flow if they wish this.os = []; this.ov = []; }; IpRecord.prototype.retryAfter = function () { var rateLimitAfter = Math.ceil( ((this.rl || 0) + limits.ipRateLimitBanDurationMs - now()) / 1000 ); var banAfter = Math.ceil( ((this.bk || 0) + limits.blockIntervalMs - now()) / 1000 ); return Math.max(0, rateLimitAfter, banAfter); }; IpRecord.prototype.update = function (action, email) { // ip block is explicit, no escape hatches if (this.isBlocked()) { return this.retryAfter(); } // Increment account-status-check count and throttle if needed if (actions.isAccountStatusAction(action)) { this.addAccountStatusCheck({ email: email }); if (this.isOverAccountStatusCheck()) { // If you do more checks while rate-limited, this can extend the ban. this.rateLimit(); } } // Increment verify-code-check count and throttle if needed if (actions.isCodeVerifyingAction(action)) { this.addVerifyCode({ email: email }); if (this.isOverVerifyCodes()) { // If you do more checks while rate-limited, this can extend the ban. this.rateLimit(); } } // Increment password-check count and throttle if needed if (actions.isPasswordCheckingAction(action)) { if (this.isRateLimited() || this.isOverBadLogins()) { // If we block an attempt, it still counts as a bad login. this.addBadLogin({ email: email, errno: ERRNO_THROTTLED }); } if (this.isOverBadLogins()) { // If you attempt more logins while rate-limited, this can extend the ban. this.rateLimit(); } } // Increment sms request count and throttle if needed if (actions.isSmsSendingAction(action)) { this.addSmsRequest(); if (this.isOverSmsLimit()) { // If you do more than the limit this can extend the ban. this.rateLimit(); } } if (actions.isAccountAccessAction(action)) { this.addAccountAccess(); if (this.isOverAccountAccessLimit()) { this.rateLimit(); } else { // Ignore the `rl` flag if we're not past the threshold for this action // because it's sometimes set by allow-listed email addresses in /check, // but we have no email address to allow-list against in this case. return 0; } } if (actions.isResetPasswordOtpSendingAction(action)) { const shouldRateLimit = this.isOverPasswordResetOtpLimit(); if (shouldRateLimit) { const latest = this.os[this.os.length - 1]; return Math.ceil( (latest + limits.passwordResetOtpEmailRateLimitIntervalMs - now()) / 1000 ); } this.addPasswordResetOtp(); return 0; } if (actions.isResetPasswordOtpVerificationAction(action)) { const shouldBlock = this.isOverPasswordResetOtpVerificationBlockLimit(); const shouldRateLimit = this.isOverPasswordResetOtpVerificationRateLimit(); if (shouldBlock || shouldRateLimit) { const latest = this.ov[this.ov.length - 1]; if (shouldBlock) { return Math.ceil( (latest + limits.passwordResetOtpVerificationBlockWindowMs - now()) / 1000 ); } if (shouldRateLimit) { return Math.ceil( (latest + limits.passwordResetOtpVerificationRateLimitWindowMs - now()) / 1000 ); } } this.addPasswordResetOtpVerification(); return 0; } return this.retryAfter(); }; return IpRecord; };