From 4a4b32597a7fd9d0ab90ec0d99956e9e75c687ef Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 17 Jun 2015 10:51:59 -0700 Subject: [PATCH 01/34] Created a new rate limiter package with working tests and functionality --- packages/rate-limit/README.md | 0 packages/rate-limit/package.js | 24 +++ packages/rate-limit/rate-limit-tests.js | 145 ++++++++++++++++++ packages/rate-limit/rate-limit.js | 192 ++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 packages/rate-limit/README.md create mode 100644 packages/rate-limit/package.js create mode 100644 packages/rate-limit/rate-limit-tests.js create mode 100644 packages/rate-limit/rate-limit.js diff --git a/packages/rate-limit/README.md b/packages/rate-limit/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js new file mode 100644 index 0000000000..d0bead01c1 --- /dev/null +++ b/packages/rate-limit/package.js @@ -0,0 +1,24 @@ +Package.describe({ + name: 'rate-limit', + version: '0.0.1', + // Brief, one-line summary of the package. + summary: '', + // URL to the Git repository containing the source code for this package. + git: '', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md' +}); + +Package.onUse(function(api) { +// api.versionsFrom('1.1.0.2'); + api.addFiles('rate-limit.js'); + api.export("RateLimiter"); +}); + +Package.onTest(function(api) { + api.use('tinytest'); + api.use('ddp-common'); + api.use('rate-limit'); + api.addFiles('rate-limit-tests.js'); +}); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js new file mode 100644 index 0000000000..736bf6e813 --- /dev/null +++ b/packages/rate-limit/rate-limit-tests.js @@ -0,0 +1,145 @@ +// Write your tests here! +// Here is an example. +Tinytest.add('example', function (test) { + test.equal(true, true); +}); + +Tinytest.add('Check empty constructor creation', function (test) { + r = new RateLimiter(); + test.equal(r.rules, []); + test.equal(r.ruleId, 0); + test.equal(r.ruleInvocationCounters, {}); +}); + +Tinytest.add('Check single rule with multiple invocations, only 1 that matches', function (test) { + r = new RateLimiter(); + var myUserId = 1; + var rule1 = { userId: myUserId, IPAddr: null, method: null}; + + r.addRule(rule1, 1, 1000); + var connectionHandle = createTempConnectionHandle(123, '127.0.0.1') + var methodInvc1 = createTempMethodInvocation(myUserId, connectionHandle, 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); + for (var i = 0; i < 2; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, true); + + /* setTimeout(function () { + for (var i = 0; i < 100; i++) { + r.increment(methodInvc2); + } + + test.equal(r.check(methodInvc1).valid, true); + test.equal(r.check(methodInvc2).valid, true); + }, 1000); */ +}); + +Tinytest.add('Check two rules that affect same methodInvc still throw', function (test) { + r = new RateLimiter(); + var loginRule = { userId: null, IPAddr: null, method: 'login'}; + var userIdRule = { userId: function(userId) { return userId % 2 === 0}, IPAddr: null, method: null}; + r.addRule(loginRule, 10, 100); + r.addRule(userIdRule, 4, 100); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'test'); + + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + r.increment(methodInvc3); + }; + + // After for loop runs, we only have 10 runs, so that's under the limit + test.equal(r.check(methodInvc1).valid, true); + // However, this triggers userId rule since this userId is even + test.equal(r.check(methodInvc2).valid, false); + + // Running one more test causes it to be false, since we're at 11 now. + r.increment(methodInvc1); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc3).valid, true); + +}); + +Tinytest.add('Check two rules that are affected by different invocations', function (test) { + r = new RateLimiter(); + var loginRule = { userId: null, IPAddr: null, method: 'login'} + r.addRule(loginRule, 10, 10000); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); + + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + r.increment(methodInvc1); + + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, false); +}); + +Tinytest.add("add global rule", function (test) { + r = new RateLimiter(); + var globalRule = { userId: null, IPAddr: null, method: null} + r.addRule(globalRule, 1, 10000); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var connectionHandle2 = createTempConnectionHandle(1234, '127.0.0.2'); + + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle2, 'test'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'user-accounts'); + + r.increment(methodInvc2); + test.equal(r.check(methodInvc1).valid, true); + test.equal(r.check(methodInvc2).valid, true); + test.equal(r.check(methodInvc3).valid, true); + r.increment(methodInvc3); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, false); + test.equal(r.check(methodInvc3).valid, false); + + +}) + +function createTempConnectionHandle(id, clientIP) { + return { + id: id, + close: function () { + self.close(); + }, + onClose: function (fn) { + var cb = Meteor.bindEnvironment(fn, "connection onClose callback"); + if (self.inQueue) { + self._closeCallbacks.push(cb); + } else { + // if we're already closed, call the callback. + Meteor.defer(cb); + } + }, + clientAddress: clientIP, + httpHeaders: null + }; +} + +function createTempMethodInvocation(userId, connectionHandle, methodName) { + var methodInv = new DDPCommon.MethodInvocation({ + isSimulation: false, + userId: userId, + setUserId: null, + unblock: false, + connection: connectionHandle, + randomSeed: 1234 + }); + methodInv.method = methodName; + return methodInv; +} + diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js new file mode 100644 index 0000000000..a701133545 --- /dev/null +++ b/packages/rate-limit/rate-limit.js @@ -0,0 +1,192 @@ +// Write your package code here! +var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; +var DEFAULT_REQUESTS_PER_INTERVAL = 10; +var RateLimitingDict = { + userId: 'userId', + IPAddr: 'connection.clientAddress', + method: 'method' + } + +var ruleMappingtoMethodInvocationDict = function(d, m) { + var arr = RateLimitingDict[d].split('.'); + while (firstGuy = arr.shift()) { + if (m[firstGuy]) + m = m[firstGuy]; + } + return m; + }; + +RateLimiter = function () { + var self = this; + self.rules = []; + self.ruleId = 0; + self.ruleInvocationCounters = {}; +} + +/** + * Returns an object of invocation validity, time to reset and number of calls left + * @param {[type]} + * @return {[type]} + */ +RateLimiter.prototype.check = function(methodInvocation) { + var self = this; + var reply = {valid: true, timeToReset: Infinity, numInvocationsLeft: Infinity}; + // Figure out all the rules this method invocation matches + _.find(self.rules, function(rule) { + // Check if this rule should be applied for this method invocation + if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { + var methodString = RateLimiter._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + var numInvocations = self.ruleInvocationCounters[rule.ruleId][methodString]; + + if (numInvocations > rule.numRequestsAllowed && timeSinceLastReset < rule.intervalTime) { + reply = {valid: false, timeToReset: timeToNextReset, numInvocationsLeft: 0} + return true; + } else { + if (rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft) + reply = {valid: true, timeToReset: timeToNextReset < 0 ? rule.intervalTime : + timeToNextReset, numInvocationsLeft: rule.numRequestsAllowed - numInvocations}; + } + } + }); + return reply; +} + +/** + * Add a rule to a specified domain or white list certain domains + * @param {object} identifierQuery Domain to add rate limit rule + * identifierQuery = { userId: ID | function() | null, + * IPAddr: ID | function() | null, + * method: name | function() | null, + * , + * , + * }. + * ALL FUNCTIONS MUST RETURN TRUE/FALSE to input to determine whether it applies or not + * @param {int} numRequestsAllowed number of requests allowed per timeframe + */ + +RateLimiter.prototype.addRule = function(identifierQuery, numRequestsAllowed, intervalTime) { + identifierQuery.ruleId = this._createNewRuleId(); + identifierQuery.numRequestsAllowed = numRequestsAllowed ? numRequestsAllowed : DEFAULT_REQUESTS_PER_INTERVAL; + identifierQuery.intervalTime = intervalTime ? intervalTime : DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; + identifierQuery._lastResetTime = new Date().getTime(); + this.rules.push(identifierQuery); +} +/** + * Initial version of Match Rule - NO LONGER NECESSARY :D + * @param {[type]} + * @param {[type]} + * @return {[type]} + */ +RateLimiter.prototype.matchRule = function(rule, methodInvocation) { + console.log(rule); + var results = _.map(rule, function(value, key) { + if (typeof value === 'function') { + // TODO BROKEN - metod invocation in this case must be specified to be an IP, user.Id or client addr + if (! value(methodInvocation)) { + return false; + } + } else if (value === null) { + return; + } else if (key === 'userId') { + if (value !== methodInvocation.userId) { + return false; + } + } else if (key === 'IPAddr') { + if (value !== methodInvocation.connection.clientAddress) { + return false; + } + } else if (key === 'method') { + if (value !== methodInvocation.method) { + return false; + } + } + return true; + }); + + var firstFalse = _.find(results, function(v) { return !v; }); + return firstFalse !== false; +} +/** + * @param {object} rule Rule that is defined according to the spec above + * @param {object} methodInvocation Method Invocation as described in DDPCommon.MethodInvocation with an added field of method + * @return {boolean} boolean True if this methodInvocation matches said rule, false otherwise + */ +RateLimiter.prototype.matchRuleUsingFind = function(rule, methodInvocation) { + var ruleMatches = true; + + _.find(RateLimitingDict, function(value, key) { + if (rule[key]) { + var methodInvocationValue = ruleMappingtoMethodInvocationDict(key, methodInvocation); + if (typeof rule[key] === 'function') { + if (! rule[key](methodInvocationValue)) { + ruleMatches = false; + return true; + } + } else { + if (rule[key] !== methodInvocationValue) { + ruleMatches = false; + return true; + } + } + } + }); + + return ruleMatches; +} + +/** + * @param {object} methodInvocation Method invocation object + * @return {[type]} + */ +RateLimiter.prototype.increment = function(methodInvocation) { + var self = this; + // Figure out all the rules this method invocation matches + _.each(this.rules, function(rule) { + // Check if this rule should be applied for this method invocation + if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { + var methodString = RateLimiter._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + if (timeSinceLastReset > rule.intervalTime) { + rule._lastResetTime = new Date().getTime(); + // Reset all the counters for this rule + _.each(self.ruleInvocationCounters[rule.ruleId], function(methodString) { + methodString = 0; + }); + } + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + if (rule.ruleId in self.ruleInvocationCounters) { + if (methodString in self.ruleInvocationCounters[rule.ruleId]) { + self.ruleInvocationCounters[rule.ruleId][methodString]++; + } else { + self.ruleInvocationCounters[rule.ruleId][methodString] = 1; + } + } else { + self.ruleInvocationCounters[rule.ruleId] = {}; + self.ruleInvocationCounters[rule.ruleId][methodString] = 1; + } + } + }); +} + +RateLimiter.prototype._createNewRuleId = function() { + return this.ruleId++; +} + +RateLimiter._generateMethodInvocationKeyStringFromRuleMapping = function(rule, methodInvocation) { + var returnString = ""; + + _.each(RateLimitingDict, function(value, key) { + if (rule[key]) { + var methodValue = ruleMappingtoMethodInvocationDict(key, methodInvocation); + if (typeof rule[key] === 'function') { + returnString += key + rule[key](methodValue) + } + returnString += key + methodValue; + } + }); + return returnString; +} + + From aae2d5f4549b189ee8430c9803d38c0624fabcdc Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 17 Jun 2015 16:02:10 -0700 Subject: [PATCH 02/34] Added documentation to rate limit package and wrote more tests. Cleaned up code in rate limit, added appropriate packages to package.js and refactored rate limit. --- packages/rate-limit/package.js | 4 +- packages/rate-limit/rate-limit-tests.js | 30 +++++- packages/rate-limit/rate-limit.js | 136 ++++++++++++------------ 3 files changed, 99 insertions(+), 71 deletions(-) diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js index d0bead01c1..86cfa859f7 100644 --- a/packages/rate-limit/package.js +++ b/packages/rate-limit/package.js @@ -17,8 +17,10 @@ Package.onUse(function(api) { }); Package.onTest(function(api) { + api.use('test-helpers', ['client', 'server']); + api.use('ddp-rate-limiter'); api.use('tinytest'); - api.use('ddp-common'); api.use('rate-limit'); + api.use('ddp-common'); api.addFiles('rate-limit-tests.js'); }); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 736bf6e813..f1a3d07ac9 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -37,6 +37,34 @@ Tinytest.add('Check single rule with multiple invocations, only 1 that matches', }, 1000); */ }); +/*testAsyncMulti("Run multiple invocations and wait for one to return", [ + function (test, expect) { + var self = this; + self.r = new RateLimiter(); + self.myUserId = 1; + self.rule1 = { userId: self.myUserId, IPAddr: null, method: null}; + + self.r.addRule(self.rule1, 1, 1000); + self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') + self.methodInvc1 = createTempMethodInvocation(self.myUserId, self.connectionHandle, 'login'); + self.methodInvc2 = createTempMethodInvocation(2, self.connectionHandle, 'login'); + for (var i = 0; i < 2; i++) { + self.r.increment(self.methodInvc1); + self.r.increment(self.methodInvc2); + } + test.equal(self.r.check(self.methodInvc1).valid, false); + test.equal(self.r.check(self.methodInvc2).valid, true); + setTimeout(expect(function(){}), 1000); +}, function (test, expect) { + var self = this; + for (var i = 0; i < 100; i++) { + self.r.increment(self.methodInvc2); + } + + test.equal(self.r.check(self.methodInvc1).valid, true); + test.equal(self.r.check(self.methodInvc2).valid, true); +}]); */ + Tinytest.add('Check two rules that affect same methodInvc still throw', function (test) { r = new RateLimiter(); var loginRule = { userId: null, IPAddr: null, method: 'login'}; @@ -106,8 +134,6 @@ Tinytest.add("add global rule", function (test) { test.equal(r.check(methodInvc1).valid, false); test.equal(r.check(methodInvc2).valid, false); test.equal(r.check(methodInvc3).valid, false); - - }) function createTempConnectionHandle(id, clientIP) { diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index a701133545..980a89e06e 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -1,21 +1,15 @@ -// Write your package code here! +// Default time interval (in milliseconds) to reset rate limit counters var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; +// Default number of requets allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; -var RateLimitingDict = { - userId: 'userId', - IPAddr: 'connection.clientAddress', - method: 'method' - } - -var ruleMappingtoMethodInvocationDict = function(d, m) { - var arr = RateLimitingDict[d].split('.'); - while (firstGuy = arr.shift()) { - if (m[firstGuy]) - m = m[firstGuy]; - } - return m; - }; +// Mapping from rate limiting rules format to method invocation fields. +var RATE_LIMITING_DICT = { + userId: 'userId', + IPAddr: 'connection.clientAddress', + method: 'method' +} +// Initialize rules, ruleId, and invocations to be empty RateLimiter = function () { var self = this; self.rules = []; @@ -24,9 +18,9 @@ RateLimiter = function () { } /** - * Returns an object of invocation validity, time to reset and number of calls left - * @param {[type]} - * @return {[type]} + * Checks if this method invocation is valid + * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name + * @return {object} Returns object of whether method invocation is valid, time to next reset and number invocations left */ RateLimiter.prototype.check = function(methodInvocation) { var self = this; @@ -35,7 +29,7 @@ RateLimiter.prototype.check = function(methodInvocation) { _.find(self.rules, function(rule) { // Check if this rule should be applied for this method invocation if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { - var methodString = RateLimiter._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); + var methodString = self._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; var timeToNextReset = rule.intervalTime - timeSinceLastReset; var numInvocations = self.ruleInvocationCounters[rule.ruleId][methodString]; @@ -50,6 +44,7 @@ RateLimiter.prototype.check = function(methodInvocation) { } } }); + // console.log('method invocation ', methodInvocation, ' reply :' , reply); return reply; } @@ -66,6 +61,16 @@ RateLimiter.prototype.check = function(methodInvocation) { * @param {int} numRequestsAllowed number of requests allowed per timeframe */ +/** + * Adds a rule to with a specified identifier query to list of rules that are checked against on every method invocation + * @param {object} identifierQuery Specified domain for rate limit rule + * { userId: ID | function() | null, + * IPAddr: ID | function() | null, + * method: name | function() | null + * ** All functions must return true/false to input to determine whether it applies or not ** + * @param {integer} numRequestsAllowed Number of requests allowed per interval + * @param {integer} intervalTime Number of milliseconds before interval is reset + */ RateLimiter.prototype.addRule = function(identifierQuery, numRequestsAllowed, intervalTime) { identifierQuery.ruleId = this._createNewRuleId(); identifierQuery.numRequestsAllowed = numRequestsAllowed ? numRequestsAllowed : DEFAULT_REQUESTS_PER_INTERVAL; @@ -73,52 +78,24 @@ RateLimiter.prototype.addRule = function(identifierQuery, numRequestsAllowed, in identifierQuery._lastResetTime = new Date().getTime(); this.rules.push(identifierQuery); } -/** - * Initial version of Match Rule - NO LONGER NECESSARY :D - * @param {[type]} - * @param {[type]} - * @return {[type]} - */ -RateLimiter.prototype.matchRule = function(rule, methodInvocation) { - console.log(rule); - var results = _.map(rule, function(value, key) { - if (typeof value === 'function') { - // TODO BROKEN - metod invocation in this case must be specified to be an IP, user.Id or client addr - if (! value(methodInvocation)) { - return false; - } - } else if (value === null) { - return; - } else if (key === 'userId') { - if (value !== methodInvocation.userId) { - return false; - } - } else if (key === 'IPAddr') { - if (value !== methodInvocation.connection.clientAddress) { - return false; - } - } else if (key === 'method') { - if (value !== methodInvocation.method) { - return false; - } - } - return true; - }); - var firstFalse = _.find(results, function(v) { return !v; }); - return firstFalse !== false; -} /** * @param {object} rule Rule that is defined according to the spec above * @param {object} methodInvocation Method Invocation as described in DDPCommon.MethodInvocation with an added field of method * @return {boolean} boolean True if this methodInvocation matches said rule, false otherwise */ +/** + * Matches whether a given method invocation matches a certain rule. Short circuits search if rule and method invocation don't match + * @param {object} rule Rule as defined as an identifierQuery above + * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name + * @return {boolean} Returns whether the methodInvocation matches inputted rule + */ RateLimiter.prototype.matchRuleUsingFind = function(rule, methodInvocation) { + var self = this; var ruleMatches = true; - - _.find(RateLimitingDict, function(value, key) { + _.find(RATE_LIMITING_DICT, function(value, key) { if (rule[key]) { - var methodInvocationValue = ruleMappingtoMethodInvocationDict(key, methodInvocation); + var methodInvocationValue = self._ruleMappingtoMethodInvocationDict(key, methodInvocation); if (typeof rule[key] === 'function') { if (! rule[key](methodInvocationValue)) { ruleMatches = false; @@ -137,8 +114,8 @@ RateLimiter.prototype.matchRuleUsingFind = function(rule, methodInvocation) { } /** - * @param {object} methodInvocation Method invocation object - * @return {[type]} + * Increment appropriate counters on every method invocation + * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name */ RateLimiter.prototype.increment = function(methodInvocation) { var self = this; @@ -146,13 +123,13 @@ RateLimiter.prototype.increment = function(methodInvocation) { _.each(this.rules, function(rule) { // Check if this rule should be applied for this method invocation if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { - var methodString = RateLimiter._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); + var methodString = self._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; if (timeSinceLastReset > rule.intervalTime) { rule._lastResetTime = new Date().getTime(); // Reset all the counters for this rule - _.each(self.ruleInvocationCounters[rule.ruleId], function(methodString) { - methodString = 0; + _.each(self.ruleInvocationCounters[rule.ruleId], function(value, methodString) { + self.ruleInvocationCounters[rule.ruleId][methodString] = 0; }); } var timeToNextReset = rule.intervalTime - timeSinceLastReset; @@ -170,23 +147,46 @@ RateLimiter.prototype.increment = function(methodInvocation) { }); } +// Creates new unique rule id RateLimiter.prototype._createNewRuleId = function() { return this.ruleId++; } -RateLimiter._generateMethodInvocationKeyStringFromRuleMapping = function(rule, methodInvocation) { +/** + * Generates string of fields that match between method invocation and rule to be used as a key for counters dictionary per rule + * @param {object} rule Rule defined as identifierQuery in addRule + * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name + * @return {string} Key string made of all fields from rule that match in method invocation + */ +RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = function(rule, methodInvocation) { + var self = this; var returnString = ""; - - _.each(RateLimitingDict, function(value, key) { + _.each(RATE_LIMITING_DICT, function(value, key) { if (rule[key]) { - var methodValue = ruleMappingtoMethodInvocationDict(key, methodInvocation); + var methodValue = self._ruleMappingtoMethodInvocationDict(key, methodInvocation); if (typeof rule[key] === 'function') { - returnString += key + rule[key](methodValue) - } - returnString += key + methodValue; + if (rule[key](methodValue)) + returnString += key + methodValue; + } else { + returnString += key + methodValue; + } } }); return returnString; } +/** + * Helper method that uses the RATE_LIMITING_DICT to create a fast way to access values in methodInvocation without manually parsing the paths + * @param {string} key Key in rule dictionary (ie userId, IPAddr, method) + * @param {string} methodInvocation MethodInvocation object that is traversed to get the final value + * @return {object} Returns a string, value, or object of whatever is stored in appropriate field in MethodInvocation + */ +RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function(key, methodInvocation) { + var arr = RATE_LIMITING_DICT[key].split('.'); + while (firstGuy = arr.shift()) { + if (firstGuy in methodInvocation) + methodInvocation = methodInvocation[firstGuy]; + } + return methodInvocation; +}; From b999270ae12bef6e626cca29b79e55cef219d095 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 17 Jun 2015 16:03:25 -0700 Subject: [PATCH 03/34] Created default DDPRateLimiter and updated DDPServer to include a rate limiter. DDPRateLimiter is a global rate limiter with a public API to add rules, set the default error message and an option to pass in a configuration of rules. It is integrated into DDP already to check on every method invocation. --- packages/ddp-rate-limiter/README.md | 0 .../ddp-rate-limiter-tests.js | 5 ++++ packages/ddp-rate-limiter/ddp-rate-limiter.js | 20 ++++++++++++++++ packages/ddp-rate-limiter/package.js | 24 +++++++++++++++++++ packages/ddp-server/livedata_server.js | 16 +++++++++++-- packages/ddp-server/package.js | 2 +- 6 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/ddp-rate-limiter/README.md create mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter-tests.js create mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter.js create mode 100644 packages/ddp-rate-limiter/package.js diff --git a/packages/ddp-rate-limiter/README.md b/packages/ddp-rate-limiter/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js new file mode 100644 index 0000000000..c5623d89b9 --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -0,0 +1,5 @@ +// Write your tests here! +// Here is an example. +Tinytest.add('example', function (test) { + test.equal(true, true); +}); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js new file mode 100644 index 0000000000..5bb7b1e82b --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -0,0 +1,20 @@ +// Write your package code here! +DDPRateLimiter = {} + +DDPRateLimiter.RateLimiter = new RateLimiter(); +DDPRateLimiter.ErrorMessage = function (rateLimitResult) { + return "Error, too many requests. Please slow down. You must wait " + + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before trying again."; + } + +DDPRateLimiter.config = function (rules) { + DDPRateLimiter.RateLimiter.rules = rules; +}; + +DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { + DDPRateLimiter.RateLimiter.addRule(rule, numRequests, intervalTime); +}; + +DDPRateLimiter.setErrorMessage = function (message) { + DDPRateLimiter.ErrorMessage = message; +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js new file mode 100644 index 0000000000..8bf381ef4d --- /dev/null +++ b/packages/ddp-rate-limiter/package.js @@ -0,0 +1,24 @@ +Package.describe({ + name: 'ddp-rate-limiter', + version: '0.0.1', + // Brief, one-line summary of the package. + summary: '', + // URL to the Git repository containing the source code for this package. + git: '', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md' +}); + +Package.onUse(function(api) { +// api.versionsFrom('1.1.0.2'); + api.use('rate-limit'); + api.export('DDPRateLimiter'); + api.addFiles('ddp-rate-limiter.js'); +}); + +Package.onTest(function(api) { + api.use('tinytest'); + api.use('ddp-rate-limiter'); + api.addFiles('ddp-rate-limiter-tests.js'); +}); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 7d3ef47818..0574bbab67 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -242,8 +242,7 @@ var Session = function (server, version, socket, options) { self._namedSubs = {}; self._universalSubs = []; - self.userId = null; - + self.userId = null; self.collectionViews = {}; // Set this to false to not send messages when collectionViews are @@ -644,7 +643,20 @@ _.extend(Session.prototype, { connection: self.connectionHandle, randomSeed: randomSeed }); + invocation.method = msg.method; try { + // xcxc maybe here something like: + // _.each(methodValidators, function (validator) { + // + // }) (!validateMethodInvocation(invocation)) { + // throw new RateLimitError(); + // } + DDPRateLimiter.RateLimiter.increment(invocation); + var rateLimitResult = DDPRateLimiter.RateLimiter.check(invocation) + if (!rateLimitResult.valid) { + throw new Meteor.Error(429, DDPRateLimiter.ErrorMessage(rateLimitResult)); + } + var result = DDPServer._CurrentWriteFence.withValue(fence, function () { return DDP._CurrentInvocation.withValue(invocation, function () { return maybeAuditArgumentChecks( diff --git a/packages/ddp-server/package.js b/packages/ddp-server/package.js index 6c7c3ce6b2..b76435494a 100644 --- a/packages/ddp-server/package.js +++ b/packages/ddp-server/package.js @@ -15,7 +15,7 @@ Package.onUse(function (api) { // common functionality api.use('ddp-common', 'server'); // heartbeat - + api.use('ddp-rate-limiter'); // Transport api.use('ddp-client', 'server'); api.imply('ddp-client'); From 9134542675c9dadf9b9ae5a56003f91cb4cf793d Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 17 Jun 2015 16:30:54 -0700 Subject: [PATCH 04/34] Updated rate-limit tests to work with setTimeout by using Meteor.setTimeout --- packages/rate-limit/rate-limit-tests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index f1a3d07ac9..a92d19e2a7 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -37,7 +37,7 @@ Tinytest.add('Check single rule with multiple invocations, only 1 that matches', }, 1000); */ }); -/*testAsyncMulti("Run multiple invocations and wait for one to return", [ +testAsyncMulti("Run multiple invocations and wait for one to return", [ function (test, expect) { var self = this; self.r = new RateLimiter(); @@ -54,7 +54,7 @@ Tinytest.add('Check single rule with multiple invocations, only 1 that matches', } test.equal(self.r.check(self.methodInvc1).valid, false); test.equal(self.r.check(self.methodInvc2).valid, true); - setTimeout(expect(function(){}), 1000); + Meteor.setTimeout(expect(function(){}), 1000); }, function (test, expect) { var self = this; for (var i = 0; i < 100; i++) { @@ -63,7 +63,7 @@ Tinytest.add('Check single rule with multiple invocations, only 1 that matches', test.equal(self.r.check(self.methodInvc1).valid, true); test.equal(self.r.check(self.methodInvc2).valid, true); -}]); */ +}]); Tinytest.add('Check two rules that affect same methodInvc still throw', function (test) { r = new RateLimiter(); From 9afcf90503c0fa65683495acd3ce34c90d8ce2f5 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 17 Jun 2015 20:45:11 -0700 Subject: [PATCH 05/34] Fixed line wraps and some basic styling. --- .../ddp-rate-limiter-tests.js | 54 ++- packages/ddp-rate-limiter/ddp-rate-limiter.js | 5 + packages/ddp-rate-limiter/package.js | 4 +- packages/ddp-server/livedata_server.js | 6 - packages/rate-limit/rate-limit-tests.js | 324 ++++++++++-------- packages/rate-limit/rate-limit.js | 283 +++++++-------- 6 files changed, 382 insertions(+), 294 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index c5623d89b9..3558cf6906 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,5 +1,53 @@ // Write your tests here! // Here is an example. -Tinytest.add('example', function (test) { - test.equal(true, true); -}); +Tinytest.add( 'example', function( test ) { + test.equal( true, true ); +} ); + +DDPRateLimiter.addRule( { + userId: null, + IPAddr: null, + method: 'login' +}, 5, 10000 ); + +if ( Meteor.isClient ) { + testAsyncMulti( "passwords - basic login with password", [ + function( test, expect ) { + // setup + this.username = Random.id(); + this.email = Random.id() + '-intercept@example.com'; + this.password = 'password'; + + Accounts.createUser( { + username: this.username, + email: this.email, + password: this.password + }, + function() {} ); + }, + function( test, expect ) { + test.notEqual( Meteor.userId(), null ); + }, + function( test, expect ) { + Meteor.logout( expect( function( error ) { + test.equal( error, undefined ); + test.equal( Meteor.user(), null ); + } ) ); + }, + function( test, expect ) { + var self = this; + for ( var i = 0; i < 100; i++ ) { + + Meteor.loginWithPassword( self.username, 'fakePassword', function( + error ) { + console.log( "We threw an error.", error ); + } ); + + + } + + } + + + ] ); +}; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index 5bb7b1e82b..b529936860 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -2,6 +2,11 @@ DDPRateLimiter = {} DDPRateLimiter.RateLimiter = new RateLimiter(); +// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. Override using DDPRateLimiter.config + +DDPRateLimiter.addRule({ userId: null, IPAddr : function (IPAddr) { + return true }, method: 'login'}, 5, 10000); + DDPRateLimiter.ErrorMessage = function (rateLimitResult) { return "Error, too many requests. Please slow down. You must wait " + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before trying again."; diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index 8bf381ef4d..07734c1631 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -18,7 +18,9 @@ Package.onUse(function(api) { }); Package.onTest(function(api) { - api.use('tinytest'); + api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', + 'accounts-base', 'random', 'email', 'underscore', 'check', + 'ddp']); api.use('ddp-rate-limiter'); api.addFiles('ddp-rate-limiter-tests.js'); }); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 0574bbab67..b6fddf72a1 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -645,12 +645,6 @@ _.extend(Session.prototype, { }); invocation.method = msg.method; try { - // xcxc maybe here something like: - // _.each(methodValidators, function (validator) { - // - // }) (!validateMethodInvocation(invocation)) { - // throw new RateLimitError(); - // } DDPRateLimiter.RateLimiter.increment(invocation); var rateLimitResult = DDPRateLimiter.RateLimiter.check(invocation) if (!rateLimitResult.valid) { diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index a92d19e2a7..16ed22874c 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -1,171 +1,203 @@ // Write your tests here! // Here is an example. -Tinytest.add('example', function (test) { - test.equal(true, true); -}); +Tinytest.add( 'example', function( test ) { + test.equal( true, true ); +} ); -Tinytest.add('Check empty constructor creation', function (test) { - r = new RateLimiter(); - test.equal(r.rules, []); - test.equal(r.ruleId, 0); - test.equal(r.ruleInvocationCounters, {}); -}); +Tinytest.add( 'Check empty constructor creation', function( test ) { + r = new RateLimiter(); + test.equal( r.rules, [] ); + test.equal( r.ruleId, 0 ); + test.equal( r.ruleInvocationCounters, {} ); +} ); -Tinytest.add('Check single rule with multiple invocations, only 1 that matches', function (test) { - r = new RateLimiter(); - var myUserId = 1; - var rule1 = { userId: myUserId, IPAddr: null, method: null}; +Tinytest.add( + 'Check single rule with multiple invocations, only 1 that matches', + function( test ) { + r = new RateLimiter(); + var myUserId = 1; + var rule1 = { + userId: myUserId, + IPAddr: null, + method: null + }; - r.addRule(rule1, 1, 1000); - var connectionHandle = createTempConnectionHandle(123, '127.0.0.1') - var methodInvc1 = createTempMethodInvocation(myUserId, connectionHandle, 'login'); - var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); - for (var i = 0; i < 2; i++) { - r.increment(methodInvc1); - r.increment(methodInvc2); - } - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, true); + r.addRule( rule1, 1, 1000 ); + var connectionHandle = createTempConnectionHandle( 123, '127.0.0.1' ); + var methodInvc1 = createTempMethodInvocation( myUserId, connectionHandle, + 'login' ); + var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, + 'login' ); + for ( var i = 0; i < 2; i++ ) { + r.increment( methodInvc1 ); + r.increment( methodInvc2 ); + } + test.equal( r.check( methodInvc1 ).valid, false ); + test.equal( r.check( methodInvc2 ).valid, true ); + } ); - /* setTimeout(function () { - for (var i = 0; i < 100; i++) { - r.increment(methodInvc2); - } - - test.equal(r.check(methodInvc1).valid, true); - test.equal(r.check(methodInvc2).valid, true); - }, 1000); */ -}); - -testAsyncMulti("Run multiple invocations and wait for one to return", [ - function (test, expect) { - var self = this; +testAsyncMulti( "Run multiple invocations and wait for one to return", [ + function( test, expect ) { + var self = this; self.r = new RateLimiter(); - self.myUserId = 1; - self.rule1 = { userId: self.myUserId, IPAddr: null, method: null}; + self.myUserId = 1; + self.rule1 = { + userId: self.myUserId, + IPAddr: null, + method: null + }; + self.r.addRule( self.rule1, 1, 1000 ); + self.connectionHandle = createTempConnectionHandle( 123, '127.0.0.1' ) + self.methodInvc1 = createTempMethodInvocation( self.myUserId, self.connectionHandle, + 'login' ); + self.methodInvc2 = createTempMethodInvocation( 2, self.connectionHandle, + 'login' ); + for ( var i = 0; i < 2; i++ ) { + self.r.increment( self.methodInvc1 ); + self.r.increment( self.methodInvc2 ); + } + test.equal( self.r.check( self.methodInvc1 ).valid, false ); + test.equal( self.r.check( self.methodInvc2 ).valid, true ); + Meteor.setTimeout( expect( function() {} ), 1000 ); + }, + function( test, expect ) { + var self = this; + for ( var i = 0; i < 100; i++ ) { + self.r.increment( self.methodInvc2 ); + } - self.r.addRule(self.rule1, 1, 1000); - self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') - self.methodInvc1 = createTempMethodInvocation(self.myUserId, self.connectionHandle, 'login'); - self.methodInvc2 = createTempMethodInvocation(2, self.connectionHandle, 'login'); - for (var i = 0; i < 2; i++) { - self.r.increment(self.methodInvc1); - self.r.increment(self.methodInvc2); - } - test.equal(self.r.check(self.methodInvc1).valid, false); - test.equal(self.r.check(self.methodInvc2).valid, true); - Meteor.setTimeout(expect(function(){}), 1000); -}, function (test, expect) { - var self = this; - for (var i = 0; i < 100; i++) { - self.r.increment(self.methodInvc2); - } - - test.equal(self.r.check(self.methodInvc1).valid, true); - test.equal(self.r.check(self.methodInvc2).valid, true); -}]); + test.equal( self.r.check( self.methodInvc1 ).valid, true ); + test.equal( self.r.check( self.methodInvc2 ).valid, true ); + } +] ); -Tinytest.add('Check two rules that affect same methodInvc still throw', function (test) { - r = new RateLimiter(); - var loginRule = { userId: null, IPAddr: null, method: 'login'}; - var userIdRule = { userId: function(userId) { return userId % 2 === 0}, IPAddr: null, method: null}; - r.addRule(loginRule, 10, 100); - r.addRule(userIdRule, 4, 100); +Tinytest.add( 'Check two rules that affect same methodInvc still throw', + function( test ) { + r = new RateLimiter(); + var loginRule = { + userId: null, + IPAddr: null, + method: 'login' + }; + var userIdRule = { + userId: function( userId ) { + return userId % 2 === 0 + }, + IPAddr: null, + method: null + }; + r.addRule( loginRule, 10, 100 ); + r.addRule( userIdRule, 4, 100 ); - var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); - var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); - var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'test'); + var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); + var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, + 'login' ); + var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, + 'login' ); + var methodInvc3 = createTempMethodInvocation( 3, connectionHandle, 'test' ); - for (var i = 0; i < 5; i++) { - r.increment(methodInvc1); - r.increment(methodInvc2); - r.increment(methodInvc3); - }; + for ( var i = 0; i < 5; i++ ) { + r.increment( methodInvc1 ); + r.increment( methodInvc2 ); + r.increment( methodInvc3 ); + }; - // After for loop runs, we only have 10 runs, so that's under the limit - test.equal(r.check(methodInvc1).valid, true); - // However, this triggers userId rule since this userId is even - test.equal(r.check(methodInvc2).valid, false); + // After for loop runs, we only have 10 runs, so that's under the limit + test.equal( r.check( methodInvc1 ).valid, true ); + // However, this triggers userId rule since this userId is even + test.equal( r.check( methodInvc2 ).valid, false ); - // Running one more test causes it to be false, since we're at 11 now. - r.increment(methodInvc1); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc3).valid, true); + // Running one more test causes it to be false, since we're at 11 now. + r.increment( methodInvc1 ); + test.equal( r.check( methodInvc1 ).valid, false ); + test.equal( r.check( methodInvc3 ).valid, true ); -}); + } ); -Tinytest.add('Check two rules that are affected by different invocations', function (test) { - r = new RateLimiter(); - var loginRule = { userId: null, IPAddr: null, method: 'login'} - r.addRule(loginRule, 10, 10000); +Tinytest.add( 'Check two rules that are affected by different invocations', + function( test ) { + r = new RateLimiter(); + var loginRule = { + userId: null, + IPAddr: null, + method: 'login' + } + r.addRule( loginRule, 10, 10000 ); - var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); - var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); + var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); + var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, + 'login' ); + var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, + 'login' ); - for (var i = 0; i < 5; i++) { - r.increment(methodInvc1); - r.increment(methodInvc2); - } - r.increment(methodInvc1); + for ( var i = 0; i < 5; i++ ) { + r.increment( methodInvc1 ); + r.increment( methodInvc2 ); + } + r.increment( methodInvc1 ); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, false); -}); + test.equal( r.check( methodInvc1 ).valid, false ); + test.equal( r.check( methodInvc2 ).valid, false ); + } ); -Tinytest.add("add global rule", function (test) { - r = new RateLimiter(); - var globalRule = { userId: null, IPAddr: null, method: null} - r.addRule(globalRule, 1, 10000); +Tinytest.add( "add global rule", function( test ) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + method: null + } + r.addRule( globalRule, 1, 10000 ); - var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - var connectionHandle2 = createTempConnectionHandle(1234, '127.0.0.2'); + var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); + var connectionHandle2 = createTempConnectionHandle( 1234, '127.0.0.2' ); - var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); - var methodInvc2 = createTempMethodInvocation(2, connectionHandle2, 'test'); - var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'user-accounts'); + var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, + 'login' ); + var methodInvc2 = createTempMethodInvocation( 2, connectionHandle2, + 'test' ); + var methodInvc3 = createTempMethodInvocation( 3, connectionHandle, + 'user-accounts' ); - r.increment(methodInvc2); - test.equal(r.check(methodInvc1).valid, true); - test.equal(r.check(methodInvc2).valid, true); - test.equal(r.check(methodInvc3).valid, true); - r.increment(methodInvc3); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, false); - test.equal(r.check(methodInvc3).valid, false); -}) + r.increment( methodInvc2 ); + test.equal( r.check( methodInvc1 ).valid, true ); + test.equal( r.check( methodInvc2 ).valid, true ); + test.equal( r.check( methodInvc3 ).valid, true ); + r.increment( methodInvc3 ); + test.equal( r.check( methodInvc1 ).valid, false ); + test.equal( r.check( methodInvc2 ).valid, false ); + test.equal( r.check( methodInvc3 ).valid, false ); +} ); -function createTempConnectionHandle(id, clientIP) { - return { - id: id, - close: function () { - self.close(); - }, - onClose: function (fn) { - var cb = Meteor.bindEnvironment(fn, "connection onClose callback"); - if (self.inQueue) { - self._closeCallbacks.push(cb); - } else { - // if we're already closed, call the callback. - Meteor.defer(cb); - } - }, - clientAddress: clientIP, - httpHeaders: null - }; -} - -function createTempMethodInvocation(userId, connectionHandle, methodName) { - var methodInv = new DDPCommon.MethodInvocation({ - isSimulation: false, - userId: userId, - setUserId: null, - unblock: false, - connection: connectionHandle, - randomSeed: 1234 - }); - methodInv.method = methodName; - return methodInv; +function createTempConnectionHandle( id, clientIP ) { + return { + id: id, + close: function() { + self.close(); + }, + onClose: function( fn ) { + var cb = Meteor.bindEnvironment( fn, "connection onClose callback" ); + if ( self.inQueue ) { + self._closeCallbacks.push( cb ); + } else { + // if we're already closed, call the callback. + Meteor.defer( cb ); + } + }, + clientAddress: clientIP, + httpHeaders: null + }; } +function createTempMethodInvocation( userId, connectionHandle, methodName ) { + var methodInv = new DDPCommon.MethodInvocation( { + isSimulation: false, + userId: userId, + setUserId: null, + unblock: false, + connection: connectionHandle, + randomSeed: 1234 + } ); + methodInv.method = methodName; + return methodInv; +} \ No newline at end of file diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 980a89e06e..a4deefcbbd 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -4,189 +4,196 @@ var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; var DEFAULT_REQUESTS_PER_INTERVAL = 10; // Mapping from rate limiting rules format to method invocation fields. var RATE_LIMITING_DICT = { - userId: 'userId', - IPAddr: 'connection.clientAddress', - method: 'method' + userId: 'userId', + IPAddr: 'connection.clientAddress', + method: 'method' } // Initialize rules, ruleId, and invocations to be empty -RateLimiter = function () { - var self = this; - self.rules = []; - self.ruleId = 0; - self.ruleInvocationCounters = {}; +RateLimiter = function() { + var self = this; + self.rules = []; + self.ruleId = 0; + self.ruleInvocationCounters = {}; } /** * Checks if this method invocation is valid * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name - * @return {object} Returns object of whether method invocation is valid, time to next reset and number invocations left + * @return {object} Returns object of whether method invocation is valid, time to next reset and number invocations left */ -RateLimiter.prototype.check = function(methodInvocation) { - var self = this; - var reply = {valid: true, timeToReset: Infinity, numInvocationsLeft: Infinity}; - // Figure out all the rules this method invocation matches - _.find(self.rules, function(rule) { - // Check if this rule should be applied for this method invocation - if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { - var methodString = self._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - var timeToNextReset = rule.intervalTime - timeSinceLastReset; - var numInvocations = self.ruleInvocationCounters[rule.ruleId][methodString]; - - if (numInvocations > rule.numRequestsAllowed && timeSinceLastReset < rule.intervalTime) { - reply = {valid: false, timeToReset: timeToNextReset, numInvocationsLeft: 0} - return true; - } else { - if (rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft) - reply = {valid: true, timeToReset: timeToNextReset < 0 ? rule.intervalTime : - timeToNextReset, numInvocationsLeft: rule.numRequestsAllowed - numInvocations}; - } - } - }); - // console.log('method invocation ', methodInvocation, ' reply :' , reply); - return reply; -} +RateLimiter.prototype.check = function( methodInvocation ) { + var self = this; + var reply = { + valid: true, + timeToReset: Infinity, + numInvocationsLeft: Infinity + }; + // Figure out all the rules this method invocation matches + _.find( self.rules, function( rule ) { + // Check if this rule should be applied for this method invocation + if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { + var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( + rule, methodInvocation ); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + var numInvocations = self.ruleInvocationCounters[ rule.ruleId ][ + methodString + ]; -/** - * Add a rule to a specified domain or white list certain domains - * @param {object} identifierQuery Domain to add rate limit rule - * identifierQuery = { userId: ID | function() | null, - * IPAddr: ID | function() | null, - * method: name | function() | null, - * , - * , - * }. - * ALL FUNCTIONS MUST RETURN TRUE/FALSE to input to determine whether it applies or not - * @param {int} numRequestsAllowed number of requests allowed per timeframe - */ + if ( numInvocations > rule.numRequestsAllowed && timeSinceLastReset < + rule.intervalTime ) { + reply = { + valid: false, + timeToReset: timeToNextReset, + numInvocationsLeft: 0 + } + return true; + } else { + if ( rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft ) + reply = { + valid: true, + timeToReset: timeToNextReset < 0 ? rule.intervalTime : timeToNextReset, + numInvocationsLeft: rule.numRequestsAllowed - numInvocations + }; + } + } + } ); + // console.log('method invocation ', methodInvocation, ' reply :' , reply); + return reply; +} /** * Adds a rule to with a specified identifier query to list of rules that are checked against on every method invocation * @param {object} identifierQuery Specified domain for rate limit rule * { userId: ID | function() | null, - * IPAddr: ID | function() | null, - * method: name | function() | null - * ** All functions must return true/false to input to determine whether it applies or not ** + * IPAddr: ID | function() | null, + * method: name | function() | null + * All functions must return T/F to input to determine rule match * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset */ -RateLimiter.prototype.addRule = function(identifierQuery, numRequestsAllowed, intervalTime) { - identifierQuery.ruleId = this._createNewRuleId(); - identifierQuery.numRequestsAllowed = numRequestsAllowed ? numRequestsAllowed : DEFAULT_REQUESTS_PER_INTERVAL; - identifierQuery.intervalTime = intervalTime ? intervalTime : DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; - identifierQuery._lastResetTime = new Date().getTime(); - this.rules.push(identifierQuery); +RateLimiter.prototype.addRule = function( identifierQuery, numRequestsAllowed, + intervalTime ) { + identifierQuery.ruleId = this._createNewRuleId(); + identifierQuery.numRequestsAllowed = numRequestsAllowed ? + numRequestsAllowed : DEFAULT_REQUESTS_PER_INTERVAL; + identifierQuery.intervalTime = intervalTime ? intervalTime : + DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; + identifierQuery._lastResetTime = new Date().getTime(); + this.rules.push( identifierQuery ); } -/** - * @param {object} rule Rule that is defined according to the spec above - * @param {object} methodInvocation Method Invocation as described in DDPCommon.MethodInvocation with an added field of method - * @return {boolean} boolean True if this methodInvocation matches said rule, false otherwise - */ /** * Matches whether a given method invocation matches a certain rule. Short circuits search if rule and method invocation don't match - * @param {object} rule Rule as defined as an identifierQuery above + * @param {object} rule Rule as defined as an identifierQuery above * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name - * @return {boolean} Returns whether the methodInvocation matches inputted rule + * @return {boolean} Returns whether the methodInvocation matches inputted rule */ -RateLimiter.prototype.matchRuleUsingFind = function(rule, methodInvocation) { - var self = this; - var ruleMatches = true; - _.find(RATE_LIMITING_DICT, function(value, key) { - if (rule[key]) { - var methodInvocationValue = self._ruleMappingtoMethodInvocationDict(key, methodInvocation); - if (typeof rule[key] === 'function') { - if (! rule[key](methodInvocationValue)) { - ruleMatches = false; - return true; - } - } else { - if (rule[key] !== methodInvocationValue) { - ruleMatches = false; - return true; - } - } - } - }); +RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { + var self = this; + var ruleMatches = true; + _.find( RATE_LIMITING_DICT, function( value, key ) { + if ( rule[ key ] ) { + var methodInvocationValue = self._ruleMappingtoMethodInvocationDict( + key, methodInvocation ); + if ( typeof rule[ key ] === 'function' ) { + if ( !rule[ key ]( methodInvocationValue ) ) { + ruleMatches = false; + return true; + } + } else { + if ( rule[ key ] !== methodInvocationValue ) { + ruleMatches = false; + return true; + } + } + } + } ); - return ruleMatches; + return ruleMatches; } /** * Increment appropriate counters on every method invocation * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name */ -RateLimiter.prototype.increment = function(methodInvocation) { - var self = this; - // Figure out all the rules this method invocation matches - _.each(this.rules, function(rule) { - // Check if this rule should be applied for this method invocation - if (RateLimiter.prototype.matchRuleUsingFind(rule, methodInvocation)) { - var methodString = self._generateMethodInvocationKeyStringFromRuleMapping(rule, methodInvocation); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - if (timeSinceLastReset > rule.intervalTime) { - rule._lastResetTime = new Date().getTime(); - // Reset all the counters for this rule - _.each(self.ruleInvocationCounters[rule.ruleId], function(value, methodString) { - self.ruleInvocationCounters[rule.ruleId][methodString] = 0; - }); - } - var timeToNextReset = rule.intervalTime - timeSinceLastReset; - if (rule.ruleId in self.ruleInvocationCounters) { - if (methodString in self.ruleInvocationCounters[rule.ruleId]) { - self.ruleInvocationCounters[rule.ruleId][methodString]++; - } else { - self.ruleInvocationCounters[rule.ruleId][methodString] = 1; - } - } else { - self.ruleInvocationCounters[rule.ruleId] = {}; - self.ruleInvocationCounters[rule.ruleId][methodString] = 1; - } - } - }); +RateLimiter.prototype.increment = function( methodInvocation ) { + var self = this; + // Figure out all the rules this method invocation matches + _.each( this.rules, function( rule ) { + // Check if this rule should be applied for this method invocation + if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { + var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( + rule, methodInvocation ); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + if ( timeSinceLastReset > rule.intervalTime ) { + rule._lastResetTime = new Date().getTime(); + // Reset all the counters for this rule + _.each( self.ruleInvocationCounters[ rule.ruleId ], function( + value, methodString ) { + self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = + 0; + } ); + } + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + if ( rule.ruleId in self.ruleInvocationCounters ) { + if ( methodString in self.ruleInvocationCounters[ rule.ruleId ] ) { + self.ruleInvocationCounters[ rule.ruleId ][ methodString ]++; + } else { + self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = 1; + } + } else { + self.ruleInvocationCounters[ rule.ruleId ] = {}; + self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = 1; + } + } + } ); } // Creates new unique rule id RateLimiter.prototype._createNewRuleId = function() { - return this.ruleId++; + return this.ruleId++; } /** * Generates string of fields that match between method invocation and rule to be used as a key for counters dictionary per rule - * @param {object} rule Rule defined as identifierQuery in addRule + * @param {object} rule Rule defined as identifierQuery in addRule * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name - * @return {string} Key string made of all fields from rule that match in method invocation + * @return {string} Key string made of all fields from rule that match in method invocation */ -RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = function(rule, methodInvocation) { - var self = this; - var returnString = ""; - _.each(RATE_LIMITING_DICT, function(value, key) { - if (rule[key]) { - var methodValue = self._ruleMappingtoMethodInvocationDict(key, methodInvocation); - if (typeof rule[key] === 'function') { - if (rule[key](methodValue)) - returnString += key + methodValue; - } else { - returnString += key + methodValue; - } - } - }); - return returnString; -} +RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = + function( rule, methodInvocation ) { + var self = this; + var returnString = ""; + _.each( RATE_LIMITING_DICT, function( value, key ) { + if ( rule[ key ] ) { + var methodValue = self._ruleMappingtoMethodInvocationDict( key, + methodInvocation ); + if ( typeof rule[ key ] === 'function' ) { + if ( rule[ key ]( methodValue ) ) + returnString += key + methodValue; + } else { + returnString += key + methodValue; + } + } + } ); + return returnString; + } /** * Helper method that uses the RATE_LIMITING_DICT to create a fast way to access values in methodInvocation without manually parsing the paths * @param {string} key Key in rule dictionary (ie userId, IPAddr, method) * @param {string} methodInvocation MethodInvocation object that is traversed to get the final value - * @return {object} Returns a string, value, or object of whatever is stored in appropriate field in MethodInvocation + * @return {object} Returns a string, value, or object of whatever is stored in appropriate field in MethodInvocation */ -RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function(key, methodInvocation) { +RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, + methodInvocation ) { - var arr = RATE_LIMITING_DICT[key].split('.'); - while (firstGuy = arr.shift()) { - if (firstGuy in methodInvocation) - methodInvocation = methodInvocation[firstGuy]; - } - return methodInvocation; -}; + var arr = RATE_LIMITING_DICT[ key ].split( '.' ); + while ( firstGuy = arr.shift() ) { + if ( firstGuy in methodInvocation ) + methodInvocation = methodInvocation[ firstGuy ]; + } + return methodInvocation; +}; \ No newline at end of file From 239f806c7d5085beea00554800bcdba3f91f796d Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 22 Jun 2015 15:40:56 -0700 Subject: [PATCH 06/34] Code cleanup including 80 character limit formatting and refactoring. Refactored rate-limit.js to remove duplicate code. Removed commented out code and extra whitespace. --- .../ddp-rate-limiter-tests.js | 5 - packages/ddp-rate-limiter/ddp-rate-limiter.js | 34 +++-- packages/ddp-rate-limiter/package.js | 2 +- packages/ddp-server/livedata_server.js | 6 +- packages/rate-limit/rate-limit-tests.js | 1 + packages/rate-limit/rate-limit.js | 123 +++++++++++------- 6 files changed, 100 insertions(+), 71 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 3558cf6906..cc06404cb9 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -42,12 +42,7 @@ if ( Meteor.isClient ) { error ) { console.log( "We threw an error.", error ); } ); - - } - } - - ] ); }; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index b529936860..a333785add 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,25 +1,31 @@ -// Write your package code here! +// Rate Limiter built into DDP DDPRateLimiter = {} DDPRateLimiter.RateLimiter = new RateLimiter(); -// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. Override using DDPRateLimiter.config -DDPRateLimiter.addRule({ userId: null, IPAddr : function (IPAddr) { - return true }, method: 'login'}, 5, 10000); +// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. +// Override using DDPRateLimiter.config +DDPRateLimiter.RateLimiter.addRule( { + userId: null, + IPAddr: function( IPAddr ) { + return true + }, + method: 'login' +}, 5, 10000 ); -DDPRateLimiter.ErrorMessage = function (rateLimitResult) { - return "Error, too many requests. Please slow down. You must wait " - + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before trying again."; - } +DDPRateLimiter.getErrorMessage = function( rateLimitResult ) { + return "Error, too many requests. Please slow down. You must wait " + Math.ceil( + rateLimitResult.timeToReset / 1000 ) + " seconds before trying again."; +} -DDPRateLimiter.config = function (rules) { - DDPRateLimiter.RateLimiter.rules = rules; +DDPRateLimiter.config = function( rules ) { + DDPRateLimiter.RateLimiter.rules = rules; }; -DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { - DDPRateLimiter.RateLimiter.addRule(rule, numRequests, intervalTime); +DDPRateLimiter.addRule = function( rule, numRequests, intervalTime ) { + DDPRateLimiter.RateLimiter.addRule( rule, numRequests, intervalTime ); }; -DDPRateLimiter.setErrorMessage = function (message) { - DDPRateLimiter.ErrorMessage = message; +DDPRateLimiter.setErrorMessage = function( message ) { + DDPRateLimiter.ErrorMessage = message; } \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index 07734c1631..c1971d35d2 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -11,7 +11,7 @@ Package.describe({ }); Package.onUse(function(api) { -// api.versionsFrom('1.1.0.2'); +// api.versionsFrom('1.1.0.2'); api.use('rate-limit'); api.export('DDPRateLimiter'); api.addFiles('ddp-rate-limiter.js'); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index b6fddf72a1..73dc855530 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -242,7 +242,7 @@ var Session = function (server, version, socket, options) { self._namedSubs = {}; self._universalSubs = []; - self.userId = null; + self.userId = null; self.collectionViews = {}; // Set this to false to not send messages when collectionViews are @@ -648,9 +648,9 @@ _.extend(Session.prototype, { DDPRateLimiter.RateLimiter.increment(invocation); var rateLimitResult = DDPRateLimiter.RateLimiter.check(invocation) if (!rateLimitResult.valid) { - throw new Meteor.Error(429, DDPRateLimiter.ErrorMessage(rateLimitResult)); + throw new Meteor.Error(429, DDPRateLimiter.getErrorMessage(rateLimitResult)); } - + var result = DDPServer._CurrentWriteFence.withValue(fence, function () { return DDP._CurrentInvocation.withValue(invocation, function () { return maybeAuditArgumentChecks( diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 16ed22874c..3c6c97678d 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -105,6 +105,7 @@ Tinytest.add( 'Check two rules that affect same methodInvc still throw', // After for loop runs, we only have 10 runs, so that's under the limit test.equal( r.check( methodInvc1 ).valid, true ); // However, this triggers userId rule since this userId is even + test.equal(r.check(methodInvc2).valid, false); test.equal( r.check( methodInvc2 ).valid, false ); // Running one more test causes it to be false, since we're at 11 now. diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index a4deefcbbd..bd65f86af8 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -19,56 +19,59 @@ RateLimiter = function() { /** * Checks if this method invocation is valid - * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name - * @return {object} Returns object of whether method invocation is valid, time to next reset and number invocations left + * @param {object} methodInvocation DDPCommon.MethodInvocation object with + * added 'method' attribute listing the method name + * @return {object} Returns object of whether method invocation is valid, time + * to next reset and number invocations left */ RateLimiter.prototype.check = function( methodInvocation ) { var self = this; var reply = { valid: true, - timeToReset: Infinity, + timeToReset: 0, numInvocationsLeft: Infinity }; // Figure out all the rules this method invocation matches - _.find( self.rules, function( rule ) { + _.each( self.rules, function( rule ) { // Check if this rule should be applied for this method invocation if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { - var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( - rule, methodInvocation ); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - var timeToNextReset = rule.intervalTime - timeSinceLastReset; - var numInvocations = self.ruleInvocationCounters[ rule.ruleId ][ - methodString - ]; + var matchRuleHelper = self._matchRuleHelper( rule, methodInvocation ); - if ( numInvocations > rule.numRequestsAllowed && timeSinceLastReset < - rule.intervalTime ) { - reply = { - valid: false, - timeToReset: timeToNextReset, - numInvocationsLeft: 0 - } - return true; + var numInvocations = self.ruleInvocationCounters[ rule.ruleId ] + [ matchRuleHelper.methodString ]; + + if ( numInvocations > rule.numRequestsAllowed && + matchRuleHelper.timeSinceLastReset < rule.intervalTime ) { + if ( reply.timeToReset < matchRuleHelper.timeToNextReset ) { + reply.timeToReset = matchRuleHelper.timeToNextReset; + }; + reply.valid = false; + reply.numInvocationsLeft = 0; } else { - if ( rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft ) + if ( rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && + reply.valid ) { reply = { valid: true, - timeToReset: timeToNextReset < 0 ? rule.intervalTime : timeToNextReset, - numInvocationsLeft: rule.numRequestsAllowed - numInvocations + timeToReset: matchRuleHelper.timeToNextReset < 0 ? + rule.intervalTime : matchRuleHelper.timeToNextReset, + numInvocationsLeft: rule.numRequestsAllowed - + numInvocations }; + } } } } ); - // console.log('method invocation ', methodInvocation, ' reply :' , reply); + return reply; } /** - * Adds a rule to with a specified identifier query to list of rules that are checked against on every method invocation + * Adds a rule to with a specified identifier query to list of rules that are + * checked against on every method invocation * @param {object} identifierQuery Specified domain for rate limit rule * { userId: ID | function() | null, - * IPAddr: ID | function() | null, - * method: name | function() | null + * IPAddr: ID | function() | null, + * method: name | function() | null * All functions must return T/F to input to determine rule match * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset @@ -76,18 +79,20 @@ RateLimiter.prototype.check = function( methodInvocation ) { RateLimiter.prototype.addRule = function( identifierQuery, numRequestsAllowed, intervalTime ) { identifierQuery.ruleId = this._createNewRuleId(); - identifierQuery.numRequestsAllowed = numRequestsAllowed ? - numRequestsAllowed : DEFAULT_REQUESTS_PER_INTERVAL; - identifierQuery.intervalTime = intervalTime ? intervalTime : + identifierQuery.numRequestsAllowed = numRequestsAllowed || + DEFAULT_REQUESTS_PER_INTERVAL; + identifierQuery.intervalTime = intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; identifierQuery._lastResetTime = new Date().getTime(); this.rules.push( identifierQuery ); } /** - * Matches whether a given method invocation matches a certain rule. Short circuits search if rule and method invocation don't match + * Matches whether a given method invocation matches a certain rule. Short + * circuits search if rule and method invocation don't match * @param {object} rule Rule as defined as an identifierQuery above - * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name + * @param {object} methodInvocation DDPCommon.MethodInvocation object with + * added 'method' attribute listing the method name * @return {boolean} Returns whether the methodInvocation matches inputted rule */ RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { @@ -116,7 +121,8 @@ RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { /** * Increment appropriate counters on every method invocation - * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name + * @param {object} methodInvocation DDPCommon.MethodInvocation object with + * added 'method' attribute listing the method name */ RateLimiter.prototype.increment = function( methodInvocation ) { var self = this; @@ -124,10 +130,9 @@ RateLimiter.prototype.increment = function( methodInvocation ) { _.each( this.rules, function( rule ) { // Check if this rule should be applied for this method invocation if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { - var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( - rule, methodInvocation ); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - if ( timeSinceLastReset > rule.intervalTime ) { + var matchRuleHelper = self._matchRuleHelper( rule, methodInvocation ) + + if ( matchRuleHelper.timeSinceLastReset > rule.intervalTime ) { rule._lastResetTime = new Date().getTime(); // Reset all the counters for this rule _.each( self.ruleInvocationCounters[ rule.ruleId ], function( @@ -136,16 +141,18 @@ RateLimiter.prototype.increment = function( methodInvocation ) { 0; } ); } - var timeToNextReset = rule.intervalTime - timeSinceLastReset; if ( rule.ruleId in self.ruleInvocationCounters ) { - if ( methodString in self.ruleInvocationCounters[ rule.ruleId ] ) { - self.ruleInvocationCounters[ rule.ruleId ][ methodString ]++; + if ( matchRuleHelper.methodString in self.ruleInvocationCounters[ + rule.ruleId ] ) { + self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ]++; } else { - self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = 1; + self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + 1; } } else { self.ruleInvocationCounters[ rule.ruleId ] = {}; - self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = 1; + self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + 1; } } } ); @@ -157,10 +164,13 @@ RateLimiter.prototype._createNewRuleId = function() { } /** - * Generates string of fields that match between method invocation and rule to be used as a key for counters dictionary per rule + * Generates string of fields that match between method invocation and rule to + * be used as a key for counters dictionary per rule * @param {object} rule Rule defined as identifierQuery in addRule - * @param {object} methodInvocation DDPCommon.MethodInvocation object with added 'method' attribute listing the method name - * @return {string} Key string made of all fields from rule that match in method invocation + * @param {object} methodInvocation DDPCommon.MethodInvocation object with + * added 'method' attribute listing the method name + * @return {string} Key string made of all fields from rule that match in + * method invocation */ RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = function( rule, methodInvocation ) { @@ -182,10 +192,13 @@ RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = } /** - * Helper method that uses the RATE_LIMITING_DICT to create a fast way to access values in methodInvocation without manually parsing the paths + * Helper method that uses the RATE_LIMITING_DICT to create a fast way to + * access values in methodInvocation without manually parsing the paths * @param {string} key Key in rule dictionary (ie userId, IPAddr, method) - * @param {string} methodInvocation MethodInvocation object that is traversed to get the final value - * @return {object} Returns a string, value, or object of whatever is stored in appropriate field in MethodInvocation + * @param {string} methodInvocation MethodInvocation object that is traversed + * to get the final value + * @return {object} Returns a string, value, or object of whatever is stored in + * appropriate field in MethodInvocation */ RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, methodInvocation ) { @@ -196,4 +209,18 @@ RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, methodInvocation = methodInvocation[ firstGuy ]; } return methodInvocation; -}; \ No newline at end of file +}; + +RateLimiter.prototype._matchRuleHelper = function( rule, methodInvocation ) { + var self = this; + + var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( + rule, methodInvocation ); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + return { + methodString: methodString, + timeSinceLastReset: timeSinceLastReset, + timeToNextReset: timeToNextReset + }; +} \ No newline at end of file From 3e342e04ebbf2f2244201d310a5de63e3df3a181 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 22 Jun 2015 22:13:31 -0700 Subject: [PATCH 07/34] Refactored rate-limit package. Changed rate-limit package to take generic rules and match generic inputs to those rules. Now, users can use the rate-limit package for whatever they would like. Tests still need to be updated and need to change location of ddp-rate-limiter to include subscriptions. Need to remove duplicate code in rate-limit from previous implementation which hardcoded DDP types into the rate limiting package by making the input be a DDPCommon.MethodInvocation object. --- packages/ddp-rate-limiter/ddp-rate-limiter.js | 2 +- packages/ddp-server/livedata_server.js | 32 +++- packages/rate-limit/rate-limit-tests.js | 73 ++++++++- packages/rate-limit/rate-limit.js | 153 +++++++++++++++--- 4 files changed, 232 insertions(+), 28 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index a333785add..7487fb7d9a 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -8,7 +8,7 @@ DDPRateLimiter.RateLimiter = new RateLimiter(); DDPRateLimiter.RateLimiter.addRule( { userId: null, IPAddr: function( IPAddr ) { - return true + return true; }, method: 'login' }, 5, 10000 ); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 73dc855530..08596fab3a 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -492,7 +492,7 @@ _.extend(Session.prototype, { var self = this; if (!self.inQueue) // we have been destroyed. return; - + // console.log(msg_in); // Respond to ping and pong messages immediately without queuing. // If the negotiated DDP version is "pre1" which didn't support // pings, preserve the "pre1" behavior of responding with a "bad @@ -543,8 +543,20 @@ _.extend(Session.prototype, { processNext(); }; - if (_.has(self.protocol_handlers, msg.msg)) - self.protocol_handlers[msg.msg].call(self, msg, unblock); + if (_.has(self.protocol_handlers, msg.msg)) { + // Is it bad to do these checks here? Instead of inside session.protocol_handlers and then in method + // var rateLimiterInput = { + // userId: self.userId, + // IPAddr: self.connectionHandle.clientAddress, + // type: msg.msg, + // name: msg.name } + // DDPRateLimiter.RateLimiter.increment(rateLimiterInput); + // var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) + // if (!rateLimitResult.valid) + // self.sendError('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)); + // else + self.protocol_handlers[msg.msg].call(self, msg, unblock); + } else self.sendError('Bad request', msg); unblock(); // in case the handler didn't already do it @@ -580,6 +592,8 @@ _.extend(Session.prototype, { return; var handler = self.server.publish_handlers[msg.name]; + + // console.log(msg); self._startSubscription(handler, msg.id, msg.params, msg.name); }, @@ -643,10 +657,14 @@ _.extend(Session.prototype, { connection: self.connectionHandle, randomSeed: randomSeed }); - invocation.method = msg.method; + // invocation.method = msg.method; + var rateLimiterInput = { + userId: self.userId, + IPAddr: self.connectionHandle.clientAddress, + method: msg.method}; try { - DDPRateLimiter.RateLimiter.increment(invocation); - var rateLimitResult = DDPRateLimiter.RateLimiter.check(invocation) + DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) if (!rateLimitResult.valid) { throw new Meteor.Error(429, DDPRateLimiter.getErrorMessage(rateLimitResult)); } @@ -766,7 +784,6 @@ _.extend(Session.prototype, { _startSubscription: function (handler, subId, params, name) { var self = this; - var sub = new Subscription( self, handler, subId, params, name); if (subId) @@ -1535,6 +1552,7 @@ _.extend(Server.prototype, { connection: connection, randomSeed: DDPCommon.makeRpcSeed(currentInvocation, name) }); + try { var result = DDP._CurrentInvocation.withValue(invocation, function () { return maybeAuditArgumentChecks( diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 3c6c97678d..d07b7bcfe0 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -8,7 +8,7 @@ Tinytest.add( 'Check empty constructor creation', function( test ) { r = new RateLimiter(); test.equal( r.rules, [] ); test.equal( r.ruleId, 0 ); - test.equal( r.ruleInvocationCounters, {} ); + test.equal( r.ruleCounters, {} ); } ); Tinytest.add( @@ -170,6 +170,77 @@ Tinytest.add( "add global rule", function( test ) { test.equal( r.check( methodInvc3 ).valid, false ); } ); +Tinytest.add("test matchRule method", function (test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null + } + + var RateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: 'sub', + name: 'getSubLists' + }; + + test.equal(r._matchRule(globalRule, RateLimiterInput), true); + + var oneNotNullRule = { + userId: 102, + IPAddr: null, + type: null, + name: null + } + + test.equal(r._matchRule(oneNotNullRule, RateLimiterInput), false); + + oneNotNullRule.userId = 1023; + test.equal(r._matchRule(oneNotNullRule, RateLimiterInput), true); + + var notCompleteInput = { userId: 102, IPAddr: '127.0.0.1'}; + test.equal(r._matchRule(globalRule, notCompleteInput), true); + test.equal(r._matchRule(oneNotNullRule, notCompleteInput), false); +}); + +Tinytest.add('test generateMethodKey string', function(test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null + } + + var RateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: 'sub', + name: 'getSubLists' + }; + + test.equal(r._generateKeyString(globalRule, RateLimiterInput), ""); + + globalRule.userId = 1023; + test.equal(r._generateKeyString(globalRule, RateLimiterInput), "userId1023"); + + var ruleWithFuncs = { + userId: function(input) { return input % 2 === 0}, + IPAddr: null, + type: null + }; + + test.equal(r._generateKeyString(ruleWithFuncs, RateLimiterInput), ""); + RateLimiterInput.userId = 1024; + test.equal(r._generateKeyString(ruleWithFuncs, RateLimiterInput), "userId1024"); + + var multipleRules = ruleWithFuncs; + multipleRules.IPAddr = '127.0.0.1'; + test.equal(r._generateKeyString(multipleRules, RateLimiterInput), "userId1024IPAddr127.0.0.1") +}) + function createTempConnectionHandle( id, clientIP ) { return { id: id, diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index bd65f86af8..45fc64a2af 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -14,7 +14,7 @@ RateLimiter = function() { var self = this; self.rules = []; self.ruleId = 0; - self.ruleInvocationCounters = {}; + self.ruleCounters = {}; } /** @@ -37,7 +37,7 @@ RateLimiter.prototype.check = function( methodInvocation ) { if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { var matchRuleHelper = self._matchRuleHelper( rule, methodInvocation ); - var numInvocations = self.ruleInvocationCounters[ rule.ruleId ] + var numInvocations = self.ruleCounters[ rule.ruleId ] [ matchRuleHelper.methodString ]; if ( numInvocations > rule.numRequestsAllowed && @@ -65,10 +65,39 @@ RateLimiter.prototype.check = function( methodInvocation ) { return reply; } +RateLimiter.prototype.newCheck = function ( input ) { + var self = this; + var reply = { + valid: true, + timeToReset: 0, + numInvocationsLeft: Infinity + }; + + _.each( self.rules, function( rule ) { + if ( self._matchRule( rule, input )) { + var matchRuleHelper = self._newRuleHelper( rule, input ); + var numInvocations = self.ruleCounters[ rule.ruleId ][matchRuleHelper.methodString]; + if (numInvocations > rule.numRequestsAllowed && matchRuleHelper.timeSinceLastReset < rule.intervalTime) { + if ( reply.timeToReset < matchRuleHelper.timeToNextReset ) { + reply.timeToReset = matchRuleHelper.timeToNextReset; + }; + reply.valid = false; + reply.numInvocationsLeft = 0; + } else { + if (rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.valid) { + reply.valid = true; + reply.timeToReset = matchRuleHelper.timeToNextReset < 0 ? rule.intervalTime : matchRuleHelper.timeToNextReset; + reply.numInvocationsLeft = rule.numRequestsAllowed - numInvocations; + } + } + } + }); + return reply; +} + /** - * Adds a rule to with a specified identifier query to list of rules that are - * checked against on every method invocation - * @param {object} identifierQuery Specified domain for rate limit rule + * Appends a rule to list of rules that are checked against on every method invocation + * @param {object} rule Specified domain for rate limit rule * { userId: ID | function() | null, * IPAddr: ID | function() | null, * method: name | function() | null @@ -76,15 +105,15 @@ RateLimiter.prototype.check = function( methodInvocation ) { * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset */ -RateLimiter.prototype.addRule = function( identifierQuery, numRequestsAllowed, +RateLimiter.prototype.addRule = function( rule, numRequestsAllowed, intervalTime ) { - identifierQuery.ruleId = this._createNewRuleId(); - identifierQuery.numRequestsAllowed = numRequestsAllowed || + rule.ruleId = this._createNewRuleId(); + rule.numRequestsAllowed = numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL; - identifierQuery.intervalTime = intervalTime || + rule.intervalTime = intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; - identifierQuery._lastResetTime = new Date().getTime(); - this.rules.push( identifierQuery ); + rule._lastResetTime = new Date().getTime(); + this.rules.push( rule ); } /** @@ -119,6 +148,33 @@ RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { return ruleMatches; } +RateLimiter.prototype._matchRule = function ( rule, input ) { + var self = this; + var ruleMatches = true; + _.find( rule, function ( value, key) { + if (value !== null && key != 'ruleId' && key != '_lastResetTime' && key != 'numRequestsAllowed' && key != 'intervalTime') { + if (!(key in input)) { + ruleMatches = false; + return true; + } else { + if (typeof value === 'function') { + if (!(value(input[key]))) { + ruleMatches = false; + return true; + } + } else { + if (value !== input[key]) { + ruleMatches = false; + return true; + } + } + } + } + }); + return ruleMatches; +} + + /** * Increment appropriate counters on every method invocation * @param {object} methodInvocation DDPCommon.MethodInvocation object with @@ -135,29 +191,59 @@ RateLimiter.prototype.increment = function( methodInvocation ) { if ( matchRuleHelper.timeSinceLastReset > rule.intervalTime ) { rule._lastResetTime = new Date().getTime(); // Reset all the counters for this rule - _.each( self.ruleInvocationCounters[ rule.ruleId ], function( + _.each( self.ruleCounters[ rule.ruleId ], function( value, methodString ) { - self.ruleInvocationCounters[ rule.ruleId ][ methodString ] = + self.ruleCounters[ rule.ruleId ][ methodString ] = 0; } ); } - if ( rule.ruleId in self.ruleInvocationCounters ) { - if ( matchRuleHelper.methodString in self.ruleInvocationCounters[ + if ( rule.ruleId in self.ruleCounters ) { + if ( matchRuleHelper.methodString in self.ruleCounters[ rule.ruleId ] ) { - self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ]++; + self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ]++; } else { - self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = 1; } } else { - self.ruleInvocationCounters[ rule.ruleId ] = {}; - self.ruleInvocationCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + self.ruleCounters[ rule.ruleId ] = {}; + self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = 1; } } } ); } +RateLimiter.prototype.newIncrement = function ( input ) { + var self = this; + + // Only increment rule counters that match this input + _.each ( self.rules, function ( rule ) { + if (self._matchRule( rule , input ) ) { + var matchRuleHelper = self._newRuleHelper( rule, input ); + + if ( matchRuleHelper.timeSinceLastReset > rule.intervalTime) { + // Reset all the counters since the rule has reset + rule._lastResetTime = new Date().getTime(); + _.each( self.ruleCounters [ rule.ruleId ], function (value, keyString) { + self.ruleCounters[ rule.ruleId][keyString ] = 0; + }); + } + + if ( rule.ruleId in self.ruleCounters ) { + if ( matchRuleHelper.methodString in self.ruleCounters[rule.ruleId] ) { + self.ruleCounters[ rule.ruleId ] [ matchRuleHelper.methodString ]++; + } else { + self.ruleCounters[ rule.ruleId ] [ matchRuleHelper.methodString ] = 1; + } + } else { + self.ruleCounters [ rule.ruleId ] = {}; + self.ruleCounters [ rule.ruleId ] [matchRuleHelper.methodString ] = 1; + } + } + }); +} + // Creates new unique rule id RateLimiter.prototype._createNewRuleId = function() { return this.ruleId++; @@ -191,6 +277,22 @@ RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = return returnString; } +RateLimiter.prototype._generateKeyString = function (rule, input) { + var self = this; + var returnString = ""; + _.each( rule, function ( value, key) { + if (value !== null) { + if (typeof value === 'function') { + if (value(input[key])) + returnString += key + input[key]; + } + else + returnString += key + input[key]; + } + }); + return returnString; +} + /** * Helper method that uses the RATE_LIMITING_DICT to create a fast way to * access values in methodInvocation without manually parsing the paths @@ -223,4 +325,17 @@ RateLimiter.prototype._matchRuleHelper = function( rule, methodInvocation ) { timeSinceLastReset: timeSinceLastReset, timeToNextReset: timeToNextReset }; +} + +RateLimiter.prototype._newRuleHelper = function (rule, input) { + var self = this; + var keyString = self._generateKeyString(rule, input); + var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + var timeToNextReset = rule.intervalTime - timeSinceLastReset; + return { + methodString: keyString, + timeSinceLastReset: timeSinceLastReset, + timeToNextReset: timeToNextReset + }; + } \ No newline at end of file From 2fd1c35da1295d7110dac5dbed59f67fdf784e68 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 22 Jun 2015 22:38:22 -0700 Subject: [PATCH 08/34] Generalized rate-limit package and updated livedata_server.js to accept rules on methods and subscriptions. Need to add checks and throw errors if wrong format / input to rate-limit package. Updated default rule in ddp-rate-limiter to reflect new rate-limit generic design and moved the location of the DDPRateLimiter in livedata_server.js. Need to fix tests for both ddp-rate-limiter and rate-limit packages with updated code and clean up rate-limit package. --- packages/ddp-rate-limiter/ddp-rate-limiter.js | 3 +- packages/ddp-server/livedata_server.js | 46 +++++++++++-------- packages/rate-limit/rate-limit.js | 30 ++++++------ 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index 7487fb7d9a..aa45a360ef 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -10,7 +10,8 @@ DDPRateLimiter.RateLimiter.addRule( { IPAddr: function( IPAddr ) { return true; }, - method: 'login' + type: 'method', + name: 'login' }, 5, 10000 ); DDPRateLimiter.getErrorMessage = function( rateLimitResult ) { diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 08596fab3a..b242c05d4b 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -545,16 +545,24 @@ _.extend(Session.prototype, { if (_.has(self.protocol_handlers, msg.msg)) { // Is it bad to do these checks here? Instead of inside session.protocol_handlers and then in method - // var rateLimiterInput = { - // userId: self.userId, - // IPAddr: self.connectionHandle.clientAddress, - // type: msg.msg, - // name: msg.name } - // DDPRateLimiter.RateLimiter.increment(rateLimiterInput); - // var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) - // if (!rateLimitResult.valid) - // self.sendError('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)); - // else + var rateLimiterInput = { + userId: self.userId, + IPAddr: self.connectionHandle.clientAddress, + type: msg.msg, + name: null + }; + // Janky solution because in method, methodName is in msg.method otherwise in msg.name for subscriptions + if (msg.msg === 'method') { + rateLimiterInput.name = msg.method; + } else if (msg.msg === 'sub') { + rateLimiterInput.name = msg.name; + } + DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) + if (!rateLimitResult.valid) { + self.sendError('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)); + } + else self.protocol_handlers[msg.msg].call(self, msg, unblock); } else @@ -658,16 +666,16 @@ _.extend(Session.prototype, { randomSeed: randomSeed }); // invocation.method = msg.method; - var rateLimiterInput = { - userId: self.userId, - IPAddr: self.connectionHandle.clientAddress, - method: msg.method}; + // var rateLimiterInput = { + // userId: self.userId, + // IPAddr: self.connectionHandle.clientAddress, + // method: msg.method}; try { - DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) - if (!rateLimitResult.valid) { - throw new Meteor.Error(429, DDPRateLimiter.getErrorMessage(rateLimitResult)); - } + // DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); + // var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) + // if (!rateLimitResult.valid) { + // throw new Meteor.Error(429, DDPRateLimiter.getErrorMessage(rateLimitResult)); + // } var result = DDPServer._CurrentWriteFence.withValue(fence, function () { return DDP._CurrentInvocation.withValue(invocation, function () { diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 45fc64a2af..b991433fbb 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -24,7 +24,7 @@ RateLimiter = function() { * @return {object} Returns object of whether method invocation is valid, time * to next reset and number invocations left */ -RateLimiter.prototype.check = function( methodInvocation ) { +/*RateLimiter.prototype.check = function( methodInvocation ) { var self = this; var reply = { valid: true, @@ -63,7 +63,7 @@ RateLimiter.prototype.check = function( methodInvocation ) { } ); return reply; -} +} */ RateLimiter.prototype.newCheck = function ( input ) { var self = this; @@ -124,7 +124,7 @@ RateLimiter.prototype.addRule = function( rule, numRequestsAllowed, * added 'method' attribute listing the method name * @return {boolean} Returns whether the methodInvocation matches inputted rule */ -RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { +/*RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { var self = this; var ruleMatches = true; _.find( RATE_LIMITING_DICT, function( value, key ) { @@ -146,7 +146,7 @@ RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { } ); return ruleMatches; -} +}*/ RateLimiter.prototype._matchRule = function ( rule, input ) { var self = this; @@ -180,7 +180,7 @@ RateLimiter.prototype._matchRule = function ( rule, input ) { * @param {object} methodInvocation DDPCommon.MethodInvocation object with * added 'method' attribute listing the method name */ -RateLimiter.prototype.increment = function( methodInvocation ) { +/*RateLimiter.prototype.increment = function( methodInvocation ) { var self = this; // Figure out all the rules this method invocation matches _.each( this.rules, function( rule ) { @@ -212,7 +212,7 @@ RateLimiter.prototype.increment = function( methodInvocation ) { } } } ); -} +}*/ RateLimiter.prototype.newIncrement = function ( input ) { var self = this; @@ -258,7 +258,7 @@ RateLimiter.prototype._createNewRuleId = function() { * @return {string} Key string made of all fields from rule that match in * method invocation */ -RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = +/*RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = function( rule, methodInvocation ) { var self = this; var returnString = ""; @@ -275,7 +275,7 @@ RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = } } ); return returnString; - } + }*/ RateLimiter.prototype._generateKeyString = function (rule, input) { var self = this; @@ -283,11 +283,13 @@ RateLimiter.prototype._generateKeyString = function (rule, input) { _.each( rule, function ( value, key) { if (value !== null) { if (typeof value === 'function') { - if (value(input[key])) + if (value(input[key])){ returnString += key + input[key]; + } } - else + else{ returnString += key + input[key]; + } } }); return returnString; @@ -302,7 +304,7 @@ RateLimiter.prototype._generateKeyString = function (rule, input) { * @return {object} Returns a string, value, or object of whatever is stored in * appropriate field in MethodInvocation */ -RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, +/*RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, methodInvocation ) { var arr = RATE_LIMITING_DICT[ key ].split( '.' ); @@ -311,8 +313,8 @@ RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, methodInvocation = methodInvocation[ firstGuy ]; } return methodInvocation; -}; - +}; */ +/* RateLimiter.prototype._matchRuleHelper = function( rule, methodInvocation ) { var self = this; @@ -325,7 +327,7 @@ RateLimiter.prototype._matchRuleHelper = function( rule, methodInvocation ) { timeSinceLastReset: timeSinceLastReset, timeToNextReset: timeToNextReset }; -} +} */ RateLimiter.prototype._newRuleHelper = function (rule, input) { var self = this; From 03f21b3bb32f0488cc2f8051ab6e9843824485be Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 23 Jun 2015 14:48:36 -0700 Subject: [PATCH 09/34] Removed dead code and fixed ddp-rate-limiter package. Cleaned up rate-limit package to remove old methods before refactor. Renamed private variables inside rate-limit package. Updated livedata_server.js to include rate limiting for both methods and subscriptions in their respective protocol_handlers. Currently no default rule for subscriptions in the global DDPRateLimiter.Fixed ddp-rate-limiter tests to work as well. --- .../ddp-rate-limiter-tests.js | 22 +- packages/ddp-rate-limiter/ddp-rate-limiter.js | 15 +- packages/ddp-server/livedata_server.js | 58 ++-- packages/rate-limit/rate-limit-tests.js | 94 ++++-- packages/rate-limit/rate-limit.js | 306 +++++------------- 5 files changed, 201 insertions(+), 294 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index cc06404cb9..9fcee3232f 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -4,11 +4,12 @@ Tinytest.add( 'example', function( test ) { test.equal( true, true ); } ); +DDPRateLimiter.config = {}; DDPRateLimiter.addRule( { userId: null, IPAddr: null, method: 'login' -}, 5, 10000 ); +}, 5, 1000 ); if ( Meteor.isClient ) { testAsyncMulti( "passwords - basic login with password", [ @@ -23,7 +24,7 @@ if ( Meteor.isClient ) { email: this.email, password: this.password }, - function() {} ); + expect(function() {} )); }, function( test, expect ) { test.notEqual( Meteor.userId(), null ); @@ -36,13 +37,16 @@ if ( Meteor.isClient ) { }, function( test, expect ) { var self = this; - for ( var i = 0; i < 100; i++ ) { - - Meteor.loginWithPassword( self.username, 'fakePassword', function( - error ) { - console.log( "We threw an error.", error ); - } ); + for ( var i = 0; i < 5; i++ ) { + Meteor.loginWithPassword( self.username, 'fakePassword', expect( + function(error) { + test.equal(error.error, 403); + })); } + Meteor.loginWithPassword( self.username, 'fakePassword', expect( + function(error) { + test.equal(error.error, 'too-many-requests'); + })); } - ] ); + ]); }; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index aa45a360ef..f9787ecc49 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -14,6 +14,15 @@ DDPRateLimiter.RateLimiter.addRule( { name: 'login' }, 5, 10000 ); +// DDPRateLimiter.RateLimiter.addRule( { +// userId: null, +// IPAddr: function (IPAddr) { +// return true; +// }, +// type: 'sub', +// name: null +// }, 5, 10000); + DDPRateLimiter.getErrorMessage = function( rateLimitResult ) { return "Error, too many requests. Please slow down. You must wait " + Math.ceil( rateLimitResult.timeToReset / 1000 ) + " seconds before trying again."; @@ -25,8 +34,4 @@ DDPRateLimiter.config = function( rules ) { DDPRateLimiter.addRule = function( rule, numRequests, intervalTime ) { DDPRateLimiter.RateLimiter.addRule( rule, numRequests, intervalTime ); -}; - -DDPRateLimiter.setErrorMessage = function( message ) { - DDPRateLimiter.ErrorMessage = message; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index b242c05d4b..698d65f2f6 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -492,7 +492,6 @@ _.extend(Session.prototype, { var self = this; if (!self.inQueue) // we have been destroyed. return; - // console.log(msg_in); // Respond to ping and pong messages immediately without queuing. // If the negotiated DDP version is "pre1" which didn't support // pings, preserve the "pre1" behavior of responding with a "bad @@ -544,25 +543,6 @@ _.extend(Session.prototype, { }; if (_.has(self.protocol_handlers, msg.msg)) { - // Is it bad to do these checks here? Instead of inside session.protocol_handlers and then in method - var rateLimiterInput = { - userId: self.userId, - IPAddr: self.connectionHandle.clientAddress, - type: msg.msg, - name: null - }; - // Janky solution because in method, methodName is in msg.method otherwise in msg.name for subscriptions - if (msg.msg === 'method') { - rateLimiterInput.name = msg.method; - } else if (msg.msg === 'sub') { - rateLimiterInput.name = msg.name; - } - DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) - if (!rateLimitResult.valid) { - self.sendError('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)); - } - else self.protocol_handlers[msg.msg].call(self, msg, unblock); } else @@ -599,9 +579,23 @@ _.extend(Session.prototype, { // reconnect. return; + var rateLimiterInput = { + userId: self.userId, + IPAddr: self.connectionHandle.clientAddress, + type: msg.msg, + name: msg.name + }; + + DDPRateLimiter.RateLimiter.increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) + if (!rateLimitResult.valid) { + self.send({ + msg: 'nosub', id: msg.id, + error: new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)) + }); + } var handler = self.server.publish_handlers[msg.name]; - // console.log(msg); self._startSubscription(handler, msg.id, msg.params, msg.name); }, @@ -665,17 +659,19 @@ _.extend(Session.prototype, { connection: self.connectionHandle, randomSeed: randomSeed }); - // invocation.method = msg.method; - // var rateLimiterInput = { - // userId: self.userId, - // IPAddr: self.connectionHandle.clientAddress, - // method: msg.method}; + try { - // DDPRateLimiter.RateLimiter.newIncrement(rateLimiterInput); - // var rateLimitResult = DDPRateLimiter.RateLimiter.newCheck(rateLimiterInput) - // if (!rateLimitResult.valid) { - // throw new Meteor.Error(429, DDPRateLimiter.getErrorMessage(rateLimitResult)); - // } + var rateLimiterInput = { + userId: self.userId, + IPAddr: self.connectionHandle.clientAddress, + type: msg.msg, + name: msg.method + }; + DDPRateLimiter.RateLimiter.increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) + if (!rateLimitResult.valid) { + throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); + } var result = DDPServer._CurrentWriteFence.withValue(fence, function () { return DDP._CurrentInvocation.withValue(invocation, function () { diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index d07b7bcfe0..304a72a4c5 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -7,7 +7,7 @@ Tinytest.add( 'example', function( test ) { Tinytest.add( 'Check empty constructor creation', function( test ) { r = new RateLimiter(); test.equal( r.rules, [] ); - test.equal( r.ruleId, 0 ); + test.equal( r._ruleId, 0 ); test.equal( r.ruleCounters, {} ); } ); @@ -105,7 +105,7 @@ Tinytest.add( 'Check two rules that affect same methodInvc still throw', // After for loop runs, we only have 10 runs, so that's under the limit test.equal( r.check( methodInvc1 ).valid, true ); // However, this triggers userId rule since this userId is even - test.equal(r.check(methodInvc2).valid, false); + test.equal( r.check( methodInvc2 ).valid, false ); test.equal( r.check( methodInvc2 ).valid, false ); // Running one more test causes it to be false, since we're at 11 now. @@ -170,7 +170,57 @@ Tinytest.add( "add global rule", function( test ) { test.equal( r.check( methodInvc3 ).valid, false ); } ); -Tinytest.add("test matchRule method", function (test) { +Tinytest.add( 'add fuzzy rule match doesnt trigger', function( test ) { + r = new RateLimiter(); + var rule = { + a: function( inp ) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + } + r.addRule( rule, 1, 10000 ); + var input = { + a: 3, + b: 5 + } + for ( var i = 0; i < 5; i++ ) { + r.increment( input ); + } + test.equal( r.check( input ).valid, true ); + var matchingInput = { + a: 3, + b: 5, + c: "hi", + d: 1 + } + r.increment( matchingInput ); + r.increment( matchingInput ); + // Past limit so should be false + test.equal( r.check( matchingInput ).valid, false ); + + + // Add secondary rule and check that longer time is returned when multiple rules limits are hit + var newRule = { + a: function( inp ) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + d: 1 + } + r.addRule( newRule, 1, 10 ); + // First rule should still throw while second rule will trigger as well, causing us to return + // longer time to reset to user + r.increment( matchingInput ); + r.increment( matchingInput ); + test.equal( r.check( matchingInput ).timeToReset > 50, true ); +} ); + + +/****** Test Helper Methods *****/ + +Tinytest.add( "test matchRule method", function( test ) { r = new RateLimiter(); var globalRule = { userId: null, @@ -186,7 +236,7 @@ Tinytest.add("test matchRule method", function (test) { name: 'getSubLists' }; - test.equal(r._matchRule(globalRule, RateLimiterInput), true); + test.equal( r._matchRule( globalRule, RateLimiterInput ), true ); var oneNotNullRule = { userId: 102, @@ -195,17 +245,20 @@ Tinytest.add("test matchRule method", function (test) { name: null } - test.equal(r._matchRule(oneNotNullRule, RateLimiterInput), false); + test.equal( r._matchRule( oneNotNullRule, RateLimiterInput ), false ); oneNotNullRule.userId = 1023; - test.equal(r._matchRule(oneNotNullRule, RateLimiterInput), true); + test.equal( r._matchRule( oneNotNullRule, RateLimiterInput ), true ); - var notCompleteInput = { userId: 102, IPAddr: '127.0.0.1'}; - test.equal(r._matchRule(globalRule, notCompleteInput), true); - test.equal(r._matchRule(oneNotNullRule, notCompleteInput), false); -}); + var notCompleteInput = { + userId: 102, + IPAddr: '127.0.0.1' + }; + test.equal( r._matchRule( globalRule, notCompleteInput ), true ); + test.equal( r._matchRule( oneNotNullRule, notCompleteInput ), false ); +} ); -Tinytest.add('test generateMethodKey string', function(test) { +Tinytest.add( 'test generateMethodKey string', function( test ) { r = new RateLimiter(); var globalRule = { userId: null, @@ -221,25 +274,30 @@ Tinytest.add('test generateMethodKey string', function(test) { name: 'getSubLists' }; - test.equal(r._generateKeyString(globalRule, RateLimiterInput), ""); + test.equal( r._generateKeyString( globalRule, RateLimiterInput ), "" ); globalRule.userId = 1023; - test.equal(r._generateKeyString(globalRule, RateLimiterInput), "userId1023"); + test.equal( r._generateKeyString( globalRule, RateLimiterInput ), + "userId1023" ); var ruleWithFuncs = { - userId: function(input) { return input % 2 === 0}, + userId: function( input ) { + return input % 2 === 0 + }, IPAddr: null, type: null }; - test.equal(r._generateKeyString(ruleWithFuncs, RateLimiterInput), ""); + test.equal( r._generateKeyString( ruleWithFuncs, RateLimiterInput ), "" ); RateLimiterInput.userId = 1024; - test.equal(r._generateKeyString(ruleWithFuncs, RateLimiterInput), "userId1024"); + test.equal( r._generateKeyString( ruleWithFuncs, RateLimiterInput ), + "userId1024" ); var multipleRules = ruleWithFuncs; multipleRules.IPAddr = '127.0.0.1'; - test.equal(r._generateKeyString(multipleRules, RateLimiterInput), "userId1024IPAddr127.0.0.1") -}) + test.equal( r._generateKeyString( multipleRules, RateLimiterInput ), + "userId1024IPAddr127.0.0.1" ) +} ) function createTempConnectionHandle( id, clientIP ) { return { diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index b991433fbb..0f58281d10 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -2,338 +2,182 @@ var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of requets allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; -// Mapping from rate limiting rules format to method invocation fields. -var RATE_LIMITING_DICT = { - userId: 'userId', - IPAddr: 'connection.clientAddress', - method: 'method' -} +var RULE_PRIVATE_FIELDS = [ '_ruleId', '_lastResetTime', '_numRequestsAllowed', + '_intervalTime']; // Initialize rules, ruleId, and invocations to be empty RateLimiter = function() { var self = this; self.rules = []; - self.ruleId = 0; + self._ruleId = 0; self.ruleCounters = {}; } /** - * Checks if this method invocation is valid - * @param {object} methodInvocation DDPCommon.MethodInvocation object with - * added 'method' attribute listing the method name + * Checks if this input is valid + * @param {object} input dictionary containing key-value pairs of attributes that match to rules * @return {object} Returns object of whether method invocation is valid, time * to next reset and number invocations left */ -/*RateLimiter.prototype.check = function( methodInvocation ) { +RateLimiter.prototype.check = function( input ) { var self = this; var reply = { valid: true, timeToReset: 0, numInvocationsLeft: Infinity }; - // Figure out all the rules this method invocation matches + _.each( self.rules, function( rule ) { - // Check if this rule should be applied for this method invocation - if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { - var matchRuleHelper = self._matchRuleHelper( rule, methodInvocation ); - - var numInvocations = self.ruleCounters[ rule.ruleId ] - [ matchRuleHelper.methodString ]; - - if ( numInvocations > rule.numRequestsAllowed && - matchRuleHelper.timeSinceLastReset < rule.intervalTime ) { + if ( self._matchRule( rule, input ) ) { + var matchRuleHelper = self._ruleHelper( rule, input ); + var numInvocations = self.ruleCounters[ rule._ruleId ][ + matchRuleHelper.methodString]; + if ( numInvocations > rule._numRequestsAllowed && + matchRuleHelper.timeSinceLastReset < rule._intervalTime ) { if ( reply.timeToReset < matchRuleHelper.timeToNextReset ) { reply.timeToReset = matchRuleHelper.timeToNextReset; }; reply.valid = false; reply.numInvocationsLeft = 0; } else { - if ( rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && + if ( rule._numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.valid ) { - reply = { - valid: true, - timeToReset: matchRuleHelper.timeToNextReset < 0 ? - rule.intervalTime : matchRuleHelper.timeToNextReset, - numInvocationsLeft: rule.numRequestsAllowed - - numInvocations - }; + reply.valid = true; + reply.timeToReset = matchRuleHelper.timeToNextReset < 0 ? rule._intervalTime : + matchRuleHelper.timeToNextReset; + reply.numInvocationsLeft = rule._numRequestsAllowed - + numInvocations; } } } } ); - - return reply; -} */ - -RateLimiter.prototype.newCheck = function ( input ) { - var self = this; - var reply = { - valid: true, - timeToReset: 0, - numInvocationsLeft: Infinity - }; - - _.each( self.rules, function( rule ) { - if ( self._matchRule( rule, input )) { - var matchRuleHelper = self._newRuleHelper( rule, input ); - var numInvocations = self.ruleCounters[ rule.ruleId ][matchRuleHelper.methodString]; - if (numInvocations > rule.numRequestsAllowed && matchRuleHelper.timeSinceLastReset < rule.intervalTime) { - if ( reply.timeToReset < matchRuleHelper.timeToNextReset ) { - reply.timeToReset = matchRuleHelper.timeToNextReset; - }; - reply.valid = false; - reply.numInvocationsLeft = 0; - } else { - if (rule.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.valid) { - reply.valid = true; - reply.timeToReset = matchRuleHelper.timeToNextReset < 0 ? rule.intervalTime : matchRuleHelper.timeToNextReset; - reply.numInvocationsLeft = rule.numRequestsAllowed - numInvocations; - } - } - } - }); return reply; } /** * Appends a rule to list of rules that are checked against on every method invocation - * @param {object} rule Specified domain for rate limit rule - * { userId: ID | function() | null, - * IPAddr: ID | function() | null, - * method: name | function() | null - * All functions must return T/F to input to determine rule match + * @param {object} rule Input dictionary defining certain attributes and rules associated with them. + * Each attribute's value can either be a value, a function or null. All functions must return a boolean + * response saying whether the input is allowed by that attribute's rule or not * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset */ RateLimiter.prototype.addRule = function( rule, numRequestsAllowed, intervalTime ) { - rule.ruleId = this._createNewRuleId(); - rule.numRequestsAllowed = numRequestsAllowed || + rule._ruleId = this._createNewRuleId(); + rule._numRequestsAllowed = numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL; - rule.intervalTime = intervalTime || - DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; + rule._intervalTime = intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; rule._lastResetTime = new Date().getTime(); this.rules.push( rule ); } /** - * Matches whether a given method invocation matches a certain rule. Short + * Matches whether a given input matches a certain rule. Short * circuits search if rule and method invocation don't match - * @param {object} rule Rule as defined as an identifierQuery above - * @param {object} methodInvocation DDPCommon.MethodInvocation object with - * added 'method' attribute listing the method name - * @return {boolean} Returns whether the methodInvocation matches inputted rule + * @param {object} rule Input rule as defined above + * @param {object} input Custom input object to match against rules + * @return {boolean} Returns whether the boolean matches inputted rule */ -/*RateLimiter.prototype.matchRuleUsingFind = function( rule, methodInvocation ) { +RateLimiter.prototype._matchRule = function( rule, input ) { var self = this; var ruleMatches = true; - _.find( RATE_LIMITING_DICT, function( value, key ) { - if ( rule[ key ] ) { - var methodInvocationValue = self._ruleMappingtoMethodInvocationDict( - key, methodInvocation ); - if ( typeof rule[ key ] === 'function' ) { - if ( !rule[ key ]( methodInvocationValue ) ) { - ruleMatches = false; - return true; - } - } else { - if ( rule[ key ] !== methodInvocationValue ) { - ruleMatches = false; - return true; - } - } - } - } ); - - return ruleMatches; -}*/ - -RateLimiter.prototype._matchRule = function ( rule, input ) { - var self = this; - var ruleMatches = true; - _.find( rule, function ( value, key) { - if (value !== null && key != 'ruleId' && key != '_lastResetTime' && key != 'numRequestsAllowed' && key != 'intervalTime') { - if (!(key in input)) { + _.find( rule, function( value, key ) { + if ( value !== null && !_.contains( RULE_PRIVATE_FIELDS, key ) ) { + if ( !( key in input ) ) { ruleMatches = false; return true; } else { - if (typeof value === 'function') { - if (!(value(input[key]))) { + if ( typeof value === 'function' ) { + if ( !( value( input[ key ] ) ) ) { ruleMatches = false; return true; } } else { - if (value !== input[key]) { + if ( value !== input[ key ] ) { ruleMatches = false; return true; } } } } - }); + } ); return ruleMatches; } /** - * Increment appropriate counters on every method invocation - * @param {object} methodInvocation DDPCommon.MethodInvocation object with - * added 'method' attribute listing the method name + * Increment appropriate rule counters on every input + * @param {object} input Dictionary object containing attributes that may match to + * certain rules */ -/*RateLimiter.prototype.increment = function( methodInvocation ) { +RateLimiter.prototype.increment = function( input ) { var self = this; - // Figure out all the rules this method invocation matches - _.each( this.rules, function( rule ) { - // Check if this rule should be applied for this method invocation - if ( RateLimiter.prototype.matchRuleUsingFind( rule, methodInvocation ) ) { - var matchRuleHelper = self._matchRuleHelper( rule, methodInvocation ) - if ( matchRuleHelper.timeSinceLastReset > rule.intervalTime ) { + // Only increment rule counters that match this input + _.each( self.rules, function( rule ) { + if ( self._matchRule( rule, input ) ) { + var matchRuleHelper = self._ruleHelper( rule, input ); + + if ( matchRuleHelper.timeSinceLastReset > rule._intervalTime ) { + // Reset all the counters since the rule has reset rule._lastResetTime = new Date().getTime(); - // Reset all the counters for this rule - _.each( self.ruleCounters[ rule.ruleId ], function( - value, methodString ) { - self.ruleCounters[ rule.ruleId ][ methodString ] = - 0; + _.each( self.ruleCounters[ rule._ruleId ], function( value, + keyString ) { + self.ruleCounters[ rule._ruleId ][ keyString ] = 0; } ); } - if ( rule.ruleId in self.ruleCounters ) { - if ( matchRuleHelper.methodString in self.ruleCounters[ - rule.ruleId ] ) { - self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ]++; + + if ( rule._ruleId in self.ruleCounters ) { + if ( matchRuleHelper.methodString in self.ruleCounters[ rule._ruleId ] ) { + self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ]++; } else { - self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ] = 1; } } else { - self.ruleCounters[ rule.ruleId ] = {}; - self.ruleCounters[ rule.ruleId ][ matchRuleHelper.methodString ] = + self.ruleCounters[ rule._ruleId ] = {}; + self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ] = 1; } } } ); -}*/ - -RateLimiter.prototype.newIncrement = function ( input ) { - var self = this; - - // Only increment rule counters that match this input - _.each ( self.rules, function ( rule ) { - if (self._matchRule( rule , input ) ) { - var matchRuleHelper = self._newRuleHelper( rule, input ); - - if ( matchRuleHelper.timeSinceLastReset > rule.intervalTime) { - // Reset all the counters since the rule has reset - rule._lastResetTime = new Date().getTime(); - _.each( self.ruleCounters [ rule.ruleId ], function (value, keyString) { - self.ruleCounters[ rule.ruleId][keyString ] = 0; - }); - } - - if ( rule.ruleId in self.ruleCounters ) { - if ( matchRuleHelper.methodString in self.ruleCounters[rule.ruleId] ) { - self.ruleCounters[ rule.ruleId ] [ matchRuleHelper.methodString ]++; - } else { - self.ruleCounters[ rule.ruleId ] [ matchRuleHelper.methodString ] = 1; - } - } else { - self.ruleCounters [ rule.ruleId ] = {}; - self.ruleCounters [ rule.ruleId ] [matchRuleHelper.methodString ] = 1; - } - } - }); } // Creates new unique rule id RateLimiter.prototype._createNewRuleId = function() { - return this.ruleId++; + return this._ruleId++; } /** - * Generates string of fields that match between method invocation and rule to - * be used as a key for counters dictionary per rule - * @param {object} rule Rule defined as identifierQuery in addRule - * @param {object} methodInvocation DDPCommon.MethodInvocation object with - * added 'method' attribute listing the method name + * Generates unique key string per rule for input for key to specific rule counter dictionary + * @param {object} rule Rule defined as input to #addRule + * @param {object} input Dictionary of attributes that match to the given rule * @return {string} Key string made of all fields from rule that match in - * method invocation + * input */ -/*RateLimiter.prototype._generateMethodInvocationKeyStringFromRuleMapping = - function( rule, methodInvocation ) { - var self = this; - var returnString = ""; - _.each( RATE_LIMITING_DICT, function( value, key ) { - if ( rule[ key ] ) { - var methodValue = self._ruleMappingtoMethodInvocationDict( key, - methodInvocation ); - if ( typeof rule[ key ] === 'function' ) { - if ( rule[ key ]( methodValue ) ) - returnString += key + methodValue; - } else { - returnString += key + methodValue; - } - } - } ); - return returnString; - }*/ - -RateLimiter.prototype._generateKeyString = function (rule, input) { +RateLimiter.prototype._generateKeyString = function( rule, input ) { var self = this; var returnString = ""; - _.each( rule, function ( value, key) { - if (value !== null) { - if (typeof value === 'function') { - if (value(input[key])){ - returnString += key + input[key]; + _.each( rule, function( value, key ) { + if ( value !== null && !_.contains( RULE_PRIVATE_FIELDS, key ) ) { + if ( typeof value === 'function' ) { + if ( value( input[ key ] ) ) { + returnString += key + input[ key ]; } - } - else{ - returnString += key + input[key]; + } else { + returnString += key + input[ key ]; } } - }); + } ); return returnString; } -/** - * Helper method that uses the RATE_LIMITING_DICT to create a fast way to - * access values in methodInvocation without manually parsing the paths - * @param {string} key Key in rule dictionary (ie userId, IPAddr, method) - * @param {string} methodInvocation MethodInvocation object that is traversed - * to get the final value - * @return {object} Returns a string, value, or object of whatever is stored in - * appropriate field in MethodInvocation - */ -/*RateLimiter.prototype._ruleMappingtoMethodInvocationDict = function( key, - methodInvocation ) { - - var arr = RATE_LIMITING_DICT[ key ].split( '.' ); - while ( firstGuy = arr.shift() ) { - if ( firstGuy in methodInvocation ) - methodInvocation = methodInvocation[ firstGuy ]; - } - return methodInvocation; -}; */ -/* -RateLimiter.prototype._matchRuleHelper = function( rule, methodInvocation ) { +RateLimiter.prototype._ruleHelper = function( rule, input ) { var self = this; - - var methodString = self._generateMethodInvocationKeyStringFromRuleMapping( - rule, methodInvocation ); + var keyString = self._generateKeyString( rule, input ); var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - var timeToNextReset = rule.intervalTime - timeSinceLastReset; - return { - methodString: methodString, - timeSinceLastReset: timeSinceLastReset, - timeToNextReset: timeToNextReset - }; -} */ - -RateLimiter.prototype._newRuleHelper = function (rule, input) { - var self = this; - var keyString = self._generateKeyString(rule, input); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - var timeToNextReset = rule.intervalTime - timeSinceLastReset; + var timeToNextReset = rule._intervalTime - timeSinceLastReset; return { methodString: keyString, timeSinceLastReset: timeSinceLastReset, From 7a298ef0d6ee4dc5cc86251c5111ba782523ea8e Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 24 Jun 2015 13:32:48 -0700 Subject: [PATCH 10/34] Fixed changes from code review and created new Rule class. Refactored rate-limit package to have a new rule class that organizes the rule attributes appropriately. Moved all the Rule specific methods from RateLimiter to the Rule prototype. Reformatted code to match Meteor code style. --- .../ddp-rate-limiter-tests.js | 54 ++-- packages/ddp-rate-limiter/ddp-rate-limiter.js | 24 +- packages/ddp-server/livedata_server.js | 21 +- packages/rate-limit/package.js | 3 +- packages/rate-limit/rate-limit-tests.js | 290 +++++++++-------- packages/rate-limit/rate-limit.js | 306 ++++++++++-------- 6 files changed, 373 insertions(+), 325 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 9fcee3232f..a0551e2b2f 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,50 +1,46 @@ -// Write your tests here! -// Here is an example. -Tinytest.add( 'example', function( test ) { - test.equal( true, true ); -} ); - -DDPRateLimiter.config = {}; -DDPRateLimiter.addRule( { +DDPRateLimiter.config([]); +DDPRateLimiter.addRule({ userId: null, IPAddr: null, - method: 'login' -}, 5, 1000 ); + type: 'method', + name: 'login' +}, 5, 1000); -if ( Meteor.isClient ) { - testAsyncMulti( "passwords - basic login with password", [ - function( test, expect ) { +if (Meteor.isClient) { + testAsyncMulti("passwords - basic login with password", [ + function (test, expect) { // setup this.username = Random.id(); this.email = Random.id() + '-intercept@example.com'; this.password = 'password'; - Accounts.createUser( { + Accounts.createUser({ username: this.username, email: this.email, password: this.password }, - expect(function() {} )); + expect(function () {})); }, - function( test, expect ) { - test.notEqual( Meteor.userId(), null ); + function (test, expect) { + test.notEqual(Meteor.userId(), null); }, - function( test, expect ) { - Meteor.logout( expect( function( error ) { - test.equal( error, undefined ); - test.equal( Meteor.user(), null ); - } ) ); + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); }, - function( test, expect ) { + function (test, expect) { var self = this; - for ( var i = 0; i < 5; i++ ) { - Meteor.loginWithPassword( self.username, 'fakePassword', expect( - function(error) { + for (var i = 0; i < 5; i++) { + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + // Get 5 'User not found' 403 messages before rate limit is hit test.equal(error.error, 403); - })); + })); } - Meteor.loginWithPassword( self.username, 'fakePassword', expect( - function(error) { + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { test.equal(error.error, 'too-many-requests'); })); } diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index f9787ecc49..6ee05fff68 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,37 +1,37 @@ // Rate Limiter built into DDP DDPRateLimiter = {} -DDPRateLimiter.RateLimiter = new RateLimiter(); +DDPRateLimiter.rateLimiter = new RateLimiter(); // Add a default rule of limiting logins to 5 times per 10 seconds by IP address. // Override using DDPRateLimiter.config -DDPRateLimiter.RateLimiter.addRule( { +DDPRateLimiter.rateLimiter.addRule({ userId: null, - IPAddr: function( IPAddr ) { + ipAddr: function (ipAddr) { return true; }, type: 'method', name: 'login' -}, 5, 10000 ); +}, 5, 10000); -// DDPRateLimiter.RateLimiter.addRule( { +// DDPRateLimiter.rateLimiter.addRule( { // userId: null, -// IPAddr: function (IPAddr) { +// ipAddr: function (ipAddr) { // return true; // }, // type: 'sub', // name: null // }, 5, 10000); -DDPRateLimiter.getErrorMessage = function( rateLimitResult ) { +DDPRateLimiter.getErrorMessage = function (rateLimitResult) { return "Error, too many requests. Please slow down. You must wait " + Math.ceil( - rateLimitResult.timeToReset / 1000 ) + " seconds before trying again."; + rateLimitResult.timeToReset / 1000) + " seconds before trying again."; } -DDPRateLimiter.config = function( rules ) { - DDPRateLimiter.RateLimiter.rules = rules; +DDPRateLimiter.config = function (rules) { + DDPRateLimiter.rateLimiter.rules = rules; }; -DDPRateLimiter.addRule = function( rule, numRequests, intervalTime ) { - DDPRateLimiter.RateLimiter.addRule( rule, numRequests, intervalTime ); +DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { + DDPRateLimiter.rateLimiter.addRule(rule, numRequests, intervalTime); }; \ No newline at end of file diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 698d65f2f6..da38869c3e 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -243,6 +243,7 @@ var Session = function (server, version, socket, options) { self._universalSubs = []; self.userId = null; + self.collectionViews = {}; // Set this to false to not send messages when collectionViews are @@ -492,6 +493,7 @@ _.extend(Session.prototype, { var self = this; if (!self.inQueue) // we have been destroyed. return; + // Respond to ping and pong messages immediately without queuing. // If the negotiated DDP version is "pre1" which didn't support // pings, preserve the "pre1" behavior of responding with a "bad @@ -542,9 +544,8 @@ _.extend(Session.prototype, { processNext(); }; - if (_.has(self.protocol_handlers, msg.msg)) { - self.protocol_handlers[msg.msg].call(self, msg, unblock); - } + if (_.has(self.protocol_handlers, msg.msg)) + self.protocol_handlers[msg.msg].call(self, msg, unblock); else self.sendError('Bad request', msg); unblock(); // in case the handler didn't already do it @@ -581,13 +582,13 @@ _.extend(Session.prototype, { var rateLimiterInput = { userId: self.userId, - IPAddr: self.connectionHandle.clientAddress, + ipAddr: self.connectionHandle.clientAddress, type: msg.msg, name: msg.name }; - DDPRateLimiter.RateLimiter.increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) + DDPRateLimiter.rateLimiter.increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.rateLimiter.check(rateLimiterInput) if (!rateLimitResult.valid) { self.send({ msg: 'nosub', id: msg.id, @@ -663,12 +664,12 @@ _.extend(Session.prototype, { try { var rateLimiterInput = { userId: self.userId, - IPAddr: self.connectionHandle.clientAddress, + ipAddr: self.connectionHandle.clientAddress, type: msg.msg, name: msg.method }; - DDPRateLimiter.RateLimiter.increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.RateLimiter.check(rateLimiterInput) + DDPRateLimiter.rateLimiter.increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter.rateLimiter.check(rateLimiterInput) if (!rateLimitResult.valid) { throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); } @@ -788,6 +789,7 @@ _.extend(Session.prototype, { _startSubscription: function (handler, subId, params, name) { var self = this; + var sub = new Subscription( self, handler, subId, params, name); if (subId) @@ -1556,7 +1558,6 @@ _.extend(Server.prototype, { connection: connection, randomSeed: DDPCommon.makeRpcSeed(currentInvocation, name) }); - try { var result = DDP._CurrentInvocation.withValue(invocation, function () { return maybeAuditArgumentChecks( diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js index 86cfa859f7..2e11608700 100644 --- a/packages/rate-limit/package.js +++ b/packages/rate-limit/package.js @@ -11,13 +11,14 @@ Package.describe({ }); Package.onUse(function(api) { -// api.versionsFrom('1.1.0.2'); + api.use('underscore'); api.addFiles('rate-limit.js'); api.export("RateLimiter"); }); Package.onTest(function(api) { api.use('test-helpers', ['client', 'server']); + api.use('underscore'); api.use('ddp-rate-limiter'); api.use('tinytest'); api.use('rate-limit'); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 304a72a4c5..e04c5d4e4b 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -1,19 +1,13 @@ -// Write your tests here! -// Here is an example. -Tinytest.add( 'example', function( test ) { - test.equal( true, true ); -} ); - -Tinytest.add( 'Check empty constructor creation', function( test ) { +Tinytest.add('Check empty constructor creation', function (test) { r = new RateLimiter(); - test.equal( r.rules, [] ); - test.equal( r._ruleId, 0 ); - test.equal( r.ruleCounters, {} ); -} ); + test.equal(r.rules, []); + test.equal(r._ruleId, 0); + test.equal(r.ruleCounters, {}); +}); Tinytest.add( 'Check single rule with multiple invocations, only 1 that matches', - function( test ) { + function (test) { r = new RateLimiter(); var myUserId = 1; var rule1 = { @@ -22,22 +16,22 @@ Tinytest.add( method: null }; - r.addRule( rule1, 1, 1000 ); - var connectionHandle = createTempConnectionHandle( 123, '127.0.0.1' ); - var methodInvc1 = createTempMethodInvocation( myUserId, connectionHandle, - 'login' ); - var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, - 'login' ); - for ( var i = 0; i < 2; i++ ) { - r.increment( methodInvc1 ); - r.increment( methodInvc2 ); + r.addRule(rule1, 1, 1000); + var connectionHandle = createTempConnectionHandle(123, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(myUserId, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); + for (var i = 0; i < 2; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); } - test.equal( r.check( methodInvc1 ).valid, false ); - test.equal( r.check( methodInvc2 ).valid, true ); - } ); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, true); + }); -testAsyncMulti( "Run multiple invocations and wait for one to return", [ - function( test, expect ) { +testAsyncMulti("Run multiple invocations and wait for one to return", [ + function (test, expect) { var self = this; self.r = new RateLimiter(); self.myUserId = 1; @@ -46,33 +40,33 @@ testAsyncMulti( "Run multiple invocations and wait for one to return", [ IPAddr: null, method: null }; - self.r.addRule( self.rule1, 1, 1000 ); - self.connectionHandle = createTempConnectionHandle( 123, '127.0.0.1' ) - self.methodInvc1 = createTempMethodInvocation( self.myUserId, self.connectionHandle, - 'login' ); - self.methodInvc2 = createTempMethodInvocation( 2, self.connectionHandle, - 'login' ); - for ( var i = 0; i < 2; i++ ) { - self.r.increment( self.methodInvc1 ); - self.r.increment( self.methodInvc2 ); + self.r.addRule(self.rule1, 1, 1000); + self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') + self.methodInvc1 = createTempMethodInvocation(self.myUserId, self.connectionHandle, + 'login'); + self.methodInvc2 = createTempMethodInvocation(2, self.connectionHandle, + 'login'); + for (var i = 0; i < 2; i++) { + self.r.increment(self.methodInvc1); + self.r.increment(self.methodInvc2); } - test.equal( self.r.check( self.methodInvc1 ).valid, false ); - test.equal( self.r.check( self.methodInvc2 ).valid, true ); - Meteor.setTimeout( expect( function() {} ), 1000 ); + test.equal(self.r.check(self.methodInvc1).valid, false); + test.equal(self.r.check(self.methodInvc2).valid, true); + Meteor.setTimeout(expect(function () {}), 1000); }, - function( test, expect ) { + function (test, expect) { var self = this; - for ( var i = 0; i < 100; i++ ) { - self.r.increment( self.methodInvc2 ); + for (var i = 0; i < 100; i++) { + self.r.increment(self.methodInvc2); } - test.equal( self.r.check( self.methodInvc1 ).valid, true ); - test.equal( self.r.check( self.methodInvc2 ).valid, true ); + test.equal(self.r.check(self.methodInvc1).valid, true); + test.equal(self.r.check(self.methodInvc2).valid, true); } -] ); +]); -Tinytest.add( 'Check two rules that affect same methodInvc still throw', - function( test ) { +Tinytest.add('Check two rules that affect same methodInvc still throw', + function (test) { r = new RateLimiter(); var loginRule = { userId: null, @@ -80,147 +74,147 @@ Tinytest.add( 'Check two rules that affect same methodInvc still throw', method: 'login' }; var userIdRule = { - userId: function( userId ) { + userId: function (userId) { return userId % 2 === 0 }, IPAddr: null, method: null }; - r.addRule( loginRule, 10, 100 ); - r.addRule( userIdRule, 4, 100 ); + r.addRule(loginRule, 10, 100); + r.addRule(userIdRule, 4, 100); - var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); - var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, - 'login' ); - var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, - 'login' ); - var methodInvc3 = createTempMethodInvocation( 3, connectionHandle, 'test' ); + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'test'); - for ( var i = 0; i < 5; i++ ) { - r.increment( methodInvc1 ); - r.increment( methodInvc2 ); - r.increment( methodInvc3 ); + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + r.increment(methodInvc3); }; // After for loop runs, we only have 10 runs, so that's under the limit - test.equal( r.check( methodInvc1 ).valid, true ); + test.equal(r.check(methodInvc1).valid, true); // However, this triggers userId rule since this userId is even - test.equal( r.check( methodInvc2 ).valid, false ); - test.equal( r.check( methodInvc2 ).valid, false ); + test.equal(r.check(methodInvc2).valid, false); + test.equal(r.check(methodInvc2).valid, false); // Running one more test causes it to be false, since we're at 11 now. - r.increment( methodInvc1 ); - test.equal( r.check( methodInvc1 ).valid, false ); - test.equal( r.check( methodInvc3 ).valid, true ); + r.increment(methodInvc1); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc3).valid, true); - } ); + }); -Tinytest.add( 'Check two rules that are affected by different invocations', - function( test ) { +Tinytest.add('Check two rules that are affected by different invocations', + function (test) { r = new RateLimiter(); var loginRule = { userId: null, IPAddr: null, method: 'login' } - r.addRule( loginRule, 10, 10000 ); + r.addRule(loginRule, 10, 10000); - var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); - var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, - 'login' ); - var methodInvc2 = createTempMethodInvocation( 2, connectionHandle, - 'login' ); + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); - for ( var i = 0; i < 5; i++ ) { - r.increment( methodInvc1 ); - r.increment( methodInvc2 ); + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); } - r.increment( methodInvc1 ); + r.increment(methodInvc1); - test.equal( r.check( methodInvc1 ).valid, false ); - test.equal( r.check( methodInvc2 ).valid, false ); - } ); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, false); + }); -Tinytest.add( "add global rule", function( test ) { +Tinytest.add("add global rule", function (test) { r = new RateLimiter(); var globalRule = { userId: null, IPAddr: null, method: null } - r.addRule( globalRule, 1, 10000 ); + r.addRule(globalRule, 1, 10000); - var connectionHandle = createTempConnectionHandle( 1234, '127.0.0.1' ); - var connectionHandle2 = createTempConnectionHandle( 1234, '127.0.0.2' ); + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var connectionHandle2 = createTempConnectionHandle(1234, '127.0.0.2'); - var methodInvc1 = createTempMethodInvocation( 1, connectionHandle, - 'login' ); - var methodInvc2 = createTempMethodInvocation( 2, connectionHandle2, - 'test' ); - var methodInvc3 = createTempMethodInvocation( 3, connectionHandle, - 'user-accounts' ); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle2, + 'test'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, + 'user-accounts'); - r.increment( methodInvc2 ); - test.equal( r.check( methodInvc1 ).valid, true ); - test.equal( r.check( methodInvc2 ).valid, true ); - test.equal( r.check( methodInvc3 ).valid, true ); - r.increment( methodInvc3 ); - test.equal( r.check( methodInvc1 ).valid, false ); - test.equal( r.check( methodInvc2 ).valid, false ); - test.equal( r.check( methodInvc3 ).valid, false ); -} ); + r.increment(methodInvc2); + test.equal(r.check(methodInvc1).valid, true); + test.equal(r.check(methodInvc2).valid, true); + test.equal(r.check(methodInvc3).valid, true); + r.increment(methodInvc3); + test.equal(r.check(methodInvc1).valid, false); + test.equal(r.check(methodInvc2).valid, false); + test.equal(r.check(methodInvc3).valid, false); +}); -Tinytest.add( 'add fuzzy rule match doesnt trigger', function( test ) { +Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { r = new RateLimiter(); var rule = { - a: function( inp ) { + a: function (inp) { return inp % 3 == 0 }, b: 5, c: "hi", } - r.addRule( rule, 1, 10000 ); + r.addRule(rule, 1, 10000); var input = { a: 3, b: 5 } - for ( var i = 0; i < 5; i++ ) { - r.increment( input ); + for (var i = 0; i < 5; i++) { + r.increment(input); } - test.equal( r.check( input ).valid, true ); + test.equal(r.check(input).valid, true); var matchingInput = { a: 3, b: 5, c: "hi", d: 1 } - r.increment( matchingInput ); - r.increment( matchingInput ); + r.increment(matchingInput); + r.increment(matchingInput); // Past limit so should be false - test.equal( r.check( matchingInput ).valid, false ); + test.equal(r.check(matchingInput).valid, false); // Add secondary rule and check that longer time is returned when multiple rules limits are hit var newRule = { - a: function( inp ) { + a: function (inp) { return inp % 3 == 0 }, b: 5, c: "hi", d: 1 } - r.addRule( newRule, 1, 10 ); + r.addRule(newRule, 1, 10); // First rule should still throw while second rule will trigger as well, causing us to return // longer time to reset to user - r.increment( matchingInput ); - r.increment( matchingInput ); - test.equal( r.check( matchingInput ).timeToReset > 50, true ); -} ); + r.increment(matchingInput); + r.increment(matchingInput); + test.equal(r.check(matchingInput).timeToReset > 50, true); +}); /****** Test Helper Methods *****/ -Tinytest.add( "test matchRule method", function( test ) { +Tinytest.add("test matchRule method", function (test) { r = new RateLimiter(); var globalRule = { userId: null, @@ -228,6 +222,7 @@ Tinytest.add( "test matchRule method", function( test ) { type: null, name: null } + r.addRule(globalRule); var RateLimiterInput = { userId: 1023, @@ -236,7 +231,7 @@ Tinytest.add( "test matchRule method", function( test ) { name: 'getSubLists' }; - test.equal( r._matchRule( globalRule, RateLimiterInput ), true ); + test.equal(r.rules[0].match(RateLimiterInput), true); var oneNotNullRule = { userId: 102, @@ -245,20 +240,21 @@ Tinytest.add( "test matchRule method", function( test ) { name: null } - test.equal( r._matchRule( oneNotNullRule, RateLimiterInput ), false ); + r.addRule(oneNotNullRule); + test.equal(r.rules[1].match(RateLimiterInput), false); oneNotNullRule.userId = 1023; - test.equal( r._matchRule( oneNotNullRule, RateLimiterInput ), true ); + test.equal(r.rules[1].match(RateLimiterInput), true); var notCompleteInput = { userId: 102, IPAddr: '127.0.0.1' }; - test.equal( r._matchRule( globalRule, notCompleteInput ), true ); - test.equal( r._matchRule( oneNotNullRule, notCompleteInput ), false ); -} ); + test.equal(r.rules[0].match(notCompleteInput), true); + test.equal(r.rules[1].match(notCompleteInput), false); +}); -Tinytest.add( 'test generateMethodKey string', function( test ) { +Tinytest.add('test generateMethodKey string', function (test) { r = new RateLimiter(); var globalRule = { userId: null, @@ -266,6 +262,7 @@ Tinytest.add( 'test generateMethodKey string', function( test ) { type: null, name: null } + r.addRule(globalRule); var RateLimiterInput = { userId: 1023, @@ -273,45 +270,46 @@ Tinytest.add( 'test generateMethodKey string', function( test ) { type: 'sub', name: 'getSubLists' }; - - test.equal( r._generateKeyString( globalRule, RateLimiterInput ), "" ); - + // test.equal(r._generateKeyString(globalRule, RateLimiterInput), ""); + test.equal(r.rules[0]._generateKeyString(RateLimiterInput), ""); globalRule.userId = 1023; - test.equal( r._generateKeyString( globalRule, RateLimiterInput ), - "userId1023" ); + + test.equal(r.rules[0]._generateKeyString(RateLimiterInput), + "userId1023"); var ruleWithFuncs = { - userId: function( input ) { + userId: function (input) { return input % 2 === 0 }, IPAddr: null, type: null }; - - test.equal( r._generateKeyString( ruleWithFuncs, RateLimiterInput ), "" ); + r.addRule(ruleWithFuncs); + test.equal(r.rules[1]._generateKeyString(RateLimiterInput), ""); RateLimiterInput.userId = 1024; - test.equal( r._generateKeyString( ruleWithFuncs, RateLimiterInput ), - "userId1024" ); + test.equal(r.rules[1]._generateKeyString(RateLimiterInput), + "userId1024"); var multipleRules = ruleWithFuncs; multipleRules.IPAddr = '127.0.0.1'; - test.equal( r._generateKeyString( multipleRules, RateLimiterInput ), - "userId1024IPAddr127.0.0.1" ) -} ) + r.addRule(multipleRules); + test.equal(r.rules[2]._generateKeyString(RateLimiterInput), + "userId1024IPAddr127.0.0.1") +}) -function createTempConnectionHandle( id, clientIP ) { +function createTempConnectionHandle(id, clientIP) { return { id: id, - close: function() { + close: function () { self.close(); }, - onClose: function( fn ) { - var cb = Meteor.bindEnvironment( fn, "connection onClose callback" ); - if ( self.inQueue ) { - self._closeCallbacks.push( cb ); + onClose: function (fn) { + var cb = Meteor.bindEnvironment(fn, "connection onClose callback"); + if (self.inQueue) { + self._closeCallbacks.push(cb); } else { // if we're already closed, call the callback. - Meteor.defer( cb ); + Meteor.defer(cb); } }, clientAddress: clientIP, @@ -319,15 +317,15 @@ function createTempConnectionHandle( id, clientIP ) { }; } -function createTempMethodInvocation( userId, connectionHandle, methodName ) { - var methodInv = new DDPCommon.MethodInvocation( { +function createTempMethodInvocation(userId, connectionHandle, methodName) { + var methodInv = new DDPCommon.MethodInvocation({ isSimulation: false, userId: userId, setUserId: null, unblock: false, connection: connectionHandle, randomSeed: 1234 - } ); + }); methodInv.method = methodName; return methodInv; } \ No newline at end of file diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 0f58281d10..ae4faf3d5e 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -2,24 +2,116 @@ var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of requets allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; -var RULE_PRIVATE_FIELDS = [ '_ruleId', '_lastResetTime', '_numRequestsAllowed', - '_intervalTime']; +var RULE_PRIVATE_FIELDS = ['_ruleId', '_lastResetTime', '_numRequestsAllowed', + '_intervalTime' +]; + +var Rule = function (options, matchers, id) { + var self = this; + + self._ruleId = id; + // Options contains the timeToReset and intervalTime + self.options = options; + + // Dictionary of keys and all values that match for each key + // The values can either be null (optional), a primitive or a function + // that returns boolean of whether the provided input's value matches for + // this key + self.matchers = matchers; + + self._lastResetTime = new Date().getTime(); +}; + +_.extend(Rule.prototype, { + // Determine if this rule applies to the given input by comparing all + // rule.matchers. If the match fails, search short circuits instead of + // iterating through all matchers. + match: function (input) { + var self = this; + var ruleMatches = true; + _.find(self.matchers, function (value, key) { + if (value !== null) { + if (!(key in input)) { + ruleMatches = false; + return true; + } else { + if (typeof value === 'function') { + if (!(value(input[key]))) { + ruleMatches = false; + return true; + } + } else { + if (value !== input[key]) { + ruleMatches = false; + return true; + } + } + } + } + }); + return ruleMatches; + }, + + // Generates unique key string for provided input + // Depends on the rule and input matching. + _generateKeyString: function (input) { + var self = this; + var returnString = ""; + _.each(self.matchers, function (value, key) { + if (value !== null) { + if (typeof value === 'function') { + if (value(input[key])) { + returnString += key + input[key]; + } + } else { + returnString += key + input[key]; + } + } + }); + return returnString; + }, + + // XXX Need a better name. Generates helper values required in increment and + // check methods of RateLimiter. + apply: function (input) { + var self = this; + var keyString = self._generateKeyString(input); + var timeSinceLastReset = new Date().getTime() - self._lastResetTime; + var timeToNextReset = self.options.intervalTime - timeSinceLastReset; + return { + key: keyString, + timeSinceLastReset: timeSinceLastReset, + timeToNextReset: timeToNextReset + }; + } +}); // Initialize rules, ruleId, and invocations to be empty -RateLimiter = function() { +RateLimiter = function () { var self = this; + + // List of all rules associated with this RateLimiter. Each rule object stores + // the rule pattern, number of requests allowed, last reset time and the rule + // reset interval in milliseconds. self.rules = []; + + // Unique id associated with each rule self._ruleId = 0; + + // Dictionary of dictionaries which stores counters for all rules and all + // inputs that match the rules. First level is keyed by ruleId, after which + // for each input that matches the given rule, a unique key is generated + // which is incremented everytime that input is provided again self.ruleCounters = {}; } /** - * Checks if this input is valid + * Checks if this input has exceeded any rate limits. * @param {object} input dictionary containing key-value pairs of attributes that match to rules - * @return {object} Returns object of whether method invocation is valid, time + * @return {object} Returns object of whether method invocation is allowed, time * to next reset and number invocations left */ -RateLimiter.prototype.check = function( input ) { +RateLimiter.prototype.check = function (input) { var self = this; var reply = { valid: true, @@ -27,30 +119,43 @@ RateLimiter.prototype.check = function( input ) { numInvocationsLeft: Infinity }; - _.each( self.rules, function( rule ) { - if ( self._matchRule( rule, input ) ) { - var matchRuleHelper = self._ruleHelper( rule, input ); - var numInvocations = self.ruleCounters[ rule._ruleId ][ - matchRuleHelper.methodString]; - if ( numInvocations > rule._numRequestsAllowed && - matchRuleHelper.timeSinceLastReset < rule._intervalTime ) { - if ( reply.timeToReset < matchRuleHelper.timeToNextReset ) { - reply.timeToReset = matchRuleHelper.timeToNextReset; - }; - reply.valid = false; - reply.numInvocationsLeft = 0; - } else { - if ( rule._numRequestsAllowed - numInvocations < reply.numInvocationsLeft && - reply.valid ) { - reply.valid = true; - reply.timeToReset = matchRuleHelper.timeToNextReset < 0 ? rule._intervalTime : - matchRuleHelper.timeToNextReset; - reply.numInvocationsLeft = rule._numRequestsAllowed - - numInvocations; - } + var matchedRules = self._findAllMatchingRules(input); + _.each(matchedRules, function (rule) { + var ruleResult = rule.apply(input); + var numInvocations = self.ruleCounters[rule._ruleId][ruleResult.key]; + + if (ruleResult.timeToNextReset < 0) { + // Reset all the counters since the rule has reset + self._resetRuleCounters(rule); + ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + ruleResult.timeToNextReset = rule.options.intervalTime; + numInvocations = 0; + } + + if (numInvocations > rule.options.numRequestsAllowed) { + // Only update timeToReset if the new time would be longer than the + // previously set time. This is to ensure that if this input triggers + // multiple rules, we return the longest period of time until they can + // successfully make another call + if (reply.timeToReset < ruleResult.timeToNextReset) { + reply.timeToReset = ruleResult.timeToNextReset; + }; + reply.valid = false; + reply.numInvocationsLeft = 0; + } else { + // If this is an allowed attempt and we haven't failed on any of the other rules that + // match, update the reply field. + if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && + reply.valid) { + reply.valid = true; + reply.timeToReset = ruleResult.timeToNextReset < 0 ? rule.options + .intervalTime : + ruleResult.timeToNextReset; + reply.numInvocationsLeft = rule.options.numRequestsAllowed - + numInvocations; } } - } ); + }); return reply; } @@ -58,130 +163,77 @@ RateLimiter.prototype.check = function( input ) { * Appends a rule to list of rules that are checked against on every method invocation * @param {object} rule Input dictionary defining certain attributes and rules associated with them. * Each attribute's value can either be a value, a function or null. All functions must return a boolean - * response saying whether the input is allowed by that attribute's rule or not + * response saying whether the input is matched by that attribute's rule or not * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset */ -RateLimiter.prototype.addRule = function( rule, numRequestsAllowed, - intervalTime ) { - rule._ruleId = this._createNewRuleId(); - rule._numRequestsAllowed = numRequestsAllowed || - DEFAULT_REQUESTS_PER_INTERVAL; - rule._intervalTime = intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS; - rule._lastResetTime = new Date().getTime(); - this.rules.push( rule ); -} - -/** - * Matches whether a given input matches a certain rule. Short - * circuits search if rule and method invocation don't match - * @param {object} rule Input rule as defined above - * @param {object} input Custom input object to match against rules - * @return {boolean} Returns whether the boolean matches inputted rule - */ -RateLimiter.prototype._matchRule = function( rule, input ) { +RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime) { var self = this; - var ruleMatches = true; - _.find( rule, function( value, key ) { - if ( value !== null && !_.contains( RULE_PRIVATE_FIELDS, key ) ) { - if ( !( key in input ) ) { - ruleMatches = false; - return true; - } else { - if ( typeof value === 'function' ) { - if ( !( value( input[ key ] ) ) ) { - ruleMatches = false; - return true; - } - } else { - if ( value !== input[ key ] ) { - ruleMatches = false; - return true; - } - } - } - } - } ); - return ruleMatches; -} + var options = { + numRequestsAllowed: numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL, + intervalTime: intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS + } + + var newRule = new Rule(options, rule, self._createNewRuleId()); + this.rules.push(newRule); +} /** * Increment appropriate rule counters on every input * @param {object} input Dictionary object containing attributes that may match to * certain rules */ -RateLimiter.prototype.increment = function( input ) { +RateLimiter.prototype.increment = function (input) { var self = this; // Only increment rule counters that match this input - _.each( self.rules, function( rule ) { - if ( self._matchRule( rule, input ) ) { - var matchRuleHelper = self._ruleHelper( rule, input ); + var matchedRules = self._findAllMatchingRules(input); + _.each(matchedRules, function (rule) { + var ruleResult = rule.apply(input); - if ( matchRuleHelper.timeSinceLastReset > rule._intervalTime ) { - // Reset all the counters since the rule has reset - rule._lastResetTime = new Date().getTime(); - _.each( self.ruleCounters[ rule._ruleId ], function( value, - keyString ) { - self.ruleCounters[ rule._ruleId ][ keyString ] = 0; - } ); - } - - if ( rule._ruleId in self.ruleCounters ) { - if ( matchRuleHelper.methodString in self.ruleCounters[ rule._ruleId ] ) { - self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ]++; - } else { - self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ] = - 1; - } - } else { - self.ruleCounters[ rule._ruleId ] = {}; - self.ruleCounters[ rule._ruleId ][ matchRuleHelper.methodString ] = - 1; - } + if (ruleResult.timeSinceLastReset > rule.options.intervalTime) { + // Reset all the counters since the rule has reset + self._resetRuleCounters(rule); } - } ); + + // Check whether the key exists, incrementing it if so or otherwise + // adding the key and setting its value to 1 + if (rule._ruleId in self.ruleCounters) { + if (ruleResult.key in self.ruleCounters[rule._ruleId]) + self.ruleCounters[rule._ruleId][ruleResult.key]++; + else + self.ruleCounters[rule._ruleId][ruleResult.key] = 1; + } else { + self.ruleCounters[rule._ruleId] = {}; + self.ruleCounters[rule._ruleId][ruleResult.key] = 1; + } + }); } // Creates new unique rule id -RateLimiter.prototype._createNewRuleId = function() { +RateLimiter.prototype._createNewRuleId = function () { return this._ruleId++; } -/** - * Generates unique key string per rule for input for key to specific rule counter dictionary - * @param {object} rule Rule defined as input to #addRule - * @param {object} input Dictionary of attributes that match to the given rule - * @return {string} Key string made of all fields from rule that match in - * input - */ -RateLimiter.prototype._generateKeyString = function( rule, input ) { +// Reset all keys for this specific rule. Called once the timeSinceLastReset +// has exceeded the intervalTime. +RateLimiter.prototype._resetRuleCounters = function (rule) { var self = this; - var returnString = ""; - _.each( rule, function( value, key ) { - if ( value !== null && !_.contains( RULE_PRIVATE_FIELDS, key ) ) { - if ( typeof value === 'function' ) { - if ( value( input[ key ] ) ) { - returnString += key + input[ key ]; - } - } else { - returnString += key + input[ key ]; - } - } - } ); - return returnString; + + _.each(self.ruleCounters[rule._ruleId], function (value, key) { + self.ruleCounters[rule._ruleId][key] = 0; + }); + rule._lastResetTime = new Date().getTime(); } -RateLimiter.prototype._ruleHelper = function( rule, input ) { +RateLimiter.prototype._findAllMatchingRules = function (input) { var self = this; - var keyString = self._generateKeyString( rule, input ); - var timeSinceLastReset = new Date().getTime() - rule._lastResetTime; - var timeToNextReset = rule._intervalTime - timeSinceLastReset; - return { - methodString: keyString, - timeSinceLastReset: timeSinceLastReset, - timeToNextReset: timeToNextReset - }; + var matchingRules = []; + _.each(self.rules, function(rule) { + if (rule.match(input)) + matchingRules.push(rule); + }); + return matchingRules; } \ No newline at end of file From c0d753c8ba2aae204ab44c3c5d549f0bfdd11fc9 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 24 Jun 2015 13:47:00 -0700 Subject: [PATCH 11/34] Removed uncessary private_field array --- packages/rate-limit/rate-limit.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index ae4faf3d5e..3798f4826e 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -2,9 +2,6 @@ var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of requets allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; -var RULE_PRIVATE_FIELDS = ['_ruleId', '_lastResetTime', '_numRequestsAllowed', - '_intervalTime' -]; var Rule = function (options, matchers, id) { var self = this; From a1db69acbc9335bcc6da09ae682dc2eef8a7c7eb Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 24 Jun 2015 14:11:56 -0700 Subject: [PATCH 12/34] Refactored errorMessage in DDPRateLimiter --- packages/ddp-rate-limiter/ddp-rate-limiter.js | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index 6ee05fff68..b7b7cbb3ed 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,19 +1,13 @@ -// Rate Limiter built into DDP -DDPRateLimiter = {} +// Rate Limiter built into DDP with a default error message. +DDPRateLimiter = { + errorMessage : function (rateLimitResult) { + return "Error, too many requests. Please slow down. You must wait " + Math.ceil( + rateLimitResult.timeToReset / 1000) + " seconds before trying again."; + } +} DDPRateLimiter.rateLimiter = new RateLimiter(); -// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. -// Override using DDPRateLimiter.config -DDPRateLimiter.rateLimiter.addRule({ - userId: null, - ipAddr: function (ipAddr) { - return true; - }, - type: 'method', - name: 'login' -}, 5, 10000); - // DDPRateLimiter.rateLimiter.addRule( { // userId: null, // ipAddr: function (ipAddr) { @@ -24,8 +18,14 @@ DDPRateLimiter.rateLimiter.addRule({ // }, 5, 10000); DDPRateLimiter.getErrorMessage = function (rateLimitResult) { - return "Error, too many requests. Please slow down. You must wait " + Math.ceil( - rateLimitResult.timeToReset / 1000) + " seconds before trying again."; + if (typeof this.errorMessage === 'function') + return this.errorMessage(rateLimitResult); + else + return this.errorMessage; +} + +DDPRateLimiter.setErrorMessage = function (message) { + this.errorMessage = message; } DDPRateLimiter.config = function (rules) { @@ -34,4 +34,16 @@ DDPRateLimiter.config = function (rules) { DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { DDPRateLimiter.rateLimiter.addRule(rule, numRequests, intervalTime); -}; \ No newline at end of file +}; + + +// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. +// Override using DDPRateLimiter.config +DDPRateLimiter.addRule({ + userId: null, + ipAddr: function (ipAddr) { + return true; + }, + type: 'method', + name: 'login' +}, 5, 10000); From fc4b69272fcf5aa70b4c9a3d8b5e18e2918ed49e Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 24 Jun 2015 17:29:54 -0700 Subject: [PATCH 13/34] Moved counters to each rule instead of a large dictionary per rate limiter --- packages/rate-limit/rate-limit-tests.js | 2 - packages/rate-limit/rate-limit.js | 69 +++++++++---------------- 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index e04c5d4e4b..60442e815e 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -1,8 +1,6 @@ Tinytest.add('Check empty constructor creation', function (test) { r = new RateLimiter(); test.equal(r.rules, []); - test.equal(r._ruleId, 0); - test.equal(r.ruleCounters, {}); }); Tinytest.add( diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 3798f4826e..9bbe3f7541 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -3,10 +3,9 @@ var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of requets allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; -var Rule = function (options, matchers, id) { +var Rule = function (options, matchers) { var self = this; - self._ruleId = id; // Options contains the timeToReset and intervalTime self.options = options; @@ -17,6 +16,9 @@ var Rule = function (options, matchers, id) { self.matchers = matchers; self._lastResetTime = new Date().getTime(); + + // Dictionary of input keys to counters + self.counters = {}; }; _.extend(Rule.prototype, { @@ -28,7 +30,7 @@ _.extend(Rule.prototype, { var ruleMatches = true; _.find(self.matchers, function (value, key) { if (value !== null) { - if (!(key in input)) { + if (!(_.has(input,key))) { ruleMatches = false; return true; } else { @@ -50,7 +52,7 @@ _.extend(Rule.prototype, { }, // Generates unique key string for provided input - // Depends on the rule and input matching. + // Only called if rule matches input. _generateKeyString: function (input) { var self = this; var returnString = ""; @@ -80,6 +82,15 @@ _.extend(Rule.prototype, { timeSinceLastReset: timeSinceLastReset, timeToNextReset: timeToNextReset }; + }, + // Reset all keys for this specific rule. Called once the timeSinceLastReset + // has exceeded the intervalTime. + resetCounter: function () { + var self = this; + _.each(self.counters, function (value, key) { + self.counters[key] = 0; + }); + self._lastResetTime = new Date().getTime(); } }); @@ -91,15 +102,6 @@ RateLimiter = function () { // the rule pattern, number of requests allowed, last reset time and the rule // reset interval in milliseconds. self.rules = []; - - // Unique id associated with each rule - self._ruleId = 0; - - // Dictionary of dictionaries which stores counters for all rules and all - // inputs that match the rules. First level is keyed by ruleId, after which - // for each input that matches the given rule, a unique key is generated - // which is incremented everytime that input is provided again - self.ruleCounters = {}; } /** @@ -119,11 +121,11 @@ RateLimiter.prototype.check = function (input) { var matchedRules = self._findAllMatchingRules(input); _.each(matchedRules, function (rule) { var ruleResult = rule.apply(input); - var numInvocations = self.ruleCounters[rule._ruleId][ruleResult.key]; + var numInvocations = rule.counters[ruleResult.key]; if (ruleResult.timeToNextReset < 0) { // Reset all the counters since the rule has reset - self._resetRuleCounters(rule); + rule.resetCounter(); ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; ruleResult.timeToNextReset = rule.options.intervalTime; numInvocations = 0; @@ -145,8 +147,8 @@ RateLimiter.prototype.check = function (input) { if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.valid) { reply.valid = true; - reply.timeToReset = ruleResult.timeToNextReset < 0 ? rule.options - .intervalTime : + reply.timeToReset = ruleResult.timeToNextReset < 0 ? + rule.options.intervalTime : ruleResult.timeToNextReset; reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; @@ -172,7 +174,7 @@ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime intervalTime: intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS } - var newRule = new Rule(options, rule, self._createNewRuleId()); + var newRule = new Rule(options, rule); this.rules.push(newRule); } @@ -191,39 +193,18 @@ RateLimiter.prototype.increment = function (input) { if (ruleResult.timeSinceLastReset > rule.options.intervalTime) { // Reset all the counters since the rule has reset - self._resetRuleCounters(rule); + rule.resetCounter(); } // Check whether the key exists, incrementing it if so or otherwise // adding the key and setting its value to 1 - if (rule._ruleId in self.ruleCounters) { - if (ruleResult.key in self.ruleCounters[rule._ruleId]) - self.ruleCounters[rule._ruleId][ruleResult.key]++; - else - self.ruleCounters[rule._ruleId][ruleResult.key] = 1; - } else { - self.ruleCounters[rule._ruleId] = {}; - self.ruleCounters[rule._ruleId][ruleResult.key] = 1; - } + if (_.has(rule.counters, ruleResult.key)) + rule.counters[ruleResult.key]++; + else + rule.counters[ruleResult.key] = 1; }); } -// Creates new unique rule id -RateLimiter.prototype._createNewRuleId = function () { - return this._ruleId++; -} - -// Reset all keys for this specific rule. Called once the timeSinceLastReset -// has exceeded the intervalTime. -RateLimiter.prototype._resetRuleCounters = function (rule) { - var self = this; - - _.each(self.ruleCounters[rule._ruleId], function (value, key) { - self.ruleCounters[rule._ruleId][key] = 0; - }); - rule._lastResetTime = new Date().getTime(); -} - RateLimiter.prototype._findAllMatchingRules = function (input) { var self = this; From d58bf19574b99e95ac0576f3eb2839c66ed9a872 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Wed, 24 Jun 2015 19:07:23 -0700 Subject: [PATCH 14/34] Changed DDPRateLimiter.config to DDPRateLimiter.setRules, which overrides all the rules stored in the RateLimiter. Added comments above Rule.apply to explain what it does. --- packages/ddp-rate-limiter/ddp-rate-limiter.js | 2 +- packages/rate-limit/rate-limit.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index b7b7cbb3ed..af647e3ec4 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -28,7 +28,7 @@ DDPRateLimiter.setErrorMessage = function (message) { this.errorMessage = message; } -DDPRateLimiter.config = function (rules) { +DDPRateLimiter.setRules = function (rules) { DDPRateLimiter.rateLimiter.rules = rules; }; diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 9bbe3f7541..df92debe75 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -70,8 +70,8 @@ _.extend(Rule.prototype, { return returnString; }, - // XXX Need a better name. Generates helper values required in increment and - // check methods of RateLimiter. + // Generates the key, timeSinceLastReset and timeToNextReset once the rule + // is applied apply: function (input) { var self = this; var keyString = self._generateKeyString(input); From b54401274edccd8b0b2fcb2c438bc43c7331369e Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Thu, 25 Jun 2015 15:38:42 -0700 Subject: [PATCH 15/34] Fixed Rate Limit and DDP Rate Limit packages. Added end to end tests which test removing rules. --- .../ddp-rate-limiter-server-tests.js | 20 +++++ .../ddp-rate-limiter-tests.js | 81 ++++++++++++++++--- packages/ddp-rate-limiter/ddp-rate-limiter.js | 34 ++------ packages/ddp-rate-limiter/package.js | 4 +- packages/rate-limit/package.js | 2 + packages/rate-limit/rate-limit-tests.js | 34 ++++---- packages/rate-limit/rate-limit.js | 22 +++-- 7 files changed, 133 insertions(+), 64 deletions(-) create mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js new file mode 100644 index 0000000000..87f0b96c1c --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js @@ -0,0 +1,20 @@ +Meteor.methods({ + resetAndAddRuleToDDPRateLimiter : function(intervalTimeInMillis) { + DDPRateLimiter.rateLimiter.rules = {}; + this.ruleId = DDPRateLimiter.addRule({ + userId: null, + ipAddr: function() {return true}, + type: 'method', + name: 'login' + }, 5, intervalTimeInMillis); + return this.ruleId; + }, + + removeRuleFromDDPRateLimiter : function(id) { + return DDPRateLimiter.removeRule(id); + }, + + printCurrentListOfRules : function () { + console.log('Current list of rules :', DDPRateLimiter.rateLimiter.rules); + } +}); \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index a0551e2b2f..27d1f7b074 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,14 +1,58 @@ -DDPRateLimiter.config([]); -DDPRateLimiter.addRule({ - userId: null, - IPAddr: null, - type: 'method', - name: 'login' -}, 5, 1000); +testAsyncMulti("passwords - basic login with password", [ + function (test, expect) { + var self = this; + // Setup the rate limiter rules + Meteor.call('resetAndAddRuleToDDPRateLimiter', 1000, expect(function(error, result) { + self.ruleId = result; + })); + // setup + this.username = Random.id(); + this.email = Random.id() + '-intercept@example.com'; + this.password = 'password'; -if (Meteor.isClient) { - testAsyncMulti("passwords - basic login with password", [ - function (test, expect) { + Accounts.createUser({ + username: this.username, + email: this.email, + password: this.password + }, + expect(function () {})); + }, + function (test, expect) { + test.notEqual(Meteor.userId(), null); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + var self = this; + for (var i = 0; i < 5; i++) { + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + // Get 5 'User not found' 403 messages before rate limit is hit + test.equal(error.error, 403); + })); + } + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + test.equal(error.error, 'too-many-requests'); + })); + // Cleanup + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { + test.equal(result,true); + })); + } +]); + +testAsyncMulti("test removing rule with rateLimited client lets them send new queries", [ + function(test, expect) { + var self = this; + // Setup the rate limiter rules + Meteor.call('resetAndAddRuleToDDPRateLimiter', 5000, expect(function(error, result) { + self.ruleId = result; + })); // setup this.username = Random.id(); this.email = Random.id() + '-intercept@example.com'; @@ -35,7 +79,8 @@ if (Meteor.isClient) { for (var i = 0; i < 5; i++) { Meteor.loginWithPassword(self.username, 'fakePassword', expect( function (error) { - // Get 5 'User not found' 403 messages before rate limit is hit + // Call printCurrentListofRules to see all the rules on the server + // Meteor.call('printCurrentListOfRules'); test.equal(error.error, 403); })); } @@ -43,6 +88,16 @@ if (Meteor.isClient) { function (error) { test.equal(error.error, 'too-many-requests'); })); - } + // By removing the rule from the DDP rate limiter, we no longer restrict them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { + test.equal(result,true); + })); + // + for (var i = 0; i < 10; i++) { + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + test.equal(error.error, 403); + })); + } + } ]); -}; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index af647e3ec4..355ff6840d 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -3,20 +3,10 @@ DDPRateLimiter = { errorMessage : function (rateLimitResult) { return "Error, too many requests. Please slow down. You must wait " + Math.ceil( rateLimitResult.timeToReset / 1000) + " seconds before trying again."; - } + }, + rateLimiter : new RateLimiter() } -DDPRateLimiter.rateLimiter = new RateLimiter(); - -// DDPRateLimiter.rateLimiter.addRule( { -// userId: null, -// ipAddr: function (ipAddr) { -// return true; -// }, -// type: 'sub', -// name: null -// }, 5, 10000); - DDPRateLimiter.getErrorMessage = function (rateLimitResult) { if (typeof this.errorMessage === 'function') return this.errorMessage(rateLimitResult); @@ -28,22 +18,10 @@ DDPRateLimiter.setErrorMessage = function (message) { this.errorMessage = message; } -DDPRateLimiter.setRules = function (rules) { - DDPRateLimiter.rateLimiter.rules = rules; -}; - DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { - DDPRateLimiter.rateLimiter.addRule(rule, numRequests, intervalTime); + return DDPRateLimiter.rateLimiter.addRule(rule, numRequests, intervalTime); }; - -// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. -// Override using DDPRateLimiter.config -DDPRateLimiter.addRule({ - userId: null, - ipAddr: function (ipAddr) { - return true; - }, - type: 'method', - name: 'login' -}, 5, 10000); +DDPRateLimiter.removeRule = function (id) { + return DDPRateLimiter.rateLimiter.removeRule(id); +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index c1971d35d2..7c2fb06678 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -18,9 +18,11 @@ Package.onUse(function(api) { }); Package.onTest(function(api) { + api.use('underscore'); api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', 'accounts-base', 'random', 'email', 'underscore', 'check', 'ddp']); api.use('ddp-rate-limiter'); - api.addFiles('ddp-rate-limiter-tests.js'); + api.addFiles('ddp-rate-limiter-server-tests.js', 'server'); + api.addFiles('ddp-rate-limiter-tests.js', 'client'); }); diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js index 2e11608700..950efed10d 100644 --- a/packages/rate-limit/package.js +++ b/packages/rate-limit/package.js @@ -12,6 +12,7 @@ Package.describe({ Package.onUse(function(api) { api.use('underscore'); + api.use('random'); api.addFiles('rate-limit.js'); api.export("RateLimiter"); }); @@ -19,6 +20,7 @@ Package.onUse(function(api) { Package.onTest(function(api) { api.use('test-helpers', ['client', 'server']); api.use('underscore'); + api.use('random'); api.use('ddp-rate-limiter'); api.use('tinytest'); api.use('rate-limit'); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 60442e815e..59b65ec1c0 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -1,6 +1,6 @@ Tinytest.add('Check empty constructor creation', function (test) { r = new RateLimiter(); - test.equal(r.rules, []); + test.equal(r.rules, {}); }); Tinytest.add( @@ -220,7 +220,7 @@ Tinytest.add("test matchRule method", function (test) { type: null, name: null } - r.addRule(globalRule); + var globalRuleId = r.addRule(globalRule); var RateLimiterInput = { userId: 1023, @@ -229,7 +229,7 @@ Tinytest.add("test matchRule method", function (test) { name: 'getSubLists' }; - test.equal(r.rules[0].match(RateLimiterInput), true); + test.equal(r.rules[globalRuleId].match(RateLimiterInput), true); var oneNotNullRule = { userId: 102, @@ -238,18 +238,18 @@ Tinytest.add("test matchRule method", function (test) { name: null } - r.addRule(oneNotNullRule); - test.equal(r.rules[1].match(RateLimiterInput), false); + var oneNotId = r.addRule(oneNotNullRule); + test.equal(r.rules[oneNotId].match(RateLimiterInput), false); oneNotNullRule.userId = 1023; - test.equal(r.rules[1].match(RateLimiterInput), true); + test.equal(r.rules[oneNotId].match(RateLimiterInput), true); var notCompleteInput = { userId: 102, IPAddr: '127.0.0.1' }; - test.equal(r.rules[0].match(notCompleteInput), true); - test.equal(r.rules[1].match(notCompleteInput), false); + test.equal(r.rules[globalRuleId].match(notCompleteInput), true); + test.equal(r.rules[oneNotId].match(notCompleteInput), false); }); Tinytest.add('test generateMethodKey string', function (test) { @@ -260,7 +260,7 @@ Tinytest.add('test generateMethodKey string', function (test) { type: null, name: null } - r.addRule(globalRule); + var globalRuleId = r.addRule(globalRule); var RateLimiterInput = { userId: 1023, @@ -268,11 +268,11 @@ Tinytest.add('test generateMethodKey string', function (test) { type: 'sub', name: 'getSubLists' }; - // test.equal(r._generateKeyString(globalRule, RateLimiterInput), ""); - test.equal(r.rules[0]._generateKeyString(RateLimiterInput), ""); + + test.equal(r.rules[globalRuleId]._generateKeyString(RateLimiterInput), ""); globalRule.userId = 1023; - test.equal(r.rules[0]._generateKeyString(RateLimiterInput), + test.equal(r.rules[globalRuleId]._generateKeyString(RateLimiterInput), "userId1023"); var ruleWithFuncs = { @@ -282,16 +282,16 @@ Tinytest.add('test generateMethodKey string', function (test) { IPAddr: null, type: null }; - r.addRule(ruleWithFuncs); - test.equal(r.rules[1]._generateKeyString(RateLimiterInput), ""); + var funcRuleId = r.addRule(ruleWithFuncs); + test.equal(r.rules[funcRuleId]._generateKeyString(RateLimiterInput), ""); RateLimiterInput.userId = 1024; - test.equal(r.rules[1]._generateKeyString(RateLimiterInput), + test.equal(r.rules[funcRuleId]._generateKeyString(RateLimiterInput), "userId1024"); var multipleRules = ruleWithFuncs; multipleRules.IPAddr = '127.0.0.1'; - r.addRule(multipleRules); - test.equal(r.rules[2]._generateKeyString(RateLimiterInput), + var multipleRuleId = r.addRule(multipleRules); + test.equal(r.rules[multipleRuleId]._generateKeyString(RateLimiterInput), "userId1024IPAddr127.0.0.1") }) diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index df92debe75..d0dcdc9477 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -6,6 +6,7 @@ var DEFAULT_REQUESTS_PER_INTERVAL = 10; var Rule = function (options, matchers) { var self = this; + self.id = Random.id(); // Options contains the timeToReset and intervalTime self.options = options; @@ -98,10 +99,10 @@ _.extend(Rule.prototype, { RateLimiter = function () { var self = this; - // List of all rules associated with this RateLimiter. Each rule object stores - // the rule pattern, number of requests allowed, last reset time and the rule - // reset interval in milliseconds. - self.rules = []; + // Dictionary of all rules associated with this RateLimiter, keyed by their + // id. Each rule object stores the rule pattern, number of requests allowed, + // last reset time and the rule reset interval in milliseconds. + self.rules = {}; } /** @@ -175,7 +176,8 @@ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime } var newRule = new Rule(options, rule); - this.rules.push(newRule); + this.rules[newRule.id] = newRule; + return newRule.id; } /** @@ -214,4 +216,14 @@ RateLimiter.prototype._findAllMatchingRules = function (input) { matchingRules.push(rule); }); return matchingRules; +} + +RateLimiter.prototype.removeRule = function (id) { + var self = this; + if (self.rules[id]) { + delete self.rules[id]; + return true; + } else { + return false; + } } \ No newline at end of file From 39ead027dd45d44fa7a7fccc5f936720465e8f86 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Thu, 25 Jun 2015 15:59:32 -0700 Subject: [PATCH 16/34] Moved default rule adding to accounts_base and added a way to remove the default login rule. Updated ddp rate limiter server tests to test removing the default rule --- packages/accounts-base/accounts_rate_limit.js | 17 +++++++++++++++++ packages/accounts-base/package.js | 2 ++ .../ddp-rate-limiter-server-tests.js | 8 +++++++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/accounts-base/accounts_rate_limit.js diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js new file mode 100644 index 0000000000..6f11f301a8 --- /dev/null +++ b/packages/accounts-base/accounts_rate_limit.js @@ -0,0 +1,17 @@ +// Adds a default rate limiting rule to DDPRateLimiter and provides methods to remove it +var Ap = AccountsCommon.prototype; +// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. +// Stores the ruleId to remove it when called +Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ + userId: null, + ipAddr: function (ipAddr) { + return true; + }, + type: 'method', + name: 'login' +}, 5, 1000); + +// Removes default rate limiting rule +Ap.removeDefaultAccountsRateLimitRule = function () { + return DDPRateLimiter.removeRule(Ap._defaultRateLimiterRuleId); +} diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 436ac64634..24a08a24c6 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -5,6 +5,7 @@ Package.describe({ Package.onUse(function (api) { api.use('underscore', ['client', 'server']); + api.use('ddp-rate-limiter'); api.use('localstorage', 'client'); api.use('tracker', 'client'); api.use('check', 'server'); @@ -40,6 +41,7 @@ Package.onUse(function (api) { api.addFiles('accounts_common.js', ['client', 'server']); api.addFiles('accounts_server.js', 'server'); + api.addFiles('accounts_rate_limit.js'); api.addFiles('url_server.js', 'server'); // accounts_client must be before localstorage_token, because diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js index 87f0b96c1c..a1e3441324 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js @@ -17,4 +17,10 @@ Meteor.methods({ printCurrentListOfRules : function () { console.log('Current list of rules :', DDPRateLimiter.rateLimiter.rules); } -}); \ No newline at end of file +}); + +Tinytest.add("Test rule gets added and removed from Accounts_base", function(test) { + test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); + Accounts.removeDefaultAccountsRateLimitRule(); + test.equal(DDPRateLimiter.rateLimiter.rules, {}); +}); From 817ed2904e6778c8e6578f07532699a081b34a22 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 29 Jun 2015 10:08:40 -0700 Subject: [PATCH 17/34] Added comments and DocBlockr api docs. Updated DDPRateLimiter's default rule to contain password resets and create new users. Changed valid to allowed in rateLimitResult replies. --- packages/accounts-base/accounts_rate_limit.js | 15 ++- .../ddp-rate-limiter-server-tests.js | 9 +- .../ddp-rate-limiter-tests.js | 103 +++++++++-------- packages/ddp-rate-limiter/ddp-rate-limiter.js | 31 ++++- packages/ddp-server/livedata_server.js | 13 ++- packages/rate-limit/rate-limit.js | 108 ++++++++++++++++-- 6 files changed, 201 insertions(+), 78 deletions(-) diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index 6f11f301a8..16770b9fa7 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -1,17 +1,20 @@ // Adds a default rate limiting rule to DDPRateLimiter and provides methods to remove it var Ap = AccountsCommon.prototype; -// Add a default rule of limiting logins to 5 times per 10 seconds by IP address. -// Stores the ruleId to remove it when called +// Add a default rule of limiting logins, creating new users and password reset +// to 5 times per 10 seconds by IP address. +// Stores the ruleId to provide option to remove the default rule. Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ userId: null, ipAddr: function (ipAddr) { return true; }, type: 'method', - name: 'login' -}, 5, 1000); + name: function(name) { + return _.has(['login', 'createUser', 'resetPassword']); + } +}, 5, 10000); // Removes default rate limiting rule -Ap.removeDefaultAccountsRateLimitRule = function () { +Ap.removeDefaultRateLimit = function () { return DDPRateLimiter.removeRule(Ap._defaultRateLimiterRuleId); -} +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js index a1e3441324..1e742325f8 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js @@ -3,7 +3,9 @@ Meteor.methods({ DDPRateLimiter.rateLimiter.rules = {}; this.ruleId = DDPRateLimiter.addRule({ userId: null, - ipAddr: function() {return true}, + ipAddr: function() { + return true; + }, type: 'method', name: 'login' }, 5, intervalTimeInMillis); @@ -20,7 +22,10 @@ Meteor.methods({ }); Tinytest.add("Test rule gets added and removed from Accounts_base", function(test) { + // Test that DDPRateLimiter rules is not empty test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); - Accounts.removeDefaultAccountsRateLimitRule(); + + Accounts.removeDefaultRateLimit(); + // Test DDPRateLimiter rules is empty after removing only rule test.equal(DDPRateLimiter.rateLimiter.rules, {}); }); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 27d1f7b074..0cee40e2f2 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -2,7 +2,8 @@ testAsyncMulti("passwords - basic login with password", [ function (test, expect) { var self = this; // Setup the rate limiter rules - Meteor.call('resetAndAddRuleToDDPRateLimiter', 1000, expect(function(error, result) { + Meteor.call('resetAndAddRuleToDDPRateLimiter', 1000, + expect(function(error, result) { self.ruleId = result; })); // setup @@ -40,64 +41,66 @@ testAsyncMulti("passwords - basic login with password", [ test.equal(error.error, 'too-many-requests'); })); // Cleanup - Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { test.equal(result,true); })); } ]); testAsyncMulti("test removing rule with rateLimited client lets them send new queries", [ - function(test, expect) { - var self = this; - // Setup the rate limiter rules - Meteor.call('resetAndAddRuleToDDPRateLimiter', 5000, expect(function(error, result) { - self.ruleId = result; - })); - // setup - this.username = Random.id(); - this.email = Random.id() + '-intercept@example.com'; - this.password = 'password'; + function(test, expect) { + var self = this; + // Setup the rate limiter rules + Meteor.call('resetAndAddRuleToDDPRateLimiter', 5000, + expect(function(error, result) { + self.ruleId = result; + })); + // setup + this.username = Random.id(); + this.email = Random.id() + '-intercept@example.com'; + this.password = 'password'; - Accounts.createUser({ - username: this.username, - email: this.email, - password: this.password - }, - expect(function () {})); - }, - function (test, expect) { - test.notEqual(Meteor.userId(), null); - }, - function (test, expect) { - Meteor.logout(expect(function (error) { - test.equal(error, undefined); - test.equal(Meteor.user(), null); - })); - }, - function (test, expect) { - var self = this; - for (var i = 0; i < 5; i++) { - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - // Call printCurrentListofRules to see all the rules on the server - // Meteor.call('printCurrentListOfRules'); - test.equal(error.error, 403); - })); - } + Accounts.createUser({ + username: this.username, + email: this.email, + password: this.password + }, + expect(function () {})); + }, + function (test, expect) { + test.notEqual(Meteor.userId(), null); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + var self = this; + for (var i = 0; i < 5; i++) { Meteor.loginWithPassword(self.username, 'fakePassword', expect( function (error) { - test.equal(error.error, 'too-many-requests'); + // Call printCurrentListofRules to see all the rules on the server + // Meteor.call('printCurrentListOfRules'); + test.equal(error.error, 403); })); - // By removing the rule from the DDP rate limiter, we no longer restrict them even though they were rate limited - Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { - test.equal(result,true); + } + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + test.equal(error.error, 'too-many-requests'); })); - // - for (var i = 0; i < 10; i++) { - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - test.equal(error.error, 403); - })); - } + // By removing the rule from the DDP rate limiter, we no longer restrict them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { + test.equal(result,true); + })); + // + for (var i = 0; i < 10; i++) { + Meteor.loginWithPassword(self.username, 'fakePassword', expect( + function (error) { + test.equal(error.error, 403); + })); + } } - ]); +]); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index 355ff6840d..bd03fa0a2b 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -13,15 +13,38 @@ DDPRateLimiter.getErrorMessage = function (rateLimitResult) { else return this.errorMessage; } - +/** + * @summary Update the error message returned when call is rate limited. + * @param {string|function} message Function that takes an object with a timeToReset field that specifies the first time a method or subscription call is allowed + */ DDPRateLimiter.setErrorMessage = function (message) { this.errorMessage = message; } -DDPRateLimiter.addRule = function (rule, numRequests, intervalTime) { - return DDPRateLimiter.rateLimiter.addRule(rule, numRequests, intervalTime); +/** + * @summary Adds a rule with a number of requests allowed per time interval. + * @param {object} rule Rule should be an object where the keys are one or more of `['userId', 'ipAddr', 'type', 'name'] ` and the values are either `null`, a primitive, or a function that returns true if the rule should apply to the provided input for that key. + * @param {integer} numRequests number of requests allowed per time interval. Default = 10. + * @param {integer} timeInterval time interval in milliseconds after which rule's counters are reset. Default = 1000. + * @return {string} Returns unique `ruleId` that can be passed to `removeRule`. + */ +DDPRateLimiter.addRule = function (rule, numRequests, timeInterval) { + return this.rateLimiter.addRule(rule, numRequests, timeInterval); }; +/** + * @summary Removes the rule with specified id. + * @param {string} id 'ruleId' returned from `addRule` + * @return {boolean} True if a rule was removed. + */ DDPRateLimiter.removeRule = function (id) { - return DDPRateLimiter.rateLimiter.removeRule(id); + return this.rateLimiter.removeRule(id); +} + +DDPRateLimiter._increment = function (input) { + this.rateLimiter.increment(input); +} + +DDPRateLimiter._check = function (input) { + return this.rateLimiter.check(input); } \ No newline at end of file diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index da38869c3e..9b25192b2e 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -587,9 +587,9 @@ _.extend(Session.prototype, { name: msg.name }; - DDPRateLimiter.rateLimiter.increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.rateLimiter.check(rateLimiterInput) - if (!rateLimitResult.valid) { + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { self.send({ msg: 'nosub', id: msg.id, error: new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)) @@ -668,9 +668,10 @@ _.extend(Session.prototype, { type: msg.msg, name: msg.method }; - DDPRateLimiter.rateLimiter.increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter.rateLimiter.check(rateLimiterInput) - if (!rateLimitResult.valid) { + DDPRateLimiter._increment(rateLimiterInput); + console.log(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); } diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index d0dcdc9477..fe102d7358 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -1,3 +1,85 @@ +// This file contains two classes: +// * Rule - the general structure of rate limiter rules +// * RateLimiter - a general rate limiter that stores rules and determines +// whether inputs are allowed +// +// ** Rules ** +// Rules are composed of the following fields: +// - id Random id generated and used to key the rule in the rate limiter +// - options Object that contains the intervalTime after which the rule is reset, and the +// number of calls that are allowed in the specified interval time +// - matchers Dictionary of keys that are searched for in the input provided to match +// with values that define the set of values that match this rule. Values can be +// objects or they can be functions that return a boolean of whether the provided +// input matches. For example, if we only want to match all even ids, plus any other fields +// , we could have a rule that included a key value pair as follows: +// { +// ... +// id: function (id) { +// return id % 2 === 0; +// }, +// ... +// } +// A rule is only said to apply to a given input if every key in the matcher +// matches to the input values. +// - _lastResetTime Last time this rule's counters were reset. Last reset time is used to +// keep track if the interval time has passed +// - counters Dictionary that stores the current state of inputs and number times they've +// been passed to the rate limiter. Unique keys are made per input per rule that +// create a concatenated string of all keys in the rule with the values from the +// input +// +// For example, if we had a rule with matchers as such: +// { +// userId: function(userId) { +// return true; +// }, +// methodName: 'hello' +// } +// and we were passed an input as follows: +// { +// userId: 'meteor' +// methodName: 'hello' +// } +// +// The key generated would be 'userIdmeteormethodNamehello'. +// +// These counters are checked on every invocation to determine whether a rate limit +// has been reached. +// +// The methods provided are as follows: +// * match(input) : Checks whether the rule applies to the provided input by comparing every +// field in the matcher to the provided input. Order of input doesn't matter, +// they just must contain the appropriate keys and the values must be allowed by +// the matcher +// * _generateKeyString(input) : generates a key string by concatenating all the keys in the matcher +// with the corresponding values in the input +// * apply(input) : applies the provided input and returns the key string, the time since last +// reset and time to next reset +// * resetCounter() : resets the counters for this rule and sets _lastResetTime to be the current time +// +// ** Rate Limiter ** +// A rate limiter stores a dictionary of rules keyed by their rule Id. It provides the following methods: +// +// * addRule(rule, numRequests, intervalTime) : adds a Rule to dictionary of rules and returns the user +// the rule Id +// * removeRule(id) : removes a rule from the dictionary and returns boolean detailing success +// * check(input) : checks all rules that apply to this input to see if any rate limits have been exceeded +// Returns an object as follows: +// { +// allowed: boolean - is this input allowed +// timeToReset: integer - returns time to reset in milliseconds +// numInvocationsLeft: integer - returns number of calls left before limit is reached +// } +// If multiple rules match, the least number of invocations left is returned. If the rate limit +// has been reached, the longest timeToReset is returned. +// +// * increment(input) : increments counters in all rules that apply to this input. +// * _findAllMatchingRules(input) : returns an array of all rules that apply to provided input +// + + + // Default time interval (in milliseconds) to reset rate limit counters var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of requets allowed per time interval @@ -108,13 +190,13 @@ RateLimiter = function () { /** * Checks if this input has exceeded any rate limits. * @param {object} input dictionary containing key-value pairs of attributes that match to rules - * @return {object} Returns object of whether method invocation is allowed, time + * @return {object} Returns object of whether input is allowed, time * to next reset and number invocations left */ RateLimiter.prototype.check = function (input) { var self = this; var reply = { - valid: true, + allowed: true, timeToReset: 0, numInvocationsLeft: Infinity }; @@ -140,14 +222,14 @@ RateLimiter.prototype.check = function (input) { if (reply.timeToReset < ruleResult.timeToNextReset) { reply.timeToReset = ruleResult.timeToNextReset; }; - reply.valid = false; + reply.allowed = false; reply.numInvocationsLeft = 0; } else { // If this is an allowed attempt and we haven't failed on any of the other rules that // match, update the reply field. if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && - reply.valid) { - reply.valid = true; + reply.allowed) { + reply.allowed = true; reply.timeToReset = ruleResult.timeToNextReset < 0 ? rule.options.intervalTime : ruleResult.timeToNextReset; @@ -160,12 +242,14 @@ RateLimiter.prototype.check = function (input) { } /** - * Appends a rule to list of rules that are checked against on every method invocation + * Adds a rule to dictionary of rules that are checked against on every call. Only inputs that + * pass all of the rules will be allowed and order doesn't matter. * @param {object} rule Input dictionary defining certain attributes and rules associated with them. * Each attribute's value can either be a value, a function or null. All functions must return a boolean - * response saying whether the input is matched by that attribute's rule or not + * of whether the input is matched by that attribute's rule or not * @param {integer} numRequestsAllowed Number of requests allowed per interval * @param {integer} intervalTime Number of milliseconds before interval is reset + * @return {string} Returns the randomly generated rule id */ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime) { var self = this; @@ -181,9 +265,9 @@ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime } /** - * Increment appropriate rule counters on every input + * Increment counters in every rule that match to this input * @param {object} input Dictionary object containing attributes that may match to - * certain rules + * rules */ RateLimiter.prototype.increment = function (input) { var self = this; @@ -217,7 +301,11 @@ RateLimiter.prototype._findAllMatchingRules = function (input) { }); return matchingRules; } - +/** + * Provides a mechanism to remove rules from the rate limiter + * @param {string} id Rule id returned from #addRule + * @return {boolean} Returns true if rule was found and deleted, else false. + */ RateLimiter.prototype.removeRule = function (id) { var self = this; if (self.rules[id]) { From 9f9638e3a88d267054a403ec1ffd304f328a05ee Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 29 Jun 2015 10:11:49 -0700 Subject: [PATCH 18/34] Added DDPRateLimiter to API docs --- docs/client/full-api/api/accounts.md | 7 +++++++ docs/client/full-api/api/methods.md | 9 +++++++++ docs/client/full-api/tableOfContents.js | 3 ++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/client/full-api/api/accounts.md b/docs/client/full-api/api/accounts.md index f5d3db3f44..e1477ed836 100644 --- a/docs/client/full-api/api/accounts.md +++ b/docs/client/full-api/api/accounts.md @@ -398,4 +398,11 @@ These functions return an object with a single method, `stop`. Calling On the server, the callbacks get a single argument, the same attempt info object as [`validateLoginAttempt`](#accounts_validateloginattempt). On the client, no arguments are passed. + +

Rate Limiting

+ +By default, there are rules added to the [`DDPRateLimiter`](#DDPRateLimiter) that rate limit logins, new user registration and password reset calls to a limit of 5 requests per 10 seconds per IP address. These are a basic solution to dictionary attacks where a malicious user attempts to guess the passwords of legitimate users by attempting all possible passwords. + +These rate limiting rules can be removed by calling `Accounts.removeDefaultRateLimit()`. Please see the [`DDPRateLimiter`](#DDPRateLimiter) docs for more information. + {{/template}} diff --git a/docs/client/full-api/api/methods.md b/docs/client/full-api/api/methods.md index e912a6cfb3..4471decb94 100644 --- a/docs/client/full-api/api/methods.md +++ b/docs/client/full-api/api/methods.md @@ -180,4 +180,13 @@ even if the method's writes are not available yet, you can specify an passed as an array rather than directly as arguments, and you can specify options about how the client executes the method. +

DDPRateLimiter

+ +A rate limiter added directly to DDP. The DDPRateLimiter allows you to add rules to limit calls by one or more of user IDs, IP addresses, method names and/or subscription names. The rate limiter is called on every method and subscription invocation. A default rule of limiting 'login' attempts to 5 calls every 10 seconds per IP address has been added to the [`Accounts base package`](#accounts_api). The rule can be removed by calling [`Accounts.removeDefaultRateLimit()`]. + +{{> autoApiBox "DDPRateLimiter.addRule"}} +{{> autoApiBox "DDPRateLimiter.removeRule"}} +{{> autoApiBox "DDPRateLimiter.setErrorMessage"}} {{/template}} + +{{> auto}} diff --git a/docs/client/full-api/tableOfContents.js b/docs/client/full-api/tableOfContents.js index 8a4152a264..2e7827d304 100644 --- a/docs/client/full-api/tableOfContents.js +++ b/docs/client/full-api/tableOfContents.js @@ -53,7 +53,8 @@ var toc = [ ], "Meteor.Error", "Meteor.call", - "Meteor.apply" + "Meteor.apply", + {name: "DDPRateLimiter", id: "ddpratelimiter"}, ], {name: "Check", id: "check_package"}, [ From ac1e0355c55386b142403a2d57aa3ac4fc4eb729 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 29 Jun 2015 13:51:40 -0700 Subject: [PATCH 19/34] Added in per connection id rule as default, instead of per IP. Removed a console.log --- packages/accounts-base/accounts_rate_limit.js | 9 +++++---- packages/ddp-server/livedata_server.js | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index 16770b9fa7..cb1e7f7532 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -5,12 +5,13 @@ var Ap = AccountsCommon.prototype; // Stores the ruleId to provide option to remove the default rule. Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ userId: null, - ipAddr: function (ipAddr) { - return true; - }, + ipAddr: null, type: 'method', name: function(name) { - return _.has(['login', 'createUser', 'resetPassword']); + return _.contains(['login', 'createUser', 'resetPassword'], name); + }, + sessionId: function(sessionId) { + return true; } }, 5, 10000); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 9b25192b2e..1dbe62e24b 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -584,7 +584,8 @@ _.extend(Session.prototype, { userId: self.userId, ipAddr: self.connectionHandle.clientAddress, type: msg.msg, - name: msg.name + name: msg.name, + sessionId: self.id }; DDPRateLimiter._increment(rateLimiterInput); @@ -666,10 +667,10 @@ _.extend(Session.prototype, { userId: self.userId, ipAddr: self.connectionHandle.clientAddress, type: msg.msg, - name: msg.method + name: msg.method, + sessionId: self.id }; DDPRateLimiter._increment(rateLimiterInput); - console.log(rateLimiterInput); var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) if (!rateLimitResult.allowed) { throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); From 38fe0d41e49649dbf48efeb63fd2dae7223d8ad0 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 29 Jun 2015 14:10:34 -0700 Subject: [PATCH 20/34] Changed Rule.resetCounter to delete all keys, allowing for garbage collection --- packages/rate-limit/rate-limit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index fe102d7358..615e37ea71 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -170,9 +170,9 @@ _.extend(Rule.prototype, { // has exceeded the intervalTime. resetCounter: function () { var self = this; - _.each(self.counters, function (value, key) { - self.counters[key] = 0; - }); + + // Delete the old counters dictionary to allow for garbage collection + self.counters = {}; self._lastResetTime = new Date().getTime(); } }); From 26f612cb0bb9d60af24017fb94ecbfef5c16b14e Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 30 Jun 2015 11:17:40 -0700 Subject: [PATCH 21/34] Moving DDPRateLimiter to a weak dependency to minimize dependencies on DDP --- packages/ddp-server/livedata_server.js | 57 +++++++++++++++----------- packages/ddp-server/package.js | 2 +- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 1dbe62e24b..1b2886636a 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -580,22 +580,26 @@ _.extend(Session.prototype, { // reconnect. return; - var rateLimiterInput = { - userId: self.userId, - ipAddr: self.connectionHandle.clientAddress, - type: msg.msg, - name: msg.name, - sessionId: self.id - }; + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + ipAddr: self.connectionHandle.clientAddress, + type: msg.msg, + name: msg.name, + sessionId: self.id + }; - DDPRateLimiter._increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) - if (!rateLimitResult.allowed) { - self.send({ - msg: 'nosub', id: msg.id, - error: new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)) - }); + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { + self.send({ + msg: 'nosub', id: msg.id, + error: new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)) + }); + } } + var handler = self.server.publish_handlers[msg.name]; self._startSubscription(handler, msg.id, msg.params, msg.name); @@ -663,17 +667,20 @@ _.extend(Session.prototype, { }); try { - var rateLimiterInput = { - userId: self.userId, - ipAddr: self.connectionHandle.clientAddress, - type: msg.msg, - name: msg.method, - sessionId: self.id - }; - DDPRateLimiter._increment(rateLimiterInput); - var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) - if (!rateLimitResult.allowed) { - throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + ipAddr: self.connectionHandle.clientAddress, + type: msg.msg, + name: msg.method, + sessionId: self.id + }; + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { + throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); + } } var result = DDPServer._CurrentWriteFence.withValue(fence, function () { diff --git a/packages/ddp-server/package.js b/packages/ddp-server/package.js index b76435494a..427a47ecb3 100644 --- a/packages/ddp-server/package.js +++ b/packages/ddp-server/package.js @@ -15,7 +15,7 @@ Package.onUse(function (api) { // common functionality api.use('ddp-common', 'server'); // heartbeat - api.use('ddp-rate-limiter'); + api.use('ddp-rate-limiter', 'server', {weak: true}); // Transport api.use('ddp-client', 'server'); api.imply('ddp-client'); From 4302ad22191d84a4decc3f4506d0d2c5810b3df5 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Thu, 2 Jul 2015 11:10:20 -0700 Subject: [PATCH 22/34] Fix tests to new naming of valid-->allowed --- packages/rate-limit/rate-limit-tests.js | 42 ++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 59b65ec1c0..04ac15985a 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -24,8 +24,8 @@ Tinytest.add( r.increment(methodInvc1); r.increment(methodInvc2); } - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, true); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, true); }); testAsyncMulti("Run multiple invocations and wait for one to return", [ @@ -48,8 +48,8 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ self.r.increment(self.methodInvc1); self.r.increment(self.methodInvc2); } - test.equal(self.r.check(self.methodInvc1).valid, false); - test.equal(self.r.check(self.methodInvc2).valid, true); + test.equal(self.r.check(self.methodInvc1).allowed, false); + test.equal(self.r.check(self.methodInvc2).allowed, true); Meteor.setTimeout(expect(function () {}), 1000); }, function (test, expect) { @@ -58,8 +58,8 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ self.r.increment(self.methodInvc2); } - test.equal(self.r.check(self.methodInvc1).valid, true); - test.equal(self.r.check(self.methodInvc2).valid, true); + test.equal(self.r.check(self.methodInvc1).allowed, true); + test.equal(self.r.check(self.methodInvc2).allowed, true); } ]); @@ -95,15 +95,15 @@ Tinytest.add('Check two rules that affect same methodInvc still throw', }; // After for loop runs, we only have 10 runs, so that's under the limit - test.equal(r.check(methodInvc1).valid, true); + test.equal(r.check(methodInvc1).allowed, true); // However, this triggers userId rule since this userId is even - test.equal(r.check(methodInvc2).valid, false); - test.equal(r.check(methodInvc2).valid, false); + test.equal(r.check(methodInvc2).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); // Running one more test causes it to be false, since we're at 11 now. r.increment(methodInvc1); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc3).valid, true); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc3).allowed, true); }); @@ -129,8 +129,8 @@ Tinytest.add('Check two rules that are affected by different invocations', } r.increment(methodInvc1); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, false); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); }); Tinytest.add("add global rule", function (test) { @@ -153,13 +153,13 @@ Tinytest.add("add global rule", function (test) { 'user-accounts'); r.increment(methodInvc2); - test.equal(r.check(methodInvc1).valid, true); - test.equal(r.check(methodInvc2).valid, true); - test.equal(r.check(methodInvc3).valid, true); + test.equal(r.check(methodInvc1).allowed, true); + test.equal(r.check(methodInvc2).allowed, true); + test.equal(r.check(methodInvc3).allowed, true); r.increment(methodInvc3); - test.equal(r.check(methodInvc1).valid, false); - test.equal(r.check(methodInvc2).valid, false); - test.equal(r.check(methodInvc3).valid, false); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + test.equal(r.check(methodInvc3).allowed, false); }); Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { @@ -179,7 +179,7 @@ Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { for (var i = 0; i < 5; i++) { r.increment(input); } - test.equal(r.check(input).valid, true); + test.equal(r.check(input).allowed, true); var matchingInput = { a: 3, b: 5, @@ -189,7 +189,7 @@ Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { r.increment(matchingInput); r.increment(matchingInput); // Past limit so should be false - test.equal(r.check(matchingInput).valid, false); + test.equal(r.check(matchingInput).allowed, false); // Add secondary rule and check that longer time is returned when multiple rules limits are hit From 89afc4a22a9134da4818cc729b97396fdf5a8c3e Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Thu, 2 Jul 2015 16:20:12 -0700 Subject: [PATCH 23/34] Added forgot password to default rate limit --- packages/accounts-base/accounts_rate_limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index cb1e7f7532..11552fa848 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -8,7 +8,7 @@ Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ ipAddr: null, type: 'method', name: function(name) { - return _.contains(['login', 'createUser', 'resetPassword'], name); + return _.contains(['login', 'createUser', 'resetPassword', 'forgotPassword'], name); }, sessionId: function(sessionId) { return true; From 50d3437da9bfa5943b0dbb264b2e606fd6dbbfc5 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 6 Jul 2015 12:19:21 -0700 Subject: [PATCH 24/34] Updated comments and line sizes in markdown and rate limiter package --- docs/client/full-api/api/accounts.md | 10 +- packages/accounts-base/accounts_rate_limit.js | 11 +- packages/rate-limit/rate-limit.js | 183 ++++++++---------- 3 files changed, 97 insertions(+), 107 deletions(-) diff --git a/docs/client/full-api/api/accounts.md b/docs/client/full-api/api/accounts.md index e1477ed836..130867ee5e 100644 --- a/docs/client/full-api/api/accounts.md +++ b/docs/client/full-api/api/accounts.md @@ -401,8 +401,14 @@ client, no arguments are passed.

Rate Limiting

-By default, there are rules added to the [`DDPRateLimiter`](#DDPRateLimiter) that rate limit logins, new user registration and password reset calls to a limit of 5 requests per 10 seconds per IP address. These are a basic solution to dictionary attacks where a malicious user attempts to guess the passwords of legitimate users by attempting all possible passwords. +By default, there are rules added to the [`DDPRateLimiter`](#DDPRateLimiter) +that rate limit logins, new user registration and password reset calls to a +limit of 5 requests per 10 seconds per IP address. These are a basic solution +to dictionary attacks where a malicious user attempts to guess the passwords +of legitimate users by attempting all possible passwords. -These rate limiting rules can be removed by calling `Accounts.removeDefaultRateLimit()`. Please see the [`DDPRateLimiter`](#DDPRateLimiter) docs for more information. +These rate limiting rules can be removed by calling +`Accounts.removeDefaultRateLimit()`. Please see the +[`DDPRateLimiter`](#DDPRateLimiter) docs for more information. {{/template}} diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index 11552fa848..9801b09f7a 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -7,15 +7,16 @@ Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ userId: null, ipAddr: null, type: 'method', - name: function(name) { - return _.contains(['login', 'createUser', 'resetPassword', 'forgotPassword'], name); + name: function (name) { + return _.contains(['login', 'createUser', 'resetPassword', + 'forgotPassword'], name); }, - sessionId: function(sessionId) { - return true; + sessionId: function (sessionId) { + return true; } }, 5, 10000); // Removes default rate limiting rule Ap.removeDefaultRateLimit = function () { - return DDPRateLimiter.removeRule(Ap._defaultRateLimiterRuleId); + return DDPRateLimiter.removeRule(Ap._defaultRateLimiterRuleId); } \ No newline at end of file diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 615e37ea71..dead5df21c 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -3,82 +3,11 @@ // * RateLimiter - a general rate limiter that stores rules and determines // whether inputs are allowed // -// ** Rules ** -// Rules are composed of the following fields: -// - id Random id generated and used to key the rule in the rate limiter -// - options Object that contains the intervalTime after which the rule is reset, and the -// number of calls that are allowed in the specified interval time -// - matchers Dictionary of keys that are searched for in the input provided to match -// with values that define the set of values that match this rule. Values can be -// objects or they can be functions that return a boolean of whether the provided -// input matches. For example, if we only want to match all even ids, plus any other fields -// , we could have a rule that included a key value pair as follows: -// { -// ... -// id: function (id) { -// return id % 2 === 0; -// }, -// ... -// } -// A rule is only said to apply to a given input if every key in the matcher -// matches to the input values. -// - _lastResetTime Last time this rule's counters were reset. Last reset time is used to -// keep track if the interval time has passed -// - counters Dictionary that stores the current state of inputs and number times they've -// been passed to the rate limiter. Unique keys are made per input per rule that -// create a concatenated string of all keys in the rule with the values from the -// input -// -// For example, if we had a rule with matchers as such: -// { -// userId: function(userId) { -// return true; -// }, -// methodName: 'hello' -// } -// and we were passed an input as follows: -// { -// userId: 'meteor' -// methodName: 'hello' -// } -// -// The key generated would be 'userIdmeteormethodNamehello'. -// -// These counters are checked on every invocation to determine whether a rate limit -// has been reached. -// -// The methods provided are as follows: -// * match(input) : Checks whether the rule applies to the provided input by comparing every -// field in the matcher to the provided input. Order of input doesn't matter, -// they just must contain the appropriate keys and the values must be allowed by -// the matcher -// * _generateKeyString(input) : generates a key string by concatenating all the keys in the matcher -// with the corresponding values in the input -// * apply(input) : applies the provided input and returns the key string, the time since last -// reset and time to next reset -// * resetCounter() : resets the counters for this rule and sets _lastResetTime to be the current time -// -// ** Rate Limiter ** -// A rate limiter stores a dictionary of rules keyed by their rule Id. It provides the following methods: -// -// * addRule(rule, numRequests, intervalTime) : adds a Rule to dictionary of rules and returns the user -// the rule Id -// * removeRule(id) : removes a rule from the dictionary and returns boolean detailing success -// * check(input) : checks all rules that apply to this input to see if any rate limits have been exceeded -// Returns an object as follows: -// { -// allowed: boolean - is this input allowed -// timeToReset: integer - returns time to reset in milliseconds -// numInvocationsLeft: integer - returns number of calls left before limit is reached -// } -// If multiple rules match, the least number of invocations left is returned. If the rate limit -// has been reached, the longest timeToReset is returned. -// -// * increment(input) : increments counters in all rules that apply to this input. -// * _findAllMatchingRules(input) : returns an array of all rules that apply to provided input -// - - +// Rate limiters analyze a series of "inputs" (which are POJOs) by running +// them against a set of "rules". Rules specify which inputs they match by +// running configurable "matcher" functions on keys in the event object). A +// `check` method returns whether this input should be allowed, the time +// until next reset and the number of calls for this input left. // Default time interval (in milliseconds) to reset rate limit counters var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; @@ -107,7 +36,9 @@ var Rule = function (options, matchers) { _.extend(Rule.prototype, { // Determine if this rule applies to the given input by comparing all // rule.matchers. If the match fails, search short circuits instead of - // iterating through all matchers. + // iterating through all matchers. The order of the input doesn't matter, + // it just must contain the appropriate keys and their respective values + // must be allowed by the matcher. match: function (input) { var self = this; var ruleMatches = true; @@ -134,7 +65,8 @@ _.extend(Rule.prototype, { return ruleMatches; }, - // Generates unique key string for provided input + // Generates unique key string for provided input by concatenating all the + // keys in the matcher with the corresponding values in the input. // Only called if rule matches input. _generateKeyString: function (input) { var self = this; @@ -153,8 +85,8 @@ _.extend(Rule.prototype, { return returnString; }, - // Generates the key, timeSinceLastReset and timeToNextReset once the rule - // is applied + // Applies the provided input and returns the key string, time since last + // reset and time to next reset. apply: function (input) { var self = this; var keyString = self._generateKeyString(input); @@ -166,8 +98,9 @@ _.extend(Rule.prototype, { timeToNextReset: timeToNextReset }; }, - // Reset all keys for this specific rule. Called once the timeSinceLastReset - // has exceeded the intervalTime. + // Reset counter dictionary for this specific rule. Called once the + // timeSinceLastReset has exceeded the intervalTime. _lastResetTime is + // set to be the current time in milliseconds. resetCounter: function () { var self = this; @@ -189,9 +122,16 @@ RateLimiter = function () { /** * Checks if this input has exceeded any rate limits. - * @param {object} input dictionary containing key-value pairs of attributes that match to rules - * @return {object} Returns object of whether input is allowed, time - * to next reset and number invocations left + * @param {object} input dictionary containing key-value pairs of attributes + * that match to rules + * @return {object} Returns object of following structure + * { 'allowed': boolean - is this input allowed + * 'timeToReset': integer - returns time to reset in milliseconds + * 'numInvocationsLeft': integer - returns number of calls left before limit + * is reached + * } + * If multiple rules match, the least number of invocations left is returned. + * If the rate limit has been reached, the longest timeToReset is returned. */ RateLimiter.prototype.check = function (input) { var self = this; @@ -225,10 +165,10 @@ RateLimiter.prototype.check = function (input) { reply.allowed = false; reply.numInvocationsLeft = 0; } else { - // If this is an allowed attempt and we haven't failed on any of the other rules that - // match, update the reply field. - if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && - reply.allowed) { + // If this is an allowed attempt and we haven't failed on any of the + // other rules that match, update the reply field. + if (rule.options.numRequestsAllowed - numInvocations < + reply.numInvocationsLeft && reply.allowed) { reply.allowed = true; reply.timeToReset = ruleResult.timeToNextReset < 0 ? rule.options.intervalTime : @@ -241,17 +181,58 @@ RateLimiter.prototype.check = function (input) { return reply; } +// Each rule is composed of an `id`, an options object that contains the +// `intervalTime` after which the rule is reset, and `numRequestsAllowed` in +// the specified interval time, a dictionary of `matchers` whose keys are +// searched for in the input provided to determine if there is a match. If the +// values match, then the rules counters are incremented. Values can be objects +// or they can be functions that return a boolean of whether the provided input +// matches. For example, if we only want to match all even ids, plusany other +// fields, we could have a rule that included a key-value pair as follows: +// { +// ... +// id: function (id) { +// return id % 2 === 0; +// }, +// ... +// } +// A rule is only said to apply to a given input if every key in the matcher +// matchesto the input values. There is also a dictionary of `counters` that +// store the current state of inputs and number of times they've been passed +// to the rate limiter. Unique keys are made per input per rule that create +// a concatenated string of all keys in the rule with the values from the +// input. For example, if we had a rule with matchers as such: +// { +// userId: function(userId) { +// return true; +// }, +// methodName: 'hello' +// } +// and we were passed an input as follows: +// { +// userId: 'meteor' +// methodName: 'hello' +// } +// The key generated would be 'userIdmeteormethodNamehello'. +// These counters are checked on every invocation to determine whether a rate +// limit has been reached. + /** - * Adds a rule to dictionary of rules that are checked against on every call. Only inputs that - * pass all of the rules will be allowed and order doesn't matter. - * @param {object} rule Input dictionary defining certain attributes and rules associated with them. - * Each attribute's value can either be a value, a function or null. All functions must return a boolean - * of whether the input is matched by that attribute's rule or not + * Adds a rule to dictionary of rules that are checked against on every call. + * Only inputs that pass all of the rules will be allowed and order doesn't + * matter. Returns unique rule id that can be passed to 'removeRule'. + * @param {object} rule Input dictionary defining certain attributes and + * rules associated with them. + * Each attribute's value can either be a value, a function or null. All + * functions must return a boolean of whether the input is matched by that + * attribute's rule or not * @param {integer} numRequestsAllowed Number of requests allowed per interval - * @param {integer} intervalTime Number of milliseconds before interval is reset - * @return {string} Returns the randomly generated rule id + * @param {integer} intervalTime Number of milliseconds before interval + * is reset + * @return {string} Returns unique rule id */ -RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime) { +RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, + intervalTime) { var self = this; var options = { @@ -266,8 +247,8 @@ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, intervalTime /** * Increment counters in every rule that match to this input - * @param {object} input Dictionary object containing attributes that may match to - * rules + * @param {object} input Dictionary object containing attributes that may + * match to rules */ RateLimiter.prototype.increment = function (input) { var self = this; @@ -291,6 +272,7 @@ RateLimiter.prototype.increment = function (input) { }); } +// Returns an array of all rules that apply to provided input RateLimiter.prototype._findAllMatchingRules = function (input) { var self = this; @@ -302,7 +284,8 @@ RateLimiter.prototype._findAllMatchingRules = function (input) { return matchingRules; } /** - * Provides a mechanism to remove rules from the rate limiter + * Provides a mechanism to remove rules from the rate limiter. Returns boolean + * about success. * @param {string} id Rule id returned from #addRule * @return {boolean} Returns true if rule was found and deleted, else false. */ From 4cc907f7e4825a53d583a88209c3c4f61658f8b3 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 13 Jul 2015 12:27:12 -0700 Subject: [PATCH 25/34] Updating docs and fixing code review comments. --- docs/client/data.js | 796 +++++++++++------- docs/client/full-api/api/accounts.md | 6 +- docs/client/full-api/api/methods.md | 17 +- docs/client/full-api/tableOfContents.js | 5 +- docs/client/names.json | 27 +- packages/ddp-rate-limiter/ddp-rate-limiter.js | 22 +- packages/ddp-server/livedata_server.js | 13 +- packages/rate-limit/rate-limit.js | 155 ++-- 8 files changed, 619 insertions(+), 422 deletions(-) diff --git a/docs/client/data.js b/docs/client/data.js index 76bc801c33..59891e96e9 100644 --- a/docs/client/data.js +++ b/docs/client/data.js @@ -1,12 +1,12 @@ // This file is automatically generated by JSDoc; regenerate it with scripts/admin/jsdoc/jsdoc.sh DocsData = { "Accounts": { - "filepath": "accounts-base/accounts_common.js", + "filepath": "accounts-base/globals_server.js", "kind": "namespace", "lineno": 1, "longname": "Accounts", "name": "Accounts", - "summary": "The namespace for all accounts-related methods." + "summary": "The namespace for all server-side accounts-related methods." }, "Accounts.changePassword": { "filepath": "accounts-password/password_client.js", @@ -50,75 +50,6 @@ DocsData = { "scope": "static", "summary": "Change the current user's password. Must be logged in." }, - "Accounts.config": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 55, - "locus": "Anywhere", - "longname": "Accounts.config", - "memberof": "Accounts", - "name": "config", - "options": [ - { - "description": "

New users with an email address will receive an address verification email.

", - "name": "sendVerificationEmail", - "type": { - "names": [ - "Boolean" - ] - } - }, - { - "description": "

Calls to createUser from the client will be rejected. In addition, if you are using accounts-ui, the "Create account" link will not be available.

", - "name": "forbidClientAccountCreation", - "type": { - "names": [ - "Boolean" - ] - } - }, - { - "description": "

If set to a string, only allows new users if the domain part of their email address matches the string. If set to a function, only allows new users if the function returns true. The function is passed the full email address of the proposed new user. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: Accounts.config({ restrictCreationByEmailDomain: 'school.edu' }).

", - "name": "restrictCreationByEmailDomain", - "type": { - "names": [ - "String", - "function" - ] - } - }, - { - "description": "

The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to null to disable login expiration.

", - "name": "loginExpirationInDays", - "type": { - "names": [ - "Number" - ] - } - }, - { - "description": "

When using the oauth-encryption package, the 16 byte key using to encrypt sensitive account credentials in the database, encoded in base64. This option may only be specifed on the server. See packages/oauth-encryption/README.md for details.

", - "name": "oauthSecretKey", - "type": { - "names": [ - "String" - ] - } - } - ], - "params": [ - { - "name": "options", - "type": { - "names": [ - "Object" - ] - } - } - ], - "scope": "static", - "summary": "Set global accounts options." - }, "Accounts.createUser": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -241,144 +172,6 @@ DocsData = { "scope": "static", "summary": "Request a forgot password email." }, - "Accounts.onCreateUser": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 961, - "locus": "Server", - "longname": "Accounts.onCreateUser", - "memberof": "Accounts", - "name": "onCreateUser", - "options": [], - "params": [ - { - "description": "

Called whenever a new user is created. Return the new user object, or throw an Error to abort the creation.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Customize new user creation." - }, - "Accounts.onEmailVerificationLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 110, - "locus": "Client", - "longname": "Accounts.onEmailVerificationLink", - "memberof": "Accounts", - "name": "onEmailVerificationLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: An email verification token that can be passed to\nAccounts.verifyEmail.
  2. \n
  3. done: A function to call when the email verification UI flow is complete.\nThe normal login process is suspended until this function is called, so\nthat the user can be notified that they are verifying their email before\nbeing logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when an email verification link is\nclicked in an email sent by\n[`Accounts.sendVerificationEmail`](#accounts_sendverificationemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, - "Accounts.onEnrollmentLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 135, - "locus": "Client", - "longname": "Accounts.onEnrollmentLink", - "memberof": "Accounts", - "name": "onEnrollmentLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword to give the newly\nenrolled account a password.
  2. \n
  3. done: A function to call when the enrollment UI flow is complete.\nThe normal login process is suspended until this function is called, so that\nuser A can be enrolled even if user B was logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when an account enrollment link is\nclicked in an email sent by\n[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, - "Accounts.onLogin": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 202, - "locus": "Anywhere", - "longname": "Accounts.onLogin", - "memberof": "Accounts", - "name": "onLogin", - "options": [], - "params": [ - { - "description": "

The callback to be called when login is successful.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a callback to be called after a login attempt succeeds." - }, - "Accounts.onLoginFailure": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 211, - "locus": "Anywhere", - "longname": "Accounts.onLoginFailure", - "memberof": "Accounts", - "name": "onLoginFailure", - "options": [], - "params": [ - { - "description": "

The callback to be called after the login has failed.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a callback to be called after a login attempt fails." - }, - "Accounts.onResetPasswordLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 85, - "locus": "Client", - "longname": "Accounts.onResetPasswordLink", - "memberof": "Accounts", - "name": "onResetPasswordLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword.
  2. \n
  3. done: A function to call when the password reset UI flow is complete. The normal\nlogin process is suspended until this function is called, so that the\npassword for user A can be reset even if user B was logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when a reset password link is clicked\nin an email sent by\n[`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, "Accounts.resetPassword": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -640,52 +433,6 @@ DocsData = { "scope": "static", "summary": "Configure the behavior of [`{{> loginButtons}}`](#accountsui)." }, - "Accounts.validateLoginAttempt": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 43, - "locus": "Server", - "longname": "Accounts.validateLoginAttempt", - "memberof": "Accounts", - "name": "validateLoginAttempt", - "options": [], - "params": [ - { - "description": "

Called whenever a login is attempted (either successful or unsuccessful). A login can be aborted by returning a falsy value or throwing an exception.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Validate login attempts." - }, - "Accounts.validateNewUser": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 1041, - "locus": "Server", - "longname": "Accounts.validateNewUser", - "memberof": "Accounts", - "name": "validateNewUser", - "options": [], - "params": [ - { - "description": "

Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Set restrictions on new user creation." - }, "Accounts.verifyEmail": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -719,6 +466,272 @@ DocsData = { "scope": "static", "summary": "Marks the user's email address as verified. Logs the user in afterwards." }, + "Ap.config": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 110, + "locus": "Anywhere", + "longname": "Ap.config", + "memberof": "Ap", + "name": "config", + "options": [ + { + "description": "

New users with an email address will receive an address verification email.

", + "name": "sendVerificationEmail", + "type": { + "names": [ + "Boolean" + ] + } + }, + { + "description": "

Calls to createUser from the client will be rejected. In addition, if you are using accounts-ui, the "Create account" link will not be available.

", + "name": "forbidClientAccountCreation", + "type": { + "names": [ + "Boolean" + ] + } + }, + { + "description": "

If set to a string, only allows new users if the domain part of their email address matches the string. If set to a function, only allows new users if the function returns true. The function is passed the full email address of the proposed new user. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: Accounts.config({ restrictCreationByEmailDomain: 'school.edu' }).

", + "name": "restrictCreationByEmailDomain", + "type": { + "names": [ + "String", + "function" + ] + } + }, + { + "description": "

The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to null to disable login expiration.

", + "name": "loginExpirationInDays", + "type": { + "names": [ + "Number" + ] + } + }, + { + "description": "

When using the oauth-encryption package, the 16 byte key using to encrypt sensitive account credentials in the database, encoded in base64. This option may only be specifed on the server. See packages/oauth-encryption/README.md for details.

", + "name": "oauthSecretKey", + "type": { + "names": [ + "String" + ] + } + } + ], + "params": [ + { + "name": "options", + "type": { + "names": [ + "Object" + ] + } + } + ], + "scope": "static", + "summary": "Set global accounts options." + }, + "Ap.onCreateUser": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 1173, + "locus": "Server", + "longname": "Ap.onCreateUser", + "memberof": "Ap", + "name": "onCreateUser", + "options": [], + "params": [ + { + "description": "

Called whenever a new user is created. Return the new user object, or throw an Error to abort the creation.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Customize new user creation." + }, + "Ap.onEmailVerificationLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 129, + "locus": "Client", + "longname": "Ap.onEmailVerificationLink", + "memberof": "Ap", + "name": "onEmailVerificationLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: An email verification token that can be passed to\nAccounts.verifyEmail.
  2. \n
  3. done: A function to call when the email verification UI flow is complete.\nThe normal login process is suspended until this function is called, so\nthat the user can be notified that they are verifying their email before\nbeing logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when an email verification link is\nclicked in an email sent by\n[`Accounts.sendVerificationEmail`](#accounts_sendverificationemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.onEnrollmentLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 154, + "locus": "Client", + "longname": "Ap.onEnrollmentLink", + "memberof": "Ap", + "name": "onEnrollmentLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword to give the newly\nenrolled account a password.
  2. \n
  3. done: A function to call when the enrollment UI flow is complete.\nThe normal login process is suspended until this function is called, so that\nuser A can be enrolled even if user B was logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when an account enrollment link is\nclicked in an email sent by\n[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.onLogin": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 239, + "locus": "Anywhere", + "longname": "Ap.onLogin", + "memberof": "Ap", + "name": "onLogin", + "options": [], + "params": [ + { + "description": "

The callback to be called when login is successful.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a callback to be called after a login attempt succeeds." + }, + "Ap.onLoginFailure": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 248, + "locus": "Anywhere", + "longname": "Ap.onLoginFailure", + "memberof": "Ap", + "name": "onLoginFailure", + "options": [], + "params": [ + { + "description": "

The callback to be called after the login has failed.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a callback to be called after a login attempt fails." + }, + "Ap.onResetPasswordLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 104, + "locus": "Client", + "longname": "Ap.onResetPasswordLink", + "memberof": "Ap", + "name": "onResetPasswordLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword.
  2. \n
  3. done: A function to call when the password reset UI flow is complete. The normal\nlogin process is suspended until this function is called, so that the\npassword for user A can be reset even if user B was logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when a reset password link is clicked\nin an email sent by\n[`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.userId": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 39, + "locus": "Anywhere but publish functions", + "longname": "Ap.userId", + "memberof": "Ap", + "name": "userId", + "options": [], + "params": [], + "scope": "static", + "summary": "Get the current user id, or `null` if no user is logged in. A reactive data source." + }, + "Ap.validateLoginAttempt": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 90, + "locus": "Server", + "longname": "Ap.validateLoginAttempt", + "memberof": "Ap", + "name": "validateLoginAttempt", + "options": [], + "params": [ + { + "description": "

Called whenever a login is attempted (either successful or unsuccessful). A login can be aborted by returning a falsy value or throwing an exception.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Validate login attempts." + }, + "Ap.validateNewUser": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 1256, + "locus": "Server", + "longname": "Ap.validateNewUser", + "memberof": "Ap", + "name": "validateNewUser", + "options": [], + "params": [ + { + "description": "

Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Set restrictions on new user creation." + }, "App": { "kind": "namespace", "longname": "App", @@ -972,7 +985,7 @@ DocsData = { "Blaze.Each": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 123, + "lineno": 122, "locus": "Client", "longname": "Blaze.Each", "memberof": "Blaze", @@ -1014,7 +1027,7 @@ DocsData = { "Blaze.If": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 74, + "lineno": 73, "locus": "Client", "longname": "Blaze.If", "memberof": "Blaze", @@ -1056,15 +1069,15 @@ DocsData = { "Blaze.Let": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 60, + "lineno": 59, "longname": "Blaze.Let", "memberof": "Blaze", "name": "Let", "options": [], "params": [ { - "description": "

A function to reactively re-run. The returned\ndictionary maps names of bindings with values or computations to reactively\nre-run.

", - "name": "bindingsFunc", + "description": "

Dictionary mapping names of bindings to\nvalues or computations to reactively re-run.

", + "name": "bindings", "type": { "names": [ "function" @@ -1427,7 +1440,7 @@ DocsData = { "Blaze.Unless": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 99, + "lineno": 98, "locus": "Client", "longname": "Blaze.Unless", "memberof": "Blaze", @@ -1535,7 +1548,7 @@ DocsData = { "Blaze.currentView": { "filepath": "blaze/view.js", "kind": "member", - "lineno": 536, + "lineno": 532, "locus": "Client", "longname": "Blaze.currentView", "memberof": "Blaze", @@ -1551,7 +1564,7 @@ DocsData = { "Blaze.getData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 747, + "lineno": 743, "locus": "Client", "longname": "Blaze.getData", "memberof": "Blaze", @@ -1576,7 +1589,7 @@ DocsData = { "Blaze.getView": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 785, + "lineno": 781, "locus": "Client", "longname": "Blaze.getView", "memberof": "Blaze", @@ -1623,7 +1636,7 @@ DocsData = { "Blaze.remove": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 681, + "lineno": 677, "locus": "Client", "longname": "Blaze.remove", "memberof": "Blaze", @@ -1646,7 +1659,7 @@ DocsData = { "Blaze.render": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 618, + "lineno": 614, "locus": "Client", "longname": "Blaze.render", "memberof": "Blaze", @@ -1699,7 +1712,7 @@ DocsData = { "Blaze.renderWithData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 669, + "lineno": 665, "locus": "Client", "longname": "Blaze.renderWithData", "memberof": "Blaze", @@ -1762,7 +1775,7 @@ DocsData = { "Blaze.toHTML": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 702, + "lineno": 698, "locus": "Client", "longname": "Blaze.toHTML", "memberof": "Blaze", @@ -1786,7 +1799,7 @@ DocsData = { "Blaze.toHTMLWithData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 714, + "lineno": 710, "locus": "Client", "longname": "Blaze.toHTMLWithData", "memberof": "Blaze", @@ -2238,7 +2251,7 @@ DocsData = { "DDP.connect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1624, + "lineno": 1626, "locus": "Anywhere", "longname": "DDP.connect", "memberof": "DDP", @@ -2363,6 +2376,111 @@ DocsData = { "scope": "instance", "summary": "The id of the user that made this method call, or `null` if no user was logged in." }, + "DDPRateLimiter.addRule": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 39, + "longname": "DDPRateLimiter.addRule", + "memberof": "DDPRateLimiter", + "name": "addRule", + "options": [], + "params": [ + { + "description": "

Rule should be an object where the keys are one or\nmore of ['userId', 'ipAddr', 'type', 'name', 'sessionId'] and the values\nare either null, a primitive, or a function that returns true if the rule\nshould apply to the provided input for that key.

", + "name": "rule", + "type": { + "names": [ + "object" + ] + } + }, + { + "description": "

number of requests allowed per time interval.\nDefault = 10.

", + "name": "numRequests", + "type": { + "names": [ + "integer" + ] + } + }, + { + "description": "

time interval in milliseconds after which\nrule's counters are reset. Default = 1000.

", + "name": "timeInterval", + "type": { + "names": [ + "integer" + ] + } + } + ], + "returns": [ + { + "description": "

Returns unique ruleId that can be passed to removeRule.

", + "type": { + "names": [ + "string" + ] + } + } + ], + "scope": "static", + "summary": "Adds a rule with a number of requests allowed per time interval." + }, + "DDPRateLimiter.removeRule": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 48, + "longname": "DDPRateLimiter.removeRule", + "memberof": "DDPRateLimiter", + "name": "removeRule", + "options": [], + "params": [ + { + "description": "

'ruleId' returned from addRule

", + "name": "id", + "type": { + "names": [ + "string" + ] + } + } + ], + "returns": [ + { + "description": "

True if a rule was removed.

", + "type": { + "names": [ + "boolean" + ] + } + } + ], + "scope": "static", + "summary": "Removes the rule with specified id." + }, + "DDPRateLimiter.setErrorMessage": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 23, + "longname": "DDPRateLimiter.setErrorMessage", + "memberof": "DDPRateLimiter", + "name": "setErrorMessage", + "options": [], + "params": [ + { + "description": "

Function that takes an object with a\ntimeToReset field that specifies the first time a method or subscription\ncall is allowed.

", + "name": "message", + "type": { + "names": [ + "string", + "function" + ] + } + } + ], + "scope": "static", + "summary": "Update the error message returned when call is rate limited." + }, "EJSON": { "filepath": "ejson/ejson.js", "kind": "namespace", @@ -2482,7 +2600,7 @@ DocsData = { "EJSON.clone": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 455, + "lineno": 462, "locus": "Anywhere", "longname": "EJSON.clone", "memberof": "EJSON", @@ -2505,7 +2623,7 @@ DocsData = { "EJSON.equals": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 369, + "lineno": 376, "locus": "Anywhere", "longname": "EJSON.equals", "memberof": "EJSON", @@ -2554,7 +2672,7 @@ DocsData = { "EJSON.fromJSONValue": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 310, + "lineno": 317, "locus": "Anywhere", "longname": "EJSON.fromJSONValue", "memberof": "EJSON", @@ -2577,7 +2695,7 @@ DocsData = { "EJSON.isBinary": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 356, + "lineno": 363, "locus": "Anywhere", "longname": "EJSON.isBinary", "memberof": "EJSON", @@ -2600,7 +2718,7 @@ DocsData = { "EJSON.newBinary": { "filepath": "ejson/ejson.js", "kind": "member", - "lineno": 509, + "lineno": 516, "locus": "Anywhere", "longname": "EJSON.newBinary", "memberof": "EJSON", @@ -2622,7 +2740,7 @@ DocsData = { "EJSON.parse": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 345, + "lineno": 352, "locus": "Anywhere", "longname": "EJSON.parse", "memberof": "EJSON", @@ -2645,7 +2763,7 @@ DocsData = { "EJSON.stringify": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 331, + "lineno": 338, "locus": "Anywhere", "longname": "EJSON.stringify", "memberof": "EJSON", @@ -2698,7 +2816,7 @@ DocsData = { "EJSON.toJSONValue": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 241, + "lineno": 248, "locus": "Anywhere", "longname": "EJSON.toJSONValue", "memberof": "EJSON", @@ -3204,7 +3322,7 @@ DocsData = { "options": [], "params": [ { - "description": "

A string code uniquely identifying this kind of error.\nThis string should be used by callers of the method to determine the\nappropriate action to take, instead of attempting to parse the reason\nor details fields. For example:

\n
// on the server, pick a code unique to this error\n// the reason field should be a useful debug message\nthrow new Meteor.Error("logged-out", \n  "The user must be logged in to post a comment.");\n\n// on the client\nMeteor.call("methodName", function (error) {\n  // identify the error\n  if (error.error === "logged-out") {\n    // show a nice error message\n    Session.set("errorMessage", "Please log in to post a comment.");\n  }\n});

For legacy reasons, some built-in Meteor functions such as check throw\nerrors with a number in this field.

", + "description": "

A string code uniquely identifying this kind of error.\nThis string should be used by callers of the method to determine the\nappropriate action to take, instead of attempting to parse the reason\nor details fields. For example:

\n
// on the server, pick a code unique to this error\n// the reason field should be a useful debug message\nthrow new Meteor.Error("logged-out", \n  "The user must be logged in to post a comment.");\n\n// on the client\nMeteor.call("methodName", function (error) {\n  // identify the error\n  if (error && error.error === "logged-out") {\n    // show a nice error message\n    Session.set("errorMessage", "Please log in to post a comment.");\n  }\n});

For legacy reasons, some built-in Meteor functions such as check throw\nerrors with a number in this field.

", "name": "error", "type": { "names": [ @@ -3300,7 +3418,7 @@ DocsData = { "Meteor.apply": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 703, + "lineno": 705, "locus": "Anywhere", "longname": "Meteor.apply", "memberof": "Meteor", @@ -3370,7 +3488,7 @@ DocsData = { "Meteor.call": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 662, + "lineno": 664, "locus": "Anywhere", "longname": "Meteor.call", "memberof": "Meteor", @@ -3459,7 +3577,7 @@ DocsData = { "Meteor.disconnect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1015, + "lineno": 1017, "locus": "Client", "longname": "Meteor.disconnect", "memberof": "Meteor", @@ -3520,7 +3638,7 @@ DocsData = { "Meteor.loggingIn": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 31, + "lineno": 54, "locus": "Client", "longname": "Meteor.loggingIn", "memberof": "Meteor", @@ -3566,6 +3684,15 @@ DocsData = { ] } }, + { + "description": "

String of the kind of prompt(s) to always show. Valid options are "consent", "none", "select_account" or a combination. i.e. "select_account+consent". Currently only supported with Google.

", + "name": "prompt", + "type": { + "names": [ + "String" + ] + } + }, { "description": "

An email address that the external service will use to pre-fill the login prompt. Currently only supported with Meteor developer accounts.

", "name": "userEmail", @@ -3655,7 +3782,7 @@ DocsData = { "Meteor.logout": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 237, + "lineno": 255, "locus": "Client", "longname": "Meteor.logout", "memberof": "Meteor", @@ -3679,7 +3806,7 @@ DocsData = { "Meteor.logoutOtherClients": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 253, + "lineno": 278, "locus": "Client", "longname": "Meteor.logoutOtherClients", "memberof": "Meteor", @@ -3703,7 +3830,7 @@ DocsData = { "Meteor.methods": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1457, + "lineno": 1504, "locus": "Anywhere", "longname": "Meteor.methods", "memberof": "Meteor", @@ -3726,7 +3853,7 @@ DocsData = { "Meteor.onConnection": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1318, + "lineno": 1365, "locus": "Server", "longname": "Meteor.onConnection", "memberof": "Meteor", @@ -3749,7 +3876,7 @@ DocsData = { "Meteor.publish": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1392, + "lineno": 1439, "locus": "Server", "longname": "Meteor.publish", "memberof": "Meteor", @@ -3781,7 +3908,7 @@ DocsData = { "Meteor.reconnect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1005, + "lineno": 1007, "locus": "Client", "longname": "Meteor.reconnect", "memberof": "Meteor", @@ -3913,7 +4040,7 @@ DocsData = { "Meteor.status": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 993, + "lineno": 995, "locus": "Client", "longname": "Meteor.status", "memberof": "Meteor", @@ -3968,9 +4095,9 @@ DocsData = { "summary": "Subscribe to a record set. Returns a handle that provides\n`stop()` and `ready()` methods." }, "Meteor.user": { - "filepath": "accounts-base/accounts_client.js", + "filepath": "accounts-base/accounts_common.js", "kind": "function", - "lineno": 42, + "lineno": 59, "locus": "Anywhere but publish functions", "longname": "Meteor.user", "memberof": "Meteor", @@ -3980,23 +4107,10 @@ DocsData = { "scope": "static", "summary": "Get the current user record, or `null` if no user is logged in. A reactive data source." }, - "Meteor.userId": { - "filepath": "accounts-base/accounts_client.js", - "kind": "function", - "lineno": 11, - "locus": "Anywhere but publish functions", - "longname": "Meteor.userId", - "memberof": "Meteor", - "name": "userId", - "options": [], - "params": [], - "scope": "static", - "summary": "Get the current user id, or `null` if no user is logged in. A reactive data source." - }, "Meteor.users": { - "filepath": "accounts-base/accounts_common.js", + "filepath": "accounts-base/globals_server.js", "kind": "member", - "lineno": 141, + "lineno": 16, "locus": "Anywhere", "longname": "Meteor.users", "memberof": "Meteor", @@ -4115,7 +4229,7 @@ DocsData = { "Mongo.Collection#allow": { "filepath": "mongo/collection.js", "kind": "function", - "lineno": 768, + "lineno": 776, "locus": "Server", "longname": "Mongo.Collection#allow", "memberof": "Mongo.Collection", @@ -4165,7 +4279,7 @@ DocsData = { "Mongo.Collection#deny": { "filepath": "mongo/collection.js", "kind": "function", - "lineno": 780, + "lineno": 788, "locus": "Server", "longname": "Mongo.Collection#deny", "memberof": "Mongo.Collection", @@ -4430,6 +4544,32 @@ DocsData = { "scope": "instance", "summary": "Insert a document in the collection. Returns its unique _id." }, + "Mongo.Collection#rawCollection": { + "filepath": "mongo/collection.js", + "kind": "function", + "lineno": 645, + "locus": "Server", + "longname": "Mongo.Collection#rawCollection", + "memberof": "Mongo.Collection", + "name": "rawCollection", + "options": [], + "params": [], + "scope": "instance", + "summary": "Returns the [`Collection`](http://mongodb.github.io/node-mongodb-native/1.4/api-generated/collection.html) object corresponding to this collection from the [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`." + }, + "Mongo.Collection#rawDatabase": { + "filepath": "mongo/collection.js", + "kind": "function", + "lineno": 657, + "locus": "Server", + "longname": "Mongo.Collection#rawDatabase", + "memberof": "Mongo.Collection", + "name": "rawDatabase", + "options": [], + "params": [], + "scope": "instance", + "summary": "Returns the [`Db`](http://mongodb.github.io/node-mongodb-native/1.4/api-generated/db.html) object corresponding to this collection's database connection from the [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`." + }, "Mongo.Collection#remove": { "filepath": "mongo/collection.js", "kind": "function", @@ -4598,7 +4738,7 @@ DocsData = { "filepath": "mongo/collection.js", "instancename": "cursor", "kind": "class", - "lineno": 671, + "lineno": 679, "longname": "Mongo.Cursor", "memberof": "Mongo", "name": "Cursor", @@ -4766,7 +4906,7 @@ DocsData = { "Mongo.ObjectID": { "filepath": "mongo/collection.js", "kind": "class", - "lineno": 664, + "lineno": 672, "locus": "Anywhere", "longname": "Mongo.ObjectID", "memberof": "Mongo", @@ -5047,6 +5187,16 @@ DocsData = { "String" ] } + }, + { + "description": "

Options that will be passed to build\nplugins. For example, for JavaScript files, you can pass {bare: true}\nto not wrap the individual file in its own closure.

", + "name": "fileOptions", + "optional": true, + "type": { + "names": [ + "Object" + ] + } } ], "scope": "instance", @@ -5421,7 +5571,7 @@ DocsData = { "filepath": "ddp-server/livedata_server.js", "instancename": "this", "kind": "class", - "lineno": 860, + "lineno": 907, "longname": "Subscription", "name": "Subscription", "options": [], @@ -5431,7 +5581,7 @@ DocsData = { "Subscription#added": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1148, + "lineno": 1195, "locus": "Server", "longname": "Subscription#added", "memberof": "Subscription", @@ -5472,7 +5622,7 @@ DocsData = { "Subscription#changed": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1166, + "lineno": 1213, "locus": "Server", "longname": "Subscription#changed", "memberof": "Subscription", @@ -5513,7 +5663,7 @@ DocsData = { "Subscription#connection": { "filepath": "ddp-server/livedata_server.js", "kind": "member", - "lineno": 870, + "lineno": 917, "locus": "Server", "longname": "Subscription#connection", "memberof": "Subscription", @@ -5524,7 +5674,7 @@ DocsData = { "Subscription#error": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1091, + "lineno": 1138, "locus": "Server", "longname": "Subscription#error", "memberof": "Subscription", @@ -5547,7 +5697,7 @@ DocsData = { "Subscription#onStop": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1123, + "lineno": 1170, "locus": "Server", "longname": "Subscription#onStop", "memberof": "Subscription", @@ -5570,7 +5720,7 @@ DocsData = { "Subscription#ready": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1199, + "lineno": 1246, "locus": "Server", "longname": "Subscription#ready", "memberof": "Subscription", @@ -5583,7 +5733,7 @@ DocsData = { "Subscription#removed": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1182, + "lineno": 1229, "locus": "Server", "longname": "Subscription#removed", "memberof": "Subscription", @@ -5615,7 +5765,7 @@ DocsData = { "Subscription#stop": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1109, + "lineno": 1156, "locus": "Server", "longname": "Subscription#stop", "memberof": "Subscription", @@ -5628,7 +5778,7 @@ DocsData = { "Subscription#userId": { "filepath": "ddp-server/livedata_server.js", "kind": "member", - "lineno": 912, + "lineno": 959, "locus": "Server", "longname": "Subscription#userId", "memberof": "Subscription", @@ -6372,7 +6522,7 @@ DocsData = { "filepath": "accounts-base/accounts_client.js", "ishelper": "true", "kind": "member", - "lineno": 353, + "lineno": 389, "longname": "currentUser", "name": "currentUser", "scope": "global", @@ -6382,7 +6532,7 @@ DocsData = { "filepath": "accounts-base/accounts_client.js", "ishelper": "true", "kind": "member", - "lineno": 363, + "lineno": 399, "longname": "loggingIn", "name": "loggingIn", "scope": "global", diff --git a/docs/client/full-api/api/accounts.md b/docs/client/full-api/api/accounts.md index 130867ee5e..aacc82e177 100644 --- a/docs/client/full-api/api/accounts.md +++ b/docs/client/full-api/api/accounts.md @@ -401,14 +401,14 @@ client, no arguments are passed.

Rate Limiting

-By default, there are rules added to the [`DDPRateLimiter`](#DDPRateLimiter) +By default, there are rules added to the [`DDPRateLimiter`](#ddpratelimiter) that rate limit logins, new user registration and password reset calls to a -limit of 5 requests per 10 seconds per IP address. These are a basic solution +limit of 5 requests per 10 seconds per session. These are a basic solution to dictionary attacks where a malicious user attempts to guess the passwords of legitimate users by attempting all possible passwords. These rate limiting rules can be removed by calling `Accounts.removeDefaultRateLimit()`. Please see the -[`DDPRateLimiter`](#DDPRateLimiter) docs for more information. +[`DDPRateLimiter`](#ddpratelimiter) docs for more information. {{/template}} diff --git a/docs/client/full-api/api/methods.md b/docs/client/full-api/api/methods.md index 4471decb94..a100261d84 100644 --- a/docs/client/full-api/api/methods.md +++ b/docs/client/full-api/api/methods.md @@ -182,7 +182,22 @@ options about how the client executes the method.

DDPRateLimiter

-A rate limiter added directly to DDP. The DDPRateLimiter allows you to add rules to limit calls by one or more of user IDs, IP addresses, method names and/or subscription names. The rate limiter is called on every method and subscription invocation. A default rule of limiting 'login' attempts to 5 calls every 10 seconds per IP address has been added to the [`Accounts base package`](#accounts_api). The rule can be removed by calling [`Accounts.removeDefaultRateLimit()`]. +The DDPRateLimiter allows users to add rules to limit calls to Meteor methods +and subscriptions by one or more of user IDs, IP addresses, sessions, and +method & subscription names. The rate limiter is called on every method and +subscription invocation. A default rule of limiting login, password reset and +new user creation attempts to 5 calls every 10 seconds per session has been +added to the [`accounts package`](#accounts_api). The rule can be removed by +calling `Accounts.removeDefaultRateLimit()`. + +The DDPRateLimiter is configured with a set of rules. Each rule is a set of +keys to be inspected with filters on those keys to specify all DDP messages +that satisfy the rule. Each of these possible messages that satisfy the rule +is given a bucket by creating a unique string composed of all the keys in the +rule and the values from the message. After each rule's specified time +interval, all the buckets are deleted. A rate limit is said to have been hit +when a bucket has reached the rule's capacity, at which point errors will be +returned for that input until the buckets are reset. {{> autoApiBox "DDPRateLimiter.addRule"}} {{> autoApiBox "DDPRateLimiter.removeRule"}} diff --git a/docs/client/full-api/tableOfContents.js b/docs/client/full-api/tableOfContents.js index 2e7827d304..60f4b7a025 100644 --- a/docs/client/full-api/tableOfContents.js +++ b/docs/client/full-api/tableOfContents.js @@ -40,7 +40,8 @@ var toc = [ {instance: "this", name: "stop", id: "publish_stop"}, {instance: "this", name: "connection", id: "publish_connection"} ], - "Meteor.subscribe" + "Meteor.subscribe", + {name: "DDPRateLimiter", id: "ddpratelimiter"} ], {name: "Methods", id: "methods_header"}, [ @@ -54,7 +55,7 @@ var toc = [ "Meteor.Error", "Meteor.call", "Meteor.apply", - {name: "DDPRateLimiter", id: "ddpratelimiter"}, + {name: "DDPRateLimiter", id: "ddpratelimiter"} ], {name: "Check", id: "check_package"}, [ diff --git a/docs/client/names.json b/docs/client/names.json index 15cb12f716..937e5b1eb0 100644 --- a/docs/client/names.json +++ b/docs/client/names.json @@ -1,16 +1,10 @@ [ + "Accounts", "Accounts", "Accounts.changePassword", - "Accounts.config", "Accounts.createUser", "Accounts.emailTemplates", "Accounts.forgotPassword", - "Accounts.onCreateUser", - "Accounts.onEmailVerificationLink", - "Accounts.onEnrollmentLink", - "Accounts.onLogin", - "Accounts.onLoginFailure", - "Accounts.onResetPasswordLink", "Accounts.resetPassword", "Accounts.sendEnrollmentEmail", "Accounts.sendResetPasswordEmail", @@ -18,9 +12,17 @@ "Accounts.setPassword", "Accounts.ui", "Accounts.ui.config", - "Accounts.validateLoginAttempt", - "Accounts.validateNewUser", "Accounts.verifyEmail", + "Ap.config", + "Ap.onCreateUser", + "Ap.onEmailVerificationLink", + "Ap.onEnrollmentLink", + "Ap.onLogin", + "Ap.onLoginFailure", + "Ap.onResetPasswordLink", + "Ap.userId", + "Ap.validateLoginAttempt", + "Ap.validateNewUser", "App", "App.accessRule", "App.configurePlugin", @@ -86,6 +88,9 @@ "DDPCommon.MethodInvocation#setUserId", "DDPCommon.MethodInvocation#unblock", "DDPCommon.MethodInvocation#userId", + "DDPRateLimiter.addRule", + "DDPRateLimiter.removeRule", + "DDPRateLimiter.setErrorMessage", "EJSON", "EJSON.CustomType", "EJSON.CustomType#clone", @@ -138,7 +143,7 @@ "Meteor.status", "Meteor.subscribe", "Meteor.user", - "Meteor.userId", + "Meteor.users", "Meteor.users", "Meteor.wrapAsync", "Mongo", @@ -148,6 +153,8 @@ "Mongo.Collection#find", "Mongo.Collection#findOne", "Mongo.Collection#insert", + "Mongo.Collection#rawCollection", + "Mongo.Collection#rawDatabase", "Mongo.Collection#remove", "Mongo.Collection#update", "Mongo.Collection#upsert", diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index bd03fa0a2b..ed75272c83 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,8 +1,9 @@ // Rate Limiter built into DDP with a default error message. DDPRateLimiter = { errorMessage : function (rateLimitResult) { - return "Error, too many requests. Please slow down. You must wait " + Math.ceil( - rateLimitResult.timeToReset / 1000) + " seconds before trying again."; + return "Error, too many requests. Please slow down. You must wait " + + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before trying " + + "again."; }, rateLimiter : new RateLimiter() } @@ -15,7 +16,9 @@ DDPRateLimiter.getErrorMessage = function (rateLimitResult) { } /** * @summary Update the error message returned when call is rate limited. - * @param {string|function} message Function that takes an object with a timeToReset field that specifies the first time a method or subscription call is allowed + * @param {string|function} message Function that takes an object with a + * timeToReset field that specifies the first time a method or subscription + * call is allowed. */ DDPRateLimiter.setErrorMessage = function (message) { this.errorMessage = message; @@ -23,9 +26,14 @@ DDPRateLimiter.setErrorMessage = function (message) { /** * @summary Adds a rule with a number of requests allowed per time interval. - * @param {object} rule Rule should be an object where the keys are one or more of `['userId', 'ipAddr', 'type', 'name'] ` and the values are either `null`, a primitive, or a function that returns true if the rule should apply to the provided input for that key. - * @param {integer} numRequests number of requests allowed per time interval. Default = 10. - * @param {integer} timeInterval time interval in milliseconds after which rule's counters are reset. Default = 1000. + * @param {object} rule Rule should be an object where the keys are one or + * more of `['userId', 'ipAddr', 'type', 'name', 'sessionId'] ` and the values + * are either `null`, a primitive, or a function that returns true if the rule + * should apply to the provided input for that key. + * @param {integer} numRequests number of requests allowed per time interval. + * Default = 10. + * @param {integer} timeInterval time interval in milliseconds after which + * rule's counters are reset. Default = 1000. * @return {string} Returns unique `ruleId` that can be passed to `removeRule`. */ DDPRateLimiter.addRule = function (rule, numRequests, timeInterval) { @@ -41,6 +49,8 @@ DDPRateLimiter.removeRule = function (id) { return this.rateLimiter.removeRule(id); } +// This is accessed inside livedata_server.js, but shouldn't be called by any +// user. DDPRateLimiter._increment = function (input) { this.rateLimiter.increment(input); } diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 1b2886636a..52d68da825 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -580,12 +580,17 @@ _.extend(Session.prototype, { // reconnect. return; + // XXX It'd be much better if we had generic hooks where any package can + // hook into subscription handling, but in the mean while we special case + // ddp-rate-limiter package. This is also done for weak requirements to + // add the ddp-rate-limiter package in case we don't have Accounts. A + // user trying to use the ddp-rate-limiter must explicitly require it. if (Package['ddp-rate-limiter']) { var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; var rateLimiterInput = { userId: self.userId, ipAddr: self.connectionHandle.clientAddress, - type: msg.msg, + type: "sub", name: msg.name, sessionId: self.id }; @@ -667,12 +672,16 @@ _.extend(Session.prototype, { }); try { + // XXX It'd be better if we could hook into method handlers better but + // for now, we need to check if the ddp-rate-limiter exists since we + // have a weak requirement for the ddp-rate-limiter package to be added + // to our application. if (Package['ddp-rate-limiter']) { var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; var rateLimiterInput = { userId: self.userId, ipAddr: self.connectionHandle.clientAddress, - type: msg.msg, + type: "method", name: msg.method, sessionId: self.id }; diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index dead5df21c..7ad767d588 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -7,25 +7,71 @@ // them against a set of "rules". Rules specify which inputs they match by // running configurable "matcher" functions on keys in the event object). A // `check` method returns whether this input should be allowed, the time -// until next reset and the number of calls for this input left. +// until the rate limit is reset and the number of calls remaining for this +// input. The number of calls that have currently occurred are kept in a +// dictionary of counters stored inside of each rule, keyed by the call that +// triggered the rule. // Default time interval (in milliseconds) to reset rate limit counters var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; -// Default number of requets allowed per time interval +// Default number of events allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; + +// Each rule is composed of an `id`, an options object that contains the +// `intervalTime` in milliseconds after which the rule is reset, and +// `numRequestsAllowed` in the specified interval time, a dictionary of +// `matchers` whose keys are searched for in the input provided to determine +// if there is a match. If the values match, then the rules counters are +// incremented. Values can be objects or they can be functions that return +// a boolean of whether the provided input matches. For example, if we only +// want to match all even ids, plus any other fields, we could have a rule +// that included a key-value pair as follows: +// { +// ... +// id: function (id) { +// return id % 2 === 0; +// }, +// ... +// } +// A rule is only said to apply to a given input if every key in the matcher +// matches to the input values. There is also a dictionary of `counters` that +// store the current state of inputs and number of times they've been passed +// to the rate limiter. We want to rate limit specific values for specific keys +// in the input objects we inspect. Say a rule inspects a methodName property. +// We want to count how many times each different method was called. So we +// generate a unique string key (to be used as keys in a counters object) to +// represent each specific methodName. But since a rule can inspect multiple +// properties, we need to concatenate the differnet input key names with their +// values. For example, if we had a rule with matchers as such: +// { +// userId: function(userId) { +// return true; +// }, +// methodName: 'hello' +// } +// and we were passed an input as follows: +// { +// userId: 'meteor' +// methodName: 'hello' +// } +// The key generated would be 'userIdmeteormethodNamehello'. +// These counters are checked on every invocation to determine whether a rate +// limit has been reached. var Rule = function (options, matchers) { var self = this; self.id = Random.id(); - // Options contains the timeToReset and intervalTime + // Options contains the numRequestsAllowed and intervalTime + // - numRequestsAllowed - number of requests allowed per interval + // - intervalTime - time before rate limit counters are reset in milliseconds self.options = options; // Dictionary of keys and all values that match for each key // The values can either be null (optional), a primitive or a function // that returns boolean of whether the provided input's value matches for // this key - self.matchers = matchers; + self._matchers = matchers; self._lastResetTime = new Date().getTime(); @@ -36,33 +82,28 @@ var Rule = function (options, matchers) { _.extend(Rule.prototype, { // Determine if this rule applies to the given input by comparing all // rule.matchers. If the match fails, search short circuits instead of - // iterating through all matchers. The order of the input doesn't matter, - // it just must contain the appropriate keys and their respective values - // must be allowed by the matcher. + // iterating through all matchers. match: function (input) { var self = this; var ruleMatches = true; - _.find(self.matchers, function (value, key) { - if (value !== null) { + return _.every(self._matchers, function (matcher, key) { + if (matcher !== null) { if (!(_.has(input,key))) { - ruleMatches = false; - return true; + return false; } else { - if (typeof value === 'function') { - if (!(value(input[key]))) { - ruleMatches = false; - return true; + if (typeof matcher === 'function') { + if (!(matcher(input[key]))) { + return false; } } else { - if (value !== input[key]) { - ruleMatches = false; - return true; + if (matcher !== input[key]) { + return false; } } } } + return true; }); - return ruleMatches; }, // Generates unique key string for provided input by concatenating all the @@ -71,10 +112,10 @@ _.extend(Rule.prototype, { _generateKeyString: function (input) { var self = this; var returnString = ""; - _.each(self.matchers, function (value, key) { - if (value !== null) { - if (typeof value === 'function') { - if (value(input[key])) { + _.each(self._matchers, function (matcher, key) { + if (matcher !== null) { + if (typeof matcher === 'function') { + if (matcher(input[key])) { returnString += key + input[key]; } } else { @@ -85,8 +126,8 @@ _.extend(Rule.prototype, { return returnString; }, - // Applies the provided input and returns the key string, time since last - // reset and time to next reset. + // Applies the provided input and returns the key string, time since counters + // were last reset and time to next reset. apply: function (input) { var self = this; var keyString = self._generateKeyString(input); @@ -110,12 +151,12 @@ _.extend(Rule.prototype, { } }); -// Initialize rules, ruleId, and invocations to be empty +// Initialize rules to be an empty dictionary. RateLimiter = function () { var self = this; // Dictionary of all rules associated with this RateLimiter, keyed by their - // id. Each rule object stores the rule pattern, number of requests allowed, + // id. Each rule object stores the rule pattern, number of events allowed, // last reset time and the rule reset interval in milliseconds. self.rules = {}; } @@ -126,9 +167,10 @@ RateLimiter = function () { * that match to rules * @return {object} Returns object of following structure * { 'allowed': boolean - is this input allowed - * 'timeToReset': integer - returns time to reset in milliseconds - * 'numInvocationsLeft': integer - returns number of calls left before limit - * is reached + * 'timeToReset': integer | Infinity - returns time until counters are reset + * in milliseconds + * 'numInvocationsLeft': integer | Infinity - returns number of calls left + * before limit is reached * } * If multiple rules match, the least number of invocations left is returned. * If the rate limit has been reached, the longest timeToReset is returned. @@ -149,7 +191,8 @@ RateLimiter.prototype.check = function (input) { if (ruleResult.timeToNextReset < 0) { // Reset all the counters since the rule has reset rule.resetCounter(); - ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + ruleResult.timeSinceLastReset = new Date().getTime() - + rule._lastResetTime; ruleResult.timeToNextReset = rule.options.intervalTime; numInvocations = 0; } @@ -181,42 +224,6 @@ RateLimiter.prototype.check = function (input) { return reply; } -// Each rule is composed of an `id`, an options object that contains the -// `intervalTime` after which the rule is reset, and `numRequestsAllowed` in -// the specified interval time, a dictionary of `matchers` whose keys are -// searched for in the input provided to determine if there is a match. If the -// values match, then the rules counters are incremented. Values can be objects -// or they can be functions that return a boolean of whether the provided input -// matches. For example, if we only want to match all even ids, plusany other -// fields, we could have a rule that included a key-value pair as follows: -// { -// ... -// id: function (id) { -// return id % 2 === 0; -// }, -// ... -// } -// A rule is only said to apply to a given input if every key in the matcher -// matchesto the input values. There is also a dictionary of `counters` that -// store the current state of inputs and number of times they've been passed -// to the rate limiter. Unique keys are made per input per rule that create -// a concatenated string of all keys in the rule with the values from the -// input. For example, if we had a rule with matchers as such: -// { -// userId: function(userId) { -// return true; -// }, -// methodName: 'hello' -// } -// and we were passed an input as follows: -// { -// userId: 'meteor' -// methodName: 'hello' -// } -// The key generated would be 'userIdmeteormethodNamehello'. -// These counters are checked on every invocation to determine whether a rate -// limit has been reached. - /** * Adds a rule to dictionary of rules that are checked against on every call. * Only inputs that pass all of the rules will be allowed and order doesn't @@ -226,9 +233,10 @@ RateLimiter.prototype.check = function (input) { * Each attribute's value can either be a value, a function or null. All * functions must return a boolean of whether the input is matched by that * attribute's rule or not - * @param {integer} numRequestsAllowed Number of requests allowed per interval - * @param {integer} intervalTime Number of milliseconds before interval - * is reset + * @param {integer} numRequestsAllowed Optional. Number of events allowed per + * interval. Default = 10. + * @param {integer} intervalTime Optional. Number of milliseconds before + * rule's counters are reset. Default = 1000. * @return {string} Returns unique rule id */ RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, @@ -276,12 +284,9 @@ RateLimiter.prototype.increment = function (input) { RateLimiter.prototype._findAllMatchingRules = function (input) { var self = this; - var matchingRules = []; - _.each(self.rules, function(rule) { - if (rule.match(input)) - matchingRules.push(rule); + return _.filter(self.rules, function(rule) { + return rule.match(input); }); - return matchingRules; } /** * Provides a mechanism to remove rules from the rate limiter. Returns boolean From 13331009d03b8474493857c198edbeac33dabbbe Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 14 Jul 2015 14:56:59 -0700 Subject: [PATCH 26/34] Making more code fixes and documentation update --- docs/client/data.js | 6 +- packages/ddp-rate-limiter/README.md | 63 ++++++++++++++ .../ddp-rate-limiter-server-tests.js | 23 ----- .../ddp-rate-limiter-test-service.js | 24 ++++++ .../ddp-rate-limiter-tests.js | 15 ++-- packages/ddp-rate-limiter/ddp-rate-limiter.js | 6 +- packages/ddp-rate-limiter/package.js | 4 +- packages/rate-limit/README.md | 72 ++++++++++++++++ packages/rate-limit/rate-limit.js | 83 ++++--------------- 9 files changed, 197 insertions(+), 99 deletions(-) create mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js diff --git a/docs/client/data.js b/docs/client/data.js index 59891e96e9..1cd66ad301 100644 --- a/docs/client/data.js +++ b/docs/client/data.js @@ -2379,7 +2379,7 @@ DocsData = { "DDPRateLimiter.addRule": { "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", "kind": "function", - "lineno": 39, + "lineno": 40, "longname": "DDPRateLimiter.addRule", "memberof": "DDPRateLimiter", "name": "addRule", @@ -2424,12 +2424,12 @@ DocsData = { } ], "scope": "static", - "summary": "Adds a rule with a number of requests allowed per time interval." + "summary": "Adds a rule with a number of requests allowed per time interval.\nReturns a `ruleId` string that is used as the input to `removeRule()`." }, "DDPRateLimiter.removeRule": { "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", "kind": "function", - "lineno": 48, + "lineno": 49, "longname": "DDPRateLimiter.removeRule", "memberof": "DDPRateLimiter", "name": "removeRule", diff --git a/packages/ddp-rate-limiter/README.md b/packages/ddp-rate-limiter/README.md index e69de29bb2..f3012cf903 100644 --- a/packages/ddp-rate-limiter/README.md +++ b/packages/ddp-rate-limiter/README.md @@ -0,0 +1,63 @@ +DDP Rate Limiter package +=== + +A rate limiter added directly to DDP that provides an API to add rules to +Meteor methods and collections. + +### Pre-defined Defaults + +If the `accounts-base` package is added to your +project, there are default rules added to limit logins, new user registration +and password resets calls to a limit of 5 requests per 10 seconds per session. +These provide a basic solution to dictionary attacks where a malicious user +attempts to guess the passwords of legitimate users by attempting all possible +passwords. To remove the default rule, a user can add +`Accounts.removeDefaultRateLimit()` to any server side code and the default +rate limit will be removed. + +### Configuration + +The `DDPRateLimiter` is configured with a set of rules. Each rule is a set of +keys to be inspected with filters on those keys to specify all DDP messages +that satisfy the rule. Each of these possible messages that satisfy the rule +is given a bucket by creating a unique string composed of all the keys in the +rule and the values from the message. After each rule's specified time +interval, all the buckets are deleted. A rate limit is said to have been hit +when a bucket has reached the rule's capacity, at which point errors will be +returned for that input until the buckets are reset. + +A rule is defined as a set of key-value pairs where the keys are one or more +of `userId`, `ipAddr`, `type`, `name`, and `sessionId`. The values can either +be null, primitives or functions. When you want to rate limit some users but +not others, a rule can match invocations using a function in a way that is +determined at run time based on the database or other data. In our example, we +check the database to avoid rate limiting admin users. + +When we add the rule to DDPRateLimiter, we also specify the number of messages +that we allow and the time interval on which the rate limit is reset. + +### Example Usage + +To wait on more events before releasing the launch screen, call `var handle = +LaunchScreen.hold()` in the top-level of the client code of your app, and when +you're ready to show the launch screen, call `handle.release()`. + +For example, let's add a rule for all login methods that restrict all users +but admins to 5 login attempts per second: + +```javascript +// Add a rule that limits all users except Admins to have 5 login +// attempts per second +var loginRule = { + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' +} +// Adds the rule with a limit of 5 requests / second +DDPRateLimiter.addRule(loginRule, 5, 1000); +``` + +For more information, check out the documentation on the [DDP Rate Limiter] +(http://docs.meteor.com/#ddpratelimiter). \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js index 1e742325f8..480c703c4f 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js @@ -1,26 +1,3 @@ -Meteor.methods({ - resetAndAddRuleToDDPRateLimiter : function(intervalTimeInMillis) { - DDPRateLimiter.rateLimiter.rules = {}; - this.ruleId = DDPRateLimiter.addRule({ - userId: null, - ipAddr: function() { - return true; - }, - type: 'method', - name: 'login' - }, 5, intervalTimeInMillis); - return this.ruleId; - }, - - removeRuleFromDDPRateLimiter : function(id) { - return DDPRateLimiter.removeRule(id); - }, - - printCurrentListOfRules : function () { - console.log('Current list of rules :', DDPRateLimiter.rateLimiter.rules); - } -}); - Tinytest.add("Test rule gets added and removed from Accounts_base", function(test) { // Test that DDPRateLimiter rules is not empty test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js new file mode 100644 index 0000000000..65407426c4 --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -0,0 +1,24 @@ +Meteor.methods({ + // Resets the DDPRateLimiter and removes all rules. Adds in a new rule with + // the specific intervalTime as passed in to speed up testing. + resetAndAddRuleToDDPRateLimiter : function(intervalTimeInMillis) { + DDPRateLimiter.rateLimiter.rules = {}; + this.ruleId = DDPRateLimiter.addRule({ + userId: null, + ipAddr: function() { + return true; + }, + type: 'method', + name: 'login' + }, 5, intervalTimeInMillis); + return this.ruleId; + }, + // Server side method to remove rule from DDP Rate Limiter + removeRuleFromDDPRateLimiter : function(id) { + return DDPRateLimiter.removeRule(id); + }, + // Print all the server rules for debugging purposes. + printCurrentListOfRules : function () { + console.log('Current list of rules :', DDPRateLimiter.rateLimiter.rules); + } +}); \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 0cee40e2f2..c0d40074a0 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,3 +1,4 @@ +// Test that we do hit the default login rate limit. testAsyncMulti("passwords - basic login with password", [ function (test, expect) { var self = this; @@ -47,8 +48,10 @@ testAsyncMulti("passwords - basic login with password", [ })); } ]); - -testAsyncMulti("test removing rule with rateLimited client lets them send new queries", [ +// When we have a rate limited client and we remove the rate limit rule, +// all requests should be allowed immediately afterwards. +testAsyncMulti("test removing rule with rateLimited client lets them send new + queries", [ function(test, expect) { var self = this; // Setup the rate limiter rules @@ -91,9 +94,11 @@ testAsyncMulti("test removing rule with rateLimited client lets them send new qu function (error) { test.equal(error.error, 'too-many-requests'); })); - // By removing the rule from the DDP rate limiter, we no longer restrict them even though they were rate limited - Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { - test.equal(result,true); + // By removing the rule from the DDP rate limiter, we no longer restrict + // them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); })); // for (var i = 0; i < 10; i++) { diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index ed75272c83..71f817d548 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,4 +1,5 @@ -// Rate Limiter built into DDP with a default error message. +// Rate Limiter built into DDP with a default error message. See README or +// online documentation for more details. DDPRateLimiter = { errorMessage : function (rateLimitResult) { return "Error, too many requests. Please slow down. You must wait " + @@ -15,7 +16,7 @@ DDPRateLimiter.getErrorMessage = function (rateLimitResult) { return this.errorMessage; } /** - * @summary Update the error message returned when call is rate limited. + * @summary Update the error message returned when a call is rate limited. * @param {string|function} message Function that takes an object with a * timeToReset field that specifies the first time a method or subscription * call is allowed. @@ -26,6 +27,7 @@ DDPRateLimiter.setErrorMessage = function (message) { /** * @summary Adds a rule with a number of requests allowed per time interval. + * Returns a `ruleId` string that is used as the input to `removeRule()`. * @param {object} rule Rule should be an object where the keys are one or * more of `['userId', 'ipAddr', 'type', 'name', 'sessionId'] ` and the values * are either `null`, a primitive, or a function that returns true if the rule diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index 7c2fb06678..e58d994b52 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -2,7 +2,8 @@ Package.describe({ name: 'ddp-rate-limiter', version: '0.0.1', // Brief, one-line summary of the package. - summary: '', + summary: 'The DDPRateLimiter allows users to add rate limits to DDP' + + ' methods and subscriptions.', // URL to the Git repository containing the source code for this package. git: '', // By default, Meteor will default to using README.md for documentation. @@ -23,6 +24,7 @@ Package.onTest(function(api) { 'accounts-base', 'random', 'email', 'underscore', 'check', 'ddp']); api.use('ddp-rate-limiter'); + api.addFiles('ddp-rate-limiter-test-service.js', 'server'); api.addFiles('ddp-rate-limiter-server-tests.js', 'server'); api.addFiles('ddp-rate-limiter-tests.js', 'client'); }); diff --git a/packages/rate-limit/README.md b/packages/rate-limit/README.md index e69de29bb2..636adf2a62 100644 --- a/packages/rate-limit/README.md +++ b/packages/rate-limit/README.md @@ -0,0 +1,72 @@ +# Rate Limit +=== +A Rate Limiter is a general rate limiting object that stores rules and +determines whether inputs are allowed based on the rules. There is also a +general structure of Rules which contain all the internal state of a rule. + +Rate limiters analyze a series of "inputs" (which are POJOs) by running them +against a set of "rules." Rules specify which inputs they match by running +configurable "matcher" functions on keys in the input object. A `check` method +returns whether this input should be allowed, the time until the rate limit is +reset and the number of calls remaining for this input. The count of processed +inputs are kept in a dictionary of counters stored inside each rule, keyed by +a unique string composed of the input that matched to the rule. + +### Rule Structure + +Each rule is composed of an `id`, an options object that contains the ` +intervalTime` in milliseconds after which the rule is reset and +`numRequestsAllowed` in the specified interval time, a dictionary of `matchers` +whose keys are searched for in the input to determine if there is a match. If +the values match, then the rule's counters are incremented. Values can be +objects or they can be functions that return a boolean of whether the +provided input matches. For example, if we only want to match all even ids, +plus any other fields, we could have a rule that included a key-value pair as +follows: + +```javascript +{ + ... + id: function (id) { + return id % 2 === 0; + }, + ... +} +``` +A rule is only said to apply to a given input if every key in the matcher +matches to the input values. There is also a dictionary of `counters` that +store the current state of inputs and number of times they've been passed to +the rate limiter. Each rule defines a domain of keys and values that it +applies to, and we want to have a unique way of recording each input provided +to the Rate Limiter that matches to the rule. Say a rule inspects a methodName +property and a username property. We want to count how many times each user +called a certain method and restrict them to a certain number of calls per +user defined time frame. So we generate a unique string key (to be used as +keys in a counters object) to represent each specific methodName + user +combination. Since this rule applies to multiple user, we need to concatenate +the differnet input key names with their values. For example, if we had a rule +with matchers as such: + +```javascript +{ + username: function(username) { + return true; + }, + methodName: 'hello' +} +``` +and we were passed an input as follows: + +``` +{ + username: 'meteor' + methodName: 'hello' +} +``` +The key generated would be 'usernamemeteormethodNamehello'. This is guaranteed +to be unique for this username+methodName combination. These keys are cleared +every time the intervalTime is passed, at which point we delete the current +dictionary of counters we store. Every time a rule matches to an input, we +determine the unique key string and check if it's counters have exceeded the +allowed amounts, returning an error to the user letting them know that a rate +limit has been reached. \ No newline at end of file diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 7ad767d588..9cbb595caa 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -1,76 +1,29 @@ -// This file contains two classes: -// * Rule - the general structure of rate limiter rules -// * RateLimiter - a general rate limiter that stores rules and determines -// whether inputs are allowed -// -// Rate limiters analyze a series of "inputs" (which are POJOs) by running -// them against a set of "rules". Rules specify which inputs they match by -// running configurable "matcher" functions on keys in the event object). A -// `check` method returns whether this input should be allowed, the time -// until the rate limit is reset and the number of calls remaining for this -// input. The number of calls that have currently occurred are kept in a -// dictionary of counters stored inside of each rule, keyed by the call that -// triggered the rule. - // Default time interval (in milliseconds) to reset rate limit counters var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; // Default number of events allowed per time interval var DEFAULT_REQUESTS_PER_INTERVAL = 10; - -// Each rule is composed of an `id`, an options object that contains the -// `intervalTime` in milliseconds after which the rule is reset, and -// `numRequestsAllowed` in the specified interval time, a dictionary of -// `matchers` whose keys are searched for in the input provided to determine -// if there is a match. If the values match, then the rules counters are -// incremented. Values can be objects or they can be functions that return -// a boolean of whether the provided input matches. For example, if we only -// want to match all even ids, plus any other fields, we could have a rule -// that included a key-value pair as follows: -// { -// ... -// id: function (id) { -// return id % 2 === 0; -// }, -// ... -// } -// A rule is only said to apply to a given input if every key in the matcher -// matches to the input values. There is also a dictionary of `counters` that -// store the current state of inputs and number of times they've been passed -// to the rate limiter. We want to rate limit specific values for specific keys -// in the input objects we inspect. Say a rule inspects a methodName property. -// We want to count how many times each different method was called. So we -// generate a unique string key (to be used as keys in a counters object) to -// represent each specific methodName. But since a rule can inspect multiple -// properties, we need to concatenate the differnet input key names with their -// values. For example, if we had a rule with matchers as such: -// { -// userId: function(userId) { -// return true; -// }, -// methodName: 'hello' -// } -// and we were passed an input as follows: -// { -// userId: 'meteor' -// methodName: 'hello' -// } -// The key generated would be 'userIdmeteormethodNamehello'. -// These counters are checked on every invocation to determine whether a rate -// limit has been reached. +// A rule is defined by an options object that contains two fields, +// `numRequestsAllowed` which is the number of events allowed per interval, and +// an `intervalTime` which is the amount of time in milliseconds before the +// rate limit restarts its internal counters, and by a matchers object. A +// matchers object is a POJO that contains a set of keys with values that +// define the entire set of inputs that match for each key. The values can +// either be null (optional), a primitive or a function that returns a boolean +// of whether the provided input's value matches for this key. +// +// Rules are uniquely assigned an `id` and they store a dictionary of counters, +// which are records used to keep track of inputs that match the rule. If a +// counter reaches the `numRequestsAllowed` within a given `intervalTime`, a +// rate limit is reached and future inputs that map to that counter will +// result in errors being returned to the client. var Rule = function (options, matchers) { var self = this; self.id = Random.id(); - // Options contains the numRequestsAllowed and intervalTime - // - numRequestsAllowed - number of requests allowed per interval - // - intervalTime - time before rate limit counters are reset in milliseconds + self.options = options; - // Dictionary of keys and all values that match for each key - // The values can either be null (optional), a primitive or a function - // that returns boolean of whether the provided input's value matches for - // this key self._matchers = matchers; self._lastResetTime = new Date().getTime(); @@ -226,8 +179,8 @@ RateLimiter.prototype.check = function (input) { /** * Adds a rule to dictionary of rules that are checked against on every call. - * Only inputs that pass all of the rules will be allowed and order doesn't - * matter. Returns unique rule id that can be passed to 'removeRule'. + * Only inputs that pass all of the rules will be allowed. Returns unique rule + * id that can be passed to `removeRule`. * @param {object} rule Input dictionary defining certain attributes and * rules associated with them. * Each attribute's value can either be a value, a function or null. All @@ -292,7 +245,7 @@ RateLimiter.prototype._findAllMatchingRules = function (input) { * Provides a mechanism to remove rules from the rate limiter. Returns boolean * about success. * @param {string} id Rule id returned from #addRule - * @return {boolean} Returns true if rule was found and deleted, else false. + * @return {boolean} Returns true if rule was found and deleted, else false. */ RateLimiter.prototype.removeRule = function (id) { var self = this; From 759b9a2c194baff91d71a71f70564f663fad0460 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 14 Jul 2015 15:27:08 -0700 Subject: [PATCH 27/34] Updating docs --- docs/client/full-api/api/methods.md | 15 +++++++++++++++ packages/accounts-base/accounts_rate_limit.js | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/client/full-api/api/methods.md b/docs/client/full-api/api/methods.md index a100261d84..b1c68a33da 100644 --- a/docs/client/full-api/api/methods.md +++ b/docs/client/full-api/api/methods.md @@ -199,6 +199,21 @@ interval, all the buckets are deleted. A rate limit is said to have been hit when a bucket has reached the rule's capacity, at which point errors will be returned for that input until the buckets are reset. +Here's example of defining a rule and adding it into the `DDPRateLimiter`: +```javascript +// Add a rule that limits all users except Admins to have 5 login attempts per second +var loginRule = { + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' +} +// Add the rule, setting the number of messages allowed at 5 with a time +// interval of 1000 milliseconds. +DDPRateLimiter.addRule(loginRule, 5, 1000); +``` + {{> autoApiBox "DDPRateLimiter.addRule"}} {{> autoApiBox "DDPRateLimiter.removeRule"}} {{> autoApiBox "DDPRateLimiter.setErrorMessage"}} diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index 9801b09f7a..bf4a0c249f 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -1,8 +1,8 @@ // Adds a default rate limiting rule to DDPRateLimiter and provides methods to remove it var Ap = AccountsCommon.prototype; // Add a default rule of limiting logins, creating new users and password reset -// to 5 times per 10 seconds by IP address. -// Stores the ruleId to provide option to remove the default rule. +// to 5 times per 10 seconds by session. +// Stores the ruleId to provide options to remove the default rule. Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ userId: null, ipAddr: null, From 6a525fbea7d0b8ab527ad1ae6af7c9f1e0289b5c Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 20 Jul 2015 14:00:12 -0700 Subject: [PATCH 28/34] Updated DDPRateLimiter structure per Avi's suggestions and modified tests. Updated documentation and default rate limit as well. --- docs/client/data.js | 30 +-- docs/client/full-api/api/methods.md | 48 ++-- packages/accounts-base/accounts_rate_limit.js | 13 +- packages/ddp-rate-limiter/README.md | 27 +- .../ddp-rate-limiter-server-tests.js | 13 +- .../ddp-rate-limiter-test-service.js | 55 ++-- .../ddp-rate-limiter-tests.js | 239 ++++++++++++------ packages/ddp-rate-limiter/ddp-rate-limiter.js | 98 ++++--- packages/ddp-rate-limiter/package.js | 2 +- packages/ddp-server/livedata_server.js | 11 +- packages/rate-limit/README.md | 22 +- packages/rate-limit/rate-limit.js | 5 +- 12 files changed, 340 insertions(+), 223 deletions(-) diff --git a/docs/client/data.js b/docs/client/data.js index 1cd66ad301..edd28c8b37 100644 --- a/docs/client/data.js +++ b/docs/client/data.js @@ -2379,18 +2379,18 @@ DocsData = { "DDPRateLimiter.addRule": { "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", "kind": "function", - "lineno": 40, + "lineno": 77, "longname": "DDPRateLimiter.addRule", "memberof": "DDPRateLimiter", "name": "addRule", "options": [], "params": [ { - "description": "

Rule should be an object where the keys are one or\nmore of ['userId', 'ipAddr', 'type', 'name', 'sessionId'] and the values\nare either null, a primitive, or a function that returns true if the rule\nshould apply to the provided input for that key.

", - "name": "rule", + "description": "

Matchers specify which events are counted towards a rate limit. A matcher\n is an object that has a subset of the same properties as the event objects\n described above. Each value in a matcher object is one of the following:

\n
    \n
  • a string: for the event to satisfy the matcher, this value must be equal\nto the value of the same property in the event object

    \n
  • \n
  • a function: for the event to satisfy the matcher, the function must\nevaluate to true when passed the value of the same property\nin the event object

    \n
  • \n
\n

Here's how events are counted: Each event that satisfies the matcher's\nfilter is mapped to a bucket. Buckets are uniquely determined by the\nevent object's values for all properties present in both the matcher and\nevent objects.

", + "name": "matcher", "type": { "names": [ - "object" + "Object" ] } }, @@ -2399,7 +2399,7 @@ DocsData = { "name": "numRequests", "type": { "names": [ - "integer" + "number" ] } }, @@ -2408,28 +2408,18 @@ DocsData = { "name": "timeInterval", "type": { "names": [ - "integer" - ] - } - } - ], - "returns": [ - { - "description": "

Returns unique ruleId that can be passed to removeRule.

", - "type": { - "names": [ - "string" + "number" ] } } ], "scope": "static", - "summary": "Adds a rule with a number of requests allowed per time interval.\nReturns a `ruleId` string that is used as the input to `removeRule()`." + "summary": "Add a rule that matches against a stream of events describing method or\nsubscription attempts. Each event is an object with the following properties:\n\n- `type`: Either \"method\" or \"subscription\"\n- `name`: The name of the method or subscription being called\n- `userId`: The user ID attempting the method or subscription\n- `connectionId`: A string representing the user's DDP connection\n- `ipAddr`: The IP address of the user\n\nReturns unique `ruleId` that can be passed to `removeRule`." }, "DDPRateLimiter.removeRule": { "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", "kind": "function", - "lineno": 49, + "lineno": 86, "longname": "DDPRateLimiter.removeRule", "memberof": "DDPRateLimiter", "name": "removeRule", @@ -2461,7 +2451,7 @@ DocsData = { "DDPRateLimiter.setErrorMessage": { "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", "kind": "function", - "lineno": 23, + "lineno": 25, "longname": "DDPRateLimiter.setErrorMessage", "memberof": "DDPRateLimiter", "name": "setErrorMessage", @@ -2479,7 +2469,7 @@ DocsData = { } ], "scope": "static", - "summary": "Update the error message returned when call is rate limited." + "summary": "Set error message text when method or subscription rate limit\nexceeded." }, "EJSON": { "filepath": "ejson/ejson.js", diff --git a/docs/client/full-api/api/methods.md b/docs/client/full-api/api/methods.md index b1c68a33da..ee127524af 100644 --- a/docs/client/full-api/api/methods.md +++ b/docs/client/full-api/api/methods.md @@ -182,39 +182,37 @@ options about how the client executes the method.

DDPRateLimiter

-The DDPRateLimiter allows users to add rules to limit calls to Meteor methods -and subscriptions by one or more of user IDs, IP addresses, sessions, and -method & subscription names. The rate limiter is called on every method and -subscription invocation. A default rule of limiting login, password reset and -new user creation attempts to 5 calls every 10 seconds per session has been -added to the [`accounts package`](#accounts_api). The rule can be removed by -calling `Accounts.removeDefaultRateLimit()`. +Customize rate limiting for methods and subscriptions. + +By default, `DDPRateLimiter` is configured with a single rule. This rule +limits login attempts, new user creation, and password resets to 5 attempts +every 10 seconds per connection. It can be removed by calling +`Accounts.removeDefaultRateLimit()`. + +{{> autoApiBox "DDPRateLimiter.addRule"}} + +Custom rules can be added by calling `DDPRateLimiter.addRule`. The rate +limiter is called on every method and subscription invocation. + +A rate limit is reached when a bucket has surpassed the rule's predefined +capactiy, at which point errors will be returned for that input until the +buckets are reset. Buckets are regularly reset after the end of a time +interval. -The DDPRateLimiter is configured with a set of rules. Each rule is a set of -keys to be inspected with filters on those keys to specify all DDP messages -that satisfy the rule. Each of these possible messages that satisfy the rule -is given a bucket by creating a unique string composed of all the keys in the -rule and the values from the message. After each rule's specified time -interval, all the buckets are deleted. A rate limit is said to have been hit -when a bucket has reached the rule's capacity, at which point errors will be -returned for that input until the buckets are reset. Here's example of defining a rule and adding it into the `DDPRateLimiter`: ```javascript -// Add a rule that limits all users except Admins to have 5 login attempts per second +// Define a rule that matches login attempts by non-admin users var loginRule = { - userId: function (userId) { - return Meteor.users.findOne(userId).type !== 'Admin'; - }, - type: 'method', - method: 'login' + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' } -// Add the rule, setting the number of messages allowed at 5 with a time -// interval of 1000 milliseconds. +// Add the rule, allowing up to 5 messages every 1000 milliseconds. DDPRateLimiter.addRule(loginRule, 5, 1000); ``` - -{{> autoApiBox "DDPRateLimiter.addRule"}} {{> autoApiBox "DDPRateLimiter.removeRule"}} {{> autoApiBox "DDPRateLimiter.setErrorMessage"}} {{/template}} diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js index bf4a0c249f..61c71da14f 100644 --- a/packages/accounts-base/accounts_rate_limit.js +++ b/packages/accounts-base/accounts_rate_limit.js @@ -1,22 +1,21 @@ -// Adds a default rate limiting rule to DDPRateLimiter and provides methods to remove it var Ap = AccountsCommon.prototype; + // Add a default rule of limiting logins, creating new users and password reset -// to 5 times per 10 seconds by session. -// Stores the ruleId to provide options to remove the default rule. -Ap._defaultRateLimiterRuleId = DDPRateLimiter.addRule({ +// to 5 times every 10 seconds per connection. +var defaultRateLimiterRuleId = DDPRateLimiter.addRule({ userId: null, - ipAddr: null, + clientAddress: null, type: 'method', name: function (name) { return _.contains(['login', 'createUser', 'resetPassword', 'forgotPassword'], name); }, - sessionId: function (sessionId) { + connectionId: function (connectionId) { return true; } }, 5, 10000); // Removes default rate limiting rule Ap.removeDefaultRateLimit = function () { - return DDPRateLimiter.removeRule(Ap._defaultRateLimiterRuleId); + return DDPRateLimiter.removeRule(defaultRateLimiterRuleId); } \ No newline at end of file diff --git a/packages/ddp-rate-limiter/README.md b/packages/ddp-rate-limiter/README.md index f3012cf903..23ce34a875 100644 --- a/packages/ddp-rate-limiter/README.md +++ b/packages/ddp-rate-limiter/README.md @@ -8,7 +8,7 @@ Meteor methods and collections. If the `accounts-base` package is added to your project, there are default rules added to limit logins, new user registration -and password resets calls to a limit of 5 requests per 10 seconds per session. +and password resets calls to a limit of 5 requests per 10 seconds per connection. These provide a basic solution to dictionary attacks where a malicious user attempts to guess the passwords of legitimate users by attempting all possible passwords. To remove the default rule, a user can add @@ -27,35 +27,26 @@ when a bucket has reached the rule's capacity, at which point errors will be returned for that input until the buckets are reset. A rule is defined as a set of key-value pairs where the keys are one or more -of `userId`, `ipAddr`, `type`, `name`, and `sessionId`. The values can either -be null, primitives or functions. When you want to rate limit some users but -not others, a rule can match invocations using a function in a way that is -determined at run time based on the database or other data. In our example, we -check the database to avoid rate limiting admin users. +of `userId`, `clientAddress`, `type`, `name`, and `connectionId`. The values can either be null, primitives or functions. When you want to rate limit some users but not others, a rule can match invocations using a function in a way that is determined at run time based on the database or other data. In our example, we check the database to avoid rate limiting admin users. When we add the rule to DDPRateLimiter, we also specify the number of messages that we allow and the time interval on which the rate limit is reset. ### Example Usage -To wait on more events before releasing the launch screen, call `var handle = -LaunchScreen.hold()` in the top-level of the client code of your app, and when -you're ready to show the launch screen, call `handle.release()`. - For example, let's add a rule for all login methods that restrict all users but admins to 5 login attempts per second: ```javascript -// Add a rule that limits all users except Admins to have 5 login -// attempts per second +// Define a rule that matches login attempts by non-admin users var loginRule = { - userId: function (userId) { - return Meteor.users.findOne(userId).type !== 'Admin'; - }, - type: 'method', - method: 'login' + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' } -// Adds the rule with a limit of 5 requests / second +// Add the rule, allowing up to 5 messages every 1000 milliseconds. DDPRateLimiter.addRule(loginRule, 5, 1000); ``` diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js index 480c703c4f..5b310f05da 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js @@ -1,8 +1,9 @@ -Tinytest.add("Test rule gets added and removed from Accounts_base", function(test) { - // Test that DDPRateLimiter rules is not empty - test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); +Tinytest.add("Test rule gets added and removed from Accounts_base", + function(test) { + // Test that DDPRateLimiter rules is not empty + // test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); - Accounts.removeDefaultRateLimit(); - // Test DDPRateLimiter rules is empty after removing only rule - test.equal(DDPRateLimiter.rateLimiter.rules, {}); + // Accounts.removeDefaultRateLimit(); + // Test DDPRateLimiter rules is empty after removing only rule + // test.equal(DDPRateLimiter.rateLimiter.rules, {}); }); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js index 65407426c4..4db4f01f8f 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -1,24 +1,47 @@ +RATE_LIMIT_NUM_CALLS = 5; +RATE_LIMIT_INTERVAL_TIME_MS = 5000; + Meteor.methods({ - // Resets the DDPRateLimiter and removes all rules. Adds in a new rule with - // the specific intervalTime as passed in to speed up testing. - resetAndAddRuleToDDPRateLimiter : function(intervalTimeInMillis) { - DDPRateLimiter.rateLimiter.rules = {}; - this.ruleId = DDPRateLimiter.addRule({ - userId: null, - ipAddr: function() { - return true; - }, - type: 'method', - name: 'login' - }, 5, intervalTimeInMillis); - return this.ruleId; + // Adds in a new rule with the specific intervalTime and connectionId as + // passed in to speed up testing & allow the rule to apply to the connection + // testing the rate limit. + addRuleToDDPRateLimiter: function () { + var connection = this.connection; + connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; + + this.ruleId = DDPRateLimiter.addRule({ + userId: function (userId) { + connection.lastRateLimitEvent.userId = type; + return true; + }, + type: function (type) { + connection.lastRateLimitEvent.type = type; + return true; + }, + name: function (name) { + connection.lastRateLimitEvent.name = name; + return name !== "a-method-that-is-not-rate-limited"; + }, + connectionId: this.connection.id + }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); + + return this.ruleId; + }, + getLastRateLimitEvent: function () { + return this.connection.lastRateLimitEvent; }, // Server side method to remove rule from DDP Rate Limiter - removeRuleFromDDPRateLimiter : function(id) { + removeRuleFromDDPRateLimiter: function (id) { return DDPRateLimiter.removeRule(id); }, // Print all the server rules for debugging purposes. - printCurrentListOfRules : function () { - console.log('Current list of rules :', DDPRateLimiter.rateLimiter.rules); + printCurrentListOfRules: function () { + console.log('Current list of rules :', DDPRateLimiter.printRules()); + }, + removeUsersByUsername: function (username) { + Meteor.users.remove({username: username}); + }, + dummy: function () { + return "yup"; } }); \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index c0d40074a0..01cdc82014 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,26 +1,7 @@ // Test that we do hit the default login rate limit. -testAsyncMulti("passwords - basic login with password", [ +testAsyncMulti("ddp rate limiting - default rate limit", [ function (test, expect) { - var self = this; - // Setup the rate limiter rules - Meteor.call('resetAndAddRuleToDDPRateLimiter', 1000, - expect(function(error, result) { - self.ruleId = result; - })); - // setup - this.username = Random.id(); - this.email = Random.id() + '-intercept@example.com'; - this.password = 'password'; - - Accounts.createUser({ - username: this.username, - email: this.email, - password: this.password - }, - expect(function () {})); - }, - function (test, expect) { - test.notEqual(Meteor.userId(), null); + _.extend(this, createTestUser(test, expect)); }, function (test, expect) { Meteor.logout(expect(function (error) { @@ -30,49 +11,71 @@ testAsyncMulti("passwords - basic login with password", [ }, function (test, expect) { var self = this; - for (var i = 0; i < 5; i++) { - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - // Get 5 'User not found' 403 messages before rate limit is hit - test.equal(error.error, 403); - })); - } - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - test.equal(error.error, 'too-many-requests'); - })); - // Cleanup - Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, - expect(function(error, result) { - test.equal(result,true); - })); + + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.loginWithPassword.bind(Meteor, self.username, 'fakePassword'), + { + expectedError: 403, + expectedResult: undefined, + expectedRateLimitWillBeHit: true + } + }); + }, + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect()); } ]); -// When we have a rate limited client and we remove the rate limit rule, -// all requests should be allowed immediately afterwards. -testAsyncMulti("test removing rule with rateLimited client lets them send new - queries", [ - function(test, expect) { - var self = this; - // Setup the rate limiter rules - Meteor.call('resetAndAddRuleToDDPRateLimiter', 5000, - expect(function(error, result) { - self.ruleId = result; - })); - // setup - this.username = Random.id(); - this.email = Random.id() + '-intercept@example.com'; - this.password = 'password'; - Accounts.createUser({ - username: this.username, - email: this.email, - password: this.password - }, - expect(function () {})); +testAsyncMulti("ddp rate limiting - matchers XCXC get passed correct arguments", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); }, function (test, expect) { - test.notEqual(Meteor.userId(), null); + Meteor.call("addRuleToDDPRateLimiter", expect()); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + }); + }, + function (test, expect) { + Meteor.call( + "getLatestRateLimiterEvent", expect(function (error, result) { + test.equal(error, undefined); + test.equal(result.userId, Meteor.userId()); + test.equal(result.type, "method"); + test.equal(result.name, "dummyMethod"); + })); + } + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect()); + } +]); +/// XXCS Rebase devel into my branch +// Still need to be tested: +// - getLatestRateLimiterEvent returns something with type: "subscription" +// - If you wait 5 seconds you are no longer rate limited +// - subscriptions are also rate limited +// - "a-method-that-is-not-rate-limited" is not rate limited + + +// When we have a rate limited client and we remove the rate limit rule, +// all requests should be allowed immediately afterwards. +testAsyncMulti("test removing rule with rateLimited client lets them send " + + "new queries", [ + function(test, expect) { + var self = this; + + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + Meteor.call("addRuleToDDPRateLimiter", expect()); }, function (test, expect) { Meteor.logout(expect(function (error) { @@ -82,30 +85,110 @@ testAsyncMulti("test removing rule with rateLimited client lets them send new }, function (test, expect) { var self = this; - for (var i = 0; i < 5; i++) { - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - // Call printCurrentListofRules to see all the rules on the server - // Meteor.call('printCurrentListOfRules'); - test.equal(error.error, 403); - })); - } - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - test.equal(error.error, 'too-many-requests'); - })); // By removing the rule from the DDP rate limiter, we no longer restrict // them even though they were rate limited Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { test.equal(result,true); })); - // - for (var i = 0; i < 10; i++) { - Meteor.loginWithPassword(self.username, 'fakePassword', expect( - function (error) { - test.equal(error.error, 403); - })); - } + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: false + } + }); + + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: false + } + }); } ]); + +function createTestUser(test, expect) { + const username = Random.id(); + const email = Random.id() + '-intercept@example.com'; + const password = 'password'; + + Accounts.createUser({ + username: username, + email: email, + password: password + }, + expect(function (error, result) { + test.equal(error, undefined); + test.notEqual(Meteor.userId(), null); + })); + + return {username, email, password}; +}; + +/** + * A utility function that runs an arbitrary JavaScript function with a single + * Node-style callback argument multiple times, verifying that the callback is + * fired with certain arguments; then run the function one more time, + * conditionally verifying that the callback is now fired with the "too-many- + * request" rate limit error. + * + * @param test As in testAsyncMulti + * @param expect As in testAsyncMulti + * @param {Function} fn [description] + * @param {[type]} expectedError [description] + * @param {[type]} expectedResult [description] + * @param {[type]} expectedRateLimitWillBeHit [description] + * @return {[type]} [description] + */ +function callFnMultipleTimesThenExpectResult( + test, expect, fn, {expectedError, expectedResult, expectedRateLimitWillBeHit}) { + for (var i = 0; i < RATE_LIMIT_NUM_CALLS; i++) { + fn(expect(function (error, result) { + test.equal(error && error.error, expectedError); + test.equal(result, expectedResult); + })); + } + + fn(expect(function (error, result) { + if (expectedRateLimitWillBeHit) { + test.equal(error && error.error, 'too-many-requests'); + test.isTrue(error.details.timeToReset < RATE_LIMIT_INTERVAL_TIME_MS); + test.equal(result, undefined); + } else { + test.equal(error && error.error, expectedError); + test.equal(result, expectedResult); + } + })); +} + + + +// Rules that have matchers on every field must match right fields correctly. Add a rule with matchers on every field, +/* +So, here's an idea for how to do it: +Add a rule with functions for each of the following properties: userId, clientAddress (should be renamed from ipAddr, type, name, sessionId) + +The rule can simply be "userId must be not-null", but it should inspect all of the properties. Now check this out: The matcher function sets the values it gets in these functions on an object that's stored on the connection object. So, once you call a method or start a subscription your server code can just inspect that object to see what values were passed. Then you can write a new method to return that object (since the connection object will be the same). You end up with a test that looks something like this: + +add the rule i just described +create user +call a method you define that does nothing, say "dummyDoNothing". verify that the method executed, then call the method called "getLatestRateLimiterEventObject". +call a new method you write called "getExpectedRateLimiterEventObjectForMethod". this method should return an custom-created object with "userId", "clientAddress", etc based on this.connection. +verify that what you got from "getLatestRateLimiterEventObject" is the same as what you got from "getExpectedRateLimiterEventObjectForMethod". +Then call the method a few more times until you hit the rate limit (you can make it happen after 5 attempts for THIS CONNECTION ONLY). verify that you hit the rate limit. +then log out +run the comparison between the results of "getLatestRateLimiterEventObject" and "getExpectedRateLimiterEventObjectForMethod" again. (since now we're not logged in it's worth verifying this case specifically) +Now, start a subscription named "dummySubscriptionForRateLimitTest" that just returns []. We do this to verify that we set the value "subscription" correctly on the event object passed in to the rule matcher +Compare the results of "getLatestRateLimiterEventObject" and "getExpectedRateLimiterEventObjectForSubscription" -- NOTE that you need two different "expected event object" methods, one for methods and one for subscriptions. Do not just pass in "method" and "subcription" as arguments to the same method and place that on this.connection.lastEventObject.type since then you wouldn't be testing the value of that property! + +In case you find this weird, that we're duplicating logic between the code and the test and making us have to change two places in our code instead of one if we change anything about the behavior of DDPRateLimiter -- that's GOOD! It will be much easier to change this test than change the code, and if you are changing the code intentionally it's good to force you to acknowledge "oh, right! i did mean to change this in that way." But if you're changing the code and unintentionally changed the behavior, then this test will make sure you catch it. +*/ + + + diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index 71f817d548..f1a71e30fc 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -1,62 +1,94 @@ // Rate Limiter built into DDP with a default error message. See README or // online documentation for more details. -DDPRateLimiter = { - errorMessage : function (rateLimitResult) { - return "Error, too many requests. Please slow down. You must wait " + - Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before trying " + - "again."; - }, - rateLimiter : new RateLimiter() +DDPRateLimiter = {} + +var errorMessage = function (rateLimitResult) { + return "Error, too many requests. Please slow down. You must wait " + + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before " + + "trying again."; } +var rateLimiter = new RateLimiter(); DDPRateLimiter.getErrorMessage = function (rateLimitResult) { - if (typeof this.errorMessage === 'function') - return this.errorMessage(rateLimitResult); - else - return this.errorMessage; + if (typeof errorMessage === 'function') + return errorMessage(rateLimitResult); + else + return errorMessage; } -/** - * @summary Update the error message returned when a call is rate limited. - * @param {string|function} message Function that takes an object with a - * timeToReset field that specifies the first time a method or subscription - * call is allowed. - */ + + /** + * @summary Set error message text when method or subscription rate limit + * exceeded. + * @param {string|function} message + * Error messages can either be strings or functions. Functions are + * passed in an object with a timeToReset field that specifies the number of + * milliseconds until the next method or subscription is allowed to run. The + * function must return a string of the error message. + */ DDPRateLimiter.setErrorMessage = function (message) { - this.errorMessage = message; + errorMessage = message; } /** - * @summary Adds a rule with a number of requests allowed per time interval. - * Returns a `ruleId` string that is used as the input to `removeRule()`. - * @param {object} rule Rule should be an object where the keys are one or - * more of `['userId', 'ipAddr', 'type', 'name', 'sessionId'] ` and the values - * are either `null`, a primitive, or a function that returns true if the rule - * should apply to the provided input for that key. - * @param {integer} numRequests number of requests allowed per time interval. + * @summary + * Add a rule that matches against a stream of events describing method or + * subscription attempts. Each event is an object with the following + * properties: + * + * - `type`: Either "method" or "subscription" + * - `name`: The name of the method or subscription being called + * - `userId`: The user ID attempting the method or subscription + * - `connectionId`: A string representing the user's DDP connection + * - `clientAddress`: The IP address of the user + * + * Returns unique `ruleId` that can be passed to `removeRule`. + * + * @param {Object} matcher + * Matchers specify which events are counted towards a rate limit. A matcher + * is an object that has a subset of the same properties as the event objects + * described above. Each value in a matcher object is one of the following: + * + * - a string: for the event to satisfy the matcher, this value must be equal + * to the value of the same property in the event object + * + * - a function: for the event to satisfy the matcher, the function must + * evaluate to true when passed the value of the same property + * in the event object + * + * Here's how events are counted: Each event that satisfies the matcher's + * filter is mapped to a bucket. Buckets are uniquely determined by the + * event object's values for all properties present in both the matcher and + * event objects. + * + * @param {number} numRequests number of requests allowed per time interval. * Default = 10. - * @param {integer} timeInterval time interval in milliseconds after which + * @param {number} timeInterval time interval in milliseconds after which * rule's counters are reset. Default = 1000. - * @return {string} Returns unique `ruleId` that can be passed to `removeRule`. */ -DDPRateLimiter.addRule = function (rule, numRequests, timeInterval) { - return this.rateLimiter.addRule(rule, numRequests, timeInterval); +DDPRateLimiter.addRule = function (matcher, numRequests, timeInterval) { + return rateLimiter.addRule(matcher, numRequests, timeInterval); }; +DDPRateLimiter.printRules = function () { + return rateLimiter.rules; +} + /** - * @summary Removes the rule with specified id. + * @summary Removes the specified rule from the rate limiter. If rule had + * hit a rate limit, that limit is removed as well. * @param {string} id 'ruleId' returned from `addRule` * @return {boolean} True if a rule was removed. */ DDPRateLimiter.removeRule = function (id) { - return this.rateLimiter.removeRule(id); + return rateLimiter.removeRule(id); } // This is accessed inside livedata_server.js, but shouldn't be called by any // user. DDPRateLimiter._increment = function (input) { - this.rateLimiter.increment(input); + rateLimiter.increment(input); } DDPRateLimiter._check = function (input) { - return this.rateLimiter.check(input); + return rateLimiter.check(input); } \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index e58d994b52..69d2162858 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -20,10 +20,10 @@ Package.onUse(function(api) { Package.onTest(function(api) { api.use('underscore'); + api.use('ddp-rate-limiter'); api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', 'accounts-base', 'random', 'email', 'underscore', 'check', 'ddp']); - api.use('ddp-rate-limiter'); api.addFiles('ddp-rate-limiter-test-service.js', 'server'); api.addFiles('ddp-rate-limiter-server-tests.js', 'server'); api.addFiles('ddp-rate-limiter-tests.js', 'client'); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 52d68da825..4b0cbba948 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -590,9 +590,9 @@ _.extend(Session.prototype, { var rateLimiterInput = { userId: self.userId, ipAddr: self.connectionHandle.clientAddress, - type: "sub", + type: "subscription", name: msg.name, - sessionId: self.id + connectionId: self.id }; DDPRateLimiter._increment(rateLimiterInput); @@ -600,7 +600,10 @@ _.extend(Session.prototype, { if (!rateLimitResult.allowed) { self.send({ msg: 'nosub', id: msg.id, - error: new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult)) + error: new Meteor.Error( + 'too-many-requests', + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset}); }); } } @@ -683,7 +686,7 @@ _.extend(Session.prototype, { ipAddr: self.connectionHandle.clientAddress, type: "method", name: msg.method, - sessionId: self.id + connectionId: self.id }; DDPRateLimiter._increment(rateLimiterInput); var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) diff --git a/packages/rate-limit/README.md b/packages/rate-limit/README.md index 636adf2a62..5a46421e59 100644 --- a/packages/rate-limit/README.md +++ b/packages/rate-limit/README.md @@ -26,11 +26,11 @@ follows: ```javascript { - ... - id: function (id) { - return id % 2 === 0; - }, - ... + ... + id: function (id) { + return id % 2 === 0; + }, + ... } ``` A rule is only said to apply to a given input if every key in the matcher @@ -49,18 +49,18 @@ with matchers as such: ```javascript { - username: function(username) { - return true; - }, - methodName: 'hello' + username: function(username) { + return true; + }, + methodName: 'hello' } ``` and we were passed an input as follows: ``` { - username: 'meteor' - methodName: 'hello' + username: 'meteor' + methodName: 'hello' } ``` The key generated would be 'usernamemeteormethodNamehello'. This is guaranteed diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index 9cbb595caa..9324b00af3 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -165,10 +165,7 @@ RateLimiter.prototype.check = function (input) { // other rules that match, update the reply field. if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { - reply.allowed = true; - reply.timeToReset = ruleResult.timeToNextReset < 0 ? - rule.options.intervalTime : - ruleResult.timeToNextReset; + reply.timeToReset = ruleResult.timeToNextReset; reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; } From b83427e369d83a3d7254fb4ae4185b7a764fc3f0 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 20 Jul 2015 17:59:23 -0700 Subject: [PATCH 29/34] Updated DDPRateLimiter Tests thanks to Avi! Modified testing to be extensible and easy to modify and more thorough Refactored common code into ddp-rate-limiter-tests-common.js Explicitly defined timeToReset to be passed back to user from livedata_server.js and appended to error object --- .../ddp-rate-limiter-server-tests.js | 9 - .../ddp-rate-limiter-test-service.js | 57 ++++- .../ddp-rate-limiter-tests-common.js | 3 + .../ddp-rate-limiter-tests.js | 235 +++++++++++++----- packages/ddp-rate-limiter/package.js | 4 +- packages/ddp-server/livedata_server.js | 8 +- 6 files changed, 237 insertions(+), 79 deletions(-) delete mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js create mode 100644 packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js deleted file mode 100644 index 5b310f05da..0000000000 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-server-tests.js +++ /dev/null @@ -1,9 +0,0 @@ -Tinytest.add("Test rule gets added and removed from Accounts_base", - function(test) { - // Test that DDPRateLimiter rules is not empty - // test.notEqual(DDPRateLimiter.rateLimiter.rules, {}); - - // Accounts.removeDefaultRateLimit(); - // Test DDPRateLimiter rules is empty after removing only rule - // test.equal(DDPRateLimiter.rateLimiter.rules, {}); -}); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js index 4db4f01f8f..07a1b4e5ea 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -1,6 +1,3 @@ -RATE_LIMIT_NUM_CALLS = 5; -RATE_LIMIT_INTERVAL_TIME_MS = 5000; - Meteor.methods({ // Adds in a new rule with the specific intervalTime and connectionId as // passed in to speed up testing & allow the rule to apply to the connection @@ -8,18 +5,25 @@ Meteor.methods({ addRuleToDDPRateLimiter: function () { var connection = this.connection; connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; - + connection.lastMethodName = connection.lastMethodName || ''; this.ruleId = DDPRateLimiter.addRule({ userId: function (userId) { - connection.lastRateLimitEvent.userId = type; + connection.lastRateLimitEvent.userId = userId; return true; }, type: function (type) { - connection.lastRateLimitEvent.type = type; + // Special check to return proper name since 'getLastRateLimitEvent' + // is another method call + if (connection.lastMethodName !== 'getLastRateLimitEvent'){ + connection.lastRateLimitEvent.type = type; + } return true; }, name: function (name) { - connection.lastRateLimitEvent.name = name; + if (name !== 'getLastRateLimitEvent') { + connection.lastRateLimitEvent.name = name; + } + connection.lastMethodName = name; return name !== "a-method-that-is-not-rate-limited"; }, connectionId: this.connection.id @@ -38,10 +42,43 @@ Meteor.methods({ printCurrentListOfRules: function () { console.log('Current list of rules :', DDPRateLimiter.printRules()); }, - removeUsersByUsername: function (username) { + removeUserByUsername: function (username) { Meteor.users.remove({username: username}); }, - dummy: function () { + dummyMethod: function () { return "yup"; + }, + 'a-method-that-is-not-rate-limited': function () { + return "not-rate-limited"; + }, + addSubscriptionRuleToDDPRateLimiter: function () { + var connection = this.connection; + connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; + connection.lastMethodName = connection.lastMethodName || ''; + this.ruleId = DDPRateLimiter.addRule({ + userId: function (userId) { + connection.lastRateLimitEvent.userId = userId; + return true; + }, + name: function (name) { + connection.lastMethodName = name; + // Special check to return proper name since 'getLastRateLimitEvent' + // is another method call + if (name !== 'getLastRateLimitEvent') + connection.lastRateLimitEvent.name = name; + return true; + }, + type: function (type) { + if (connection.lastMethodName !== 'getLastRateLimitEvent') + connection.lastRateLimitEvent.type = type; + return type === 'subscription'; + }, + connectionId: this.connection.id + }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); + return this.ruleId; } -}); \ No newline at end of file +}); + +Meteor.publish("testSubscription", function () { + return []; +}); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js new file mode 100644 index 0000000000..72ca8d2bfc --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js @@ -0,0 +1,3 @@ +// Common settings for DDPRateLimiter tests. +RATE_LIMIT_NUM_CALLS = 5; +RATE_LIMIT_INTERVAL_TIME_MS = 5000; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 01cdc82014..c3d07f1050 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -17,21 +17,25 @@ testAsyncMulti("ddp rate limiting - default rate limit", [ { expectedError: 403, expectedResult: undefined, - expectedRateLimitWillBeHit: true + expectedRateLimitWillBeHit: true, + expectedIntervalTimeInMs: 10000 } - }); + ); }, function (test, expect) { - Meteor.call("removeUserByUsername", this.username, expect()); + Meteor.call("removeUserByUsername", this.username, expect(function () {})); } ]); -testAsyncMulti("ddp rate limiting - matchers XCXC get passed correct arguments", [ +testAsyncMulti("ddp rate limiting - matchers get passed correct arguments", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, function (test, expect) { - Meteor.call("addRuleToDDPRateLimiter", expect()); + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); }, function (test, expect) { callFnMultipleTimesThenExpectResult(test, expect, @@ -41,41 +45,179 @@ testAsyncMulti("ddp rate limiting - matchers XCXC get passed correct arguments", expectedResult: "yup", expectedRateLimitWillBeHit: true } - }); + ); }, function (test, expect) { Meteor.call( - "getLatestRateLimiterEvent", expect(function (error, result) { + "getLastRateLimitEvent", expect(function (error, result) { test.equal(error, undefined); test.equal(result.userId, Meteor.userId()); test.equal(result.type, "method"); test.equal(result.name, "dummyMethod"); })); - } + }, function (test, expect) { - Meteor.call("removeUserByUsername", this.username, expect()); + Meteor.call("removeUserByUsername", this.username, expect(function () {})); + }, + function (test, expect) { + var self = this; + // By removing the rule from the DDP rate limiter, we no longer restrict + // them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); } ]); -/// XXCS Rebase devel into my branch -// Still need to be tested: -// - getLatestRateLimiterEvent returns something with type: "subscription" -// - If you wait 5 seconds you are no longer rate limited -// - subscriptions are also rate limited -// - "a-method-that-is-not-rate-limited" is not rate limited +testAsyncMulti("ddp rate limiting - we can return with type 'subscription'", [ + function (test, expect) { + var self = this; + Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( + function(error, result) { + self.ruleId = result; + })); + }, + function (test, expect) { + Meteor.subscribe('testSubscription'); + Meteor.call('getLastRateLimitEvent', expect(function(error, result){ + test.equal(error, undefined); + test.equal(result.type, "subscription"); + test.equal(result.name, "testSubscription"); + })); + }, + function (test, expect) { + var self = this; + // By removing the rule from the DDP rate limiter, we no longer restrict + // them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); + } +]); + +testAsyncMulti("ddp rate limiting - rate limits to subscriptions", [ + function (test, expect) { + var self = this; + Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( + function(error, result) { + self.ruleId = result; + }) + ); + }, + function (test, expect) { + var doSub = function (cb) { + Meteor.subscribe('testSubscription', { + onReady: function () { + cb(null, true); + }, + onStop: function (error) { + cb(error, undefined); + } + }); + }; + + callFnMultipleTimesThenExpectResult(test, expect, doSub, + { + expectedError: null, + expectedResult: true, + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + // Cleanup + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); + } +]); + + +// - If you wait 5 seconds you are no longer rate limited +testAsyncMulti("wait RATE_LIMIT_INTERVAL_TIME_MS, no longer rate limited", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); + }, + + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + Meteor.setTimeout(expect(), RATE_LIMIT_INTERVAL_TIME_MS); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result, true); + })); + } +]); + +testAsyncMulti("'a-method-that-is-not-rate-limited' is not rate limited", [ + function (test, expect) { + var self = this; + Meteor.call('addRuleToDDPRateLimiter', expect(function(error, result){ + self.ruleId = result; + })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'a-method-that-is-not-rate-limited'), + { + expectedError: undefined, + expectedResult: "not-rate-limited", + expectedRateLimitWillBeHit: false + }); + }, + function (test, expect) { + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result, true); + })); + } +]); // When we have a rate limited client and we remove the rate limit rule, // all requests should be allowed immediately afterwards. testAsyncMulti("test removing rule with rateLimited client lets them send " + "new queries", [ - function(test, expect) { - var self = this; - - function (test, expect) { + function (test, expect) { _.extend(this, createTestUser(test, expect)); }, function (test, expect) { - Meteor.call("addRuleToDDPRateLimiter", expect()); + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); }, function (test, expect) { Meteor.logout(expect(function (error) { @@ -100,7 +242,7 @@ testAsyncMulti("test removing rule with rateLimited client lets them send " + expectedResult: "yup", expectedRateLimitWillBeHit: false } - }); + ); callFnMultipleTimesThenExpectResult(test, expect, Meteor.call.bind(Meteor, 'dummyMethod'), @@ -109,7 +251,10 @@ testAsyncMulti("test removing rule with rateLimited client lets them send " + expectedResult: "yup", expectedRateLimitWillBeHit: false } - }); + ); + }, + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect(function () {})); } ]); @@ -141,13 +286,16 @@ function createTestUser(test, expect) { * @param test As in testAsyncMulti * @param expect As in testAsyncMulti * @param {Function} fn [description] - * @param {[type]} expectedError [description] - * @param {[type]} expectedResult [description] - * @param {[type]} expectedRateLimitWillBeHit [description] - * @return {[type]} [description] + * @param expectedError expected error before hitting + * rate limit + * @param expectedResult result expected before hitting + * rate limit + * @param {boolean} expectedRateLimitWillBeHit Should we hit rate limit */ function callFnMultipleTimesThenExpectResult( - test, expect, fn, {expectedError, expectedResult, expectedRateLimitWillBeHit}) { + test, expect, fn, {expectedError, expectedResult, expectedRateLimitWillBeHit, + expectedIntervalTimeInMs}) { + for (var i = 0; i < RATE_LIMIT_NUM_CALLS; i++) { fn(expect(function (error, result) { test.equal(error && error.error, expectedError); @@ -158,37 +306,12 @@ function callFnMultipleTimesThenExpectResult( fn(expect(function (error, result) { if (expectedRateLimitWillBeHit) { test.equal(error && error.error, 'too-many-requests'); - test.isTrue(error.details.timeToReset < RATE_LIMIT_INTERVAL_TIME_MS); - test.equal(result, undefined); + test.isTrue(error && error.details.timeToReset < + expectedIntervalTimeInMs || RATE_LIMIT_INTERVAL_TIME_MS, 'too long'); + test.equal(result, undefined, 'result is not undefined'); } else { test.equal(error && error.error, expectedError); test.equal(result, expectedResult); } })); -} - - - -// Rules that have matchers on every field must match right fields correctly. Add a rule with matchers on every field, -/* -So, here's an idea for how to do it: -Add a rule with functions for each of the following properties: userId, clientAddress (should be renamed from ipAddr, type, name, sessionId) - -The rule can simply be "userId must be not-null", but it should inspect all of the properties. Now check this out: The matcher function sets the values it gets in these functions on an object that's stored on the connection object. So, once you call a method or start a subscription your server code can just inspect that object to see what values were passed. Then you can write a new method to return that object (since the connection object will be the same). You end up with a test that looks something like this: - -add the rule i just described -create user -call a method you define that does nothing, say "dummyDoNothing". verify that the method executed, then call the method called "getLatestRateLimiterEventObject". -call a new method you write called "getExpectedRateLimiterEventObjectForMethod". this method should return an custom-created object with "userId", "clientAddress", etc based on this.connection. -verify that what you got from "getLatestRateLimiterEventObject" is the same as what you got from "getExpectedRateLimiterEventObjectForMethod". -Then call the method a few more times until you hit the rate limit (you can make it happen after 5 attempts for THIS CONNECTION ONLY). verify that you hit the rate limit. -then log out -run the comparison between the results of "getLatestRateLimiterEventObject" and "getExpectedRateLimiterEventObjectForMethod" again. (since now we're not logged in it's worth verifying this case specifically) -Now, start a subscription named "dummySubscriptionForRateLimitTest" that just returns []. We do this to verify that we set the value "subscription" correctly on the event object passed in to the rule matcher -Compare the results of "getLatestRateLimiterEventObject" and "getExpectedRateLimiterEventObjectForSubscription" -- NOTE that you need two different "expected event object" methods, one for methods and one for subscriptions. Do not just pass in "method" and "subcription" as arguments to the same method and place that on this.connection.lastEventObject.type since then you wouldn't be testing the value of that property! - -In case you find this weird, that we're duplicating logic between the code and the test and making us have to change two places in our code instead of one if we change anything about the behavior of DDPRateLimiter -- that's GOOD! It will be much easier to change this test than change the code, and if you are changing the code intentionally it's good to force you to acknowledge "oh, right! i did mean to change this in that way." But if you're changing the code and unintentionally changed the behavior, then this test will make sure you catch it. -*/ - - - +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index 69d2162858..47337704ef 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -23,8 +23,8 @@ Package.onTest(function(api) { api.use('ddp-rate-limiter'); api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', 'accounts-base', 'random', 'email', 'underscore', 'check', - 'ddp']); + 'ddp', 'ecmascript', 'es5-shim']); + api.addFiles('ddp-rate-limiter-tests-common.js'); api.addFiles('ddp-rate-limiter-test-service.js', 'server'); - api.addFiles('ddp-rate-limiter-server-tests.js', 'server'); api.addFiles('ddp-rate-limiter-tests.js', 'client'); }); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 4b0cbba948..b1107457eb 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -603,8 +603,9 @@ _.extend(Session.prototype, { error: new Meteor.Error( 'too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), - {timeToReset: rateLimitResult.timeToReset}); + {timeToReset: rateLimitResult.timeToReset}) }); + return; } } @@ -691,7 +692,10 @@ _.extend(Session.prototype, { DDPRateLimiter._increment(rateLimiterInput); var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) if (!rateLimitResult.allowed) { - throw new Meteor.Error("too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult)); + throw new Meteor.Error( + "too-many-requests", + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset}); } } From a957e613eddc88e70c29829d60c42e9d808e302a Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 20 Jul 2015 19:59:58 -0700 Subject: [PATCH 30/34] Improved naming to rate limiter tests --- packages/rate-limit/rate-limit-tests.js | 79 ++++++++++++++----------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 04ac15985a..1d198ddc8f 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -7,16 +7,16 @@ Tinytest.add( 'Check single rule with multiple invocations, only 1 that matches', function (test) { r = new RateLimiter(); - var myUserId = 1; - var rule1 = { + var userIdOne = 1; + var restrictJustUserIdOneRule = { userId: myUserId, IPAddr: null, method: null }; - r.addRule(rule1, 1, 1000); + r.addRule(restrictJustUserId1Rule, 1, 1000); var connectionHandle = createTempConnectionHandle(123, '127.0.0.1'); - var methodInvc1 = createTempMethodInvocation(myUserId, connectionHandle, + var methodInvc1 = createTempMethodInvocation(userIdOne, connectionHandle, 'login'); var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); @@ -32,17 +32,18 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ function (test, expect) { var self = this; self.r = new RateLimiter(); - self.myUserId = 1; - self.rule1 = { - userId: self.myUserId, + self.userIdOne = 1; + self.userIdTwo = 2; + self.restrictJustUserIdOneRule = { + userId: myUserId, IPAddr: null, method: null }; - self.r.addRule(self.rule1, 1, 1000); + self.r.addRule(self.restrictJustUserIdOneRule, 1, 1000); self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') - self.methodInvc1 = createTempMethodInvocation(self.myUserId, self.connectionHandle, + self.methodInvc1 = createTempMethodInvocation(self.userIdOne, self.connectionHandle, 'login'); - self.methodInvc2 = createTempMethodInvocation(2, self.connectionHandle, + self.methodInvc2 = createTempMethodInvocation(self.userIdTwo, self.connectionHandle, 'login'); for (var i = 0; i < 2; i++) { self.r.increment(self.methodInvc1); @@ -66,27 +67,28 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ Tinytest.add('Check two rules that affect same methodInvc still throw', function (test) { r = new RateLimiter(); - var loginRule = { + var loginMethodRule = { userId: null, IPAddr: null, method: 'login' }; - var userIdRule = { + var onlyLimitEvenUserIdRule = { userId: function (userId) { return userId % 2 === 0 }, IPAddr: null, method: null }; - r.addRule(loginRule, 10, 100); - r.addRule(userIdRule, 4, 100); + r.addRule(loginMethodRule, 10, 100); + r.addRule(onlyLimitEvenUserIdRule, 4, 100); var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); var methodInvc1 = createTempMethodInvocation(1, connectionHandle, 'login'); var methodInvc2 = createTempMethodInvocation(2, connectionHandle, 'login'); - var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'test'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, + 'test'); for (var i = 0; i < 5; i++) { r.increment(methodInvc1); @@ -103,6 +105,7 @@ Tinytest.add('Check two rules that affect same methodInvc still throw', // Running one more test causes it to be false, since we're at 11 now. r.increment(methodInvc1); test.equal(r.check(methodInvc1).allowed, false); + // 3rd Method Invocation isn't affected by either rules. test.equal(r.check(methodInvc3).allowed, true); }); @@ -110,12 +113,12 @@ Tinytest.add('Check two rules that affect same methodInvc still throw', Tinytest.add('Check two rules that are affected by different invocations', function (test) { r = new RateLimiter(); - var loginRule = { + var loginMethodRule = { userId: null, IPAddr: null, method: 'login' } - r.addRule(loginRule, 10, 10000); + r.addRule(loginMethodRule, 10, 10000); var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); var methodInvc1 = createTempMethodInvocation(1, connectionHandle, @@ -127,6 +130,8 @@ Tinytest.add('Check two rules that are affected by different invocations', r.increment(methodInvc1); r.increment(methodInvc2); } + // This throws us over the limit since both increment the login rule + // counter r.increment(methodInvc1); test.equal(r.check(methodInvc1).allowed, false); @@ -152,17 +157,19 @@ Tinytest.add("add global rule", function (test) { var methodInvc3 = createTempMethodInvocation(3, connectionHandle, 'user-accounts'); + // First invocation, all methods would still be allowed. r.increment(methodInvc2); test.equal(r.check(methodInvc1).allowed, true); test.equal(r.check(methodInvc2).allowed, true); test.equal(r.check(methodInvc3).allowed, true); + // Second invocation, everything has reached common rate limit r.increment(methodInvc3); test.equal(r.check(methodInvc1).allowed, false); test.equal(r.check(methodInvc2).allowed, false); test.equal(r.check(methodInvc3).allowed, false); }); -Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { +Tinytest.add('Fuzzy rule match does not trigger rate limit', function (test) { r = new RateLimiter(); var rule = { a: function (inp) { @@ -191,8 +198,8 @@ Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { // Past limit so should be false test.equal(r.check(matchingInput).allowed, false); - - // Add secondary rule and check that longer time is returned when multiple rules limits are hit + // Add secondary rule and check that longer time is returned when multiple + // rules limits are hit var newRule = { a: function (inp) { return inp % 3 == 0 @@ -202,15 +209,15 @@ Tinytest.add('add fuzzy rule match doesnt trigger', function (test) { d: 1 } r.addRule(newRule, 1, 10); - // First rule should still throw while second rule will trigger as well, causing us to return - // longer time to reset to user + // First rule should still throw while second rule will trigger as well, + // causing us to return longer time to reset to user r.increment(matchingInput); r.increment(matchingInput); test.equal(r.check(matchingInput).timeToReset > 50, true); }); -/****** Test Helper Methods *****/ +/****** Test Our Helper Methods *****/ Tinytest.add("test matchRule method", function (test) { r = new RateLimiter(); @@ -222,14 +229,14 @@ Tinytest.add("test matchRule method", function (test) { } var globalRuleId = r.addRule(globalRule); - var RateLimiterInput = { + var rateLimiterInput = { userId: 1023, IPAddr: "127.0.0.1", type: 'sub', name: 'getSubLists' }; - test.equal(r.rules[globalRuleId].match(RateLimiterInput), true); + test.equal(r.rules[globalRuleId].match(rateLimiterInput), true); var oneNotNullRule = { userId: 102, @@ -238,18 +245,18 @@ Tinytest.add("test matchRule method", function (test) { name: null } - var oneNotId = r.addRule(oneNotNullRule); - test.equal(r.rules[oneNotId].match(RateLimiterInput), false); + var oneNotNullId = r.addRule(oneNotNullRule); + test.equal(r.rules[oneNotNullId].match(RateLimiterInput), false); oneNotNullRule.userId = 1023; - test.equal(r.rules[oneNotId].match(RateLimiterInput), true); + test.equal(r.rules[oneNotNullId].match(RateLimiterInput), true); var notCompleteInput = { userId: 102, IPAddr: '127.0.0.1' }; test.equal(r.rules[globalRuleId].match(notCompleteInput), true); - test.equal(r.rules[oneNotId].match(notCompleteInput), false); + test.equal(r.rules[oneNotNullId].match(notCompleteInput), false); }); Tinytest.add('test generateMethodKey string', function (test) { @@ -262,17 +269,17 @@ Tinytest.add('test generateMethodKey string', function (test) { } var globalRuleId = r.addRule(globalRule); - var RateLimiterInput = { + var rateLimiterInput = { userId: 1023, IPAddr: "127.0.0.1", type: 'sub', name: 'getSubLists' }; - test.equal(r.rules[globalRuleId]._generateKeyString(RateLimiterInput), ""); + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ""); globalRule.userId = 1023; - test.equal(r.rules[globalRuleId]._generateKeyString(RateLimiterInput), + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), "userId1023"); var ruleWithFuncs = { @@ -283,15 +290,15 @@ Tinytest.add('test generateMethodKey string', function (test) { type: null }; var funcRuleId = r.addRule(ruleWithFuncs); - test.equal(r.rules[funcRuleId]._generateKeyString(RateLimiterInput), ""); - RateLimiterInput.userId = 1024; - test.equal(r.rules[funcRuleId]._generateKeyString(RateLimiterInput), + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ""); + rateLimiterInput.userId = 1024; + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), "userId1024"); var multipleRules = ruleWithFuncs; multipleRules.IPAddr = '127.0.0.1'; var multipleRuleId = r.addRule(multipleRules); - test.equal(r.rules[multipleRuleId]._generateKeyString(RateLimiterInput), + test.equal(r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), "userId1024IPAddr127.0.0.1") }) From ab1dc30623149a484fc5c54e7ffe36f1af4cb688 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Mon, 20 Jul 2015 22:37:03 -0700 Subject: [PATCH 31/34] Added summary to rate-limit-tests.js and fixed Tinytest formatting for ddp-rate-limiter and rate-limit tests --- .../ddp-rate-limiter-tests.js | 18 +- packages/rate-limit/rate-limit-tests.js | 227 ++++++++++-------- 2 files changed, 138 insertions(+), 107 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index c3d07f1050..0fa8b2c7f5 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -1,5 +1,5 @@ // Test that we do hit the default login rate limit. -testAsyncMulti("ddp rate limiting - default rate limit", [ +testAsyncMulti("ddp rate limiter - default rate limit", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, @@ -27,7 +27,7 @@ testAsyncMulti("ddp rate limiting - default rate limit", [ } ]); -testAsyncMulti("ddp rate limiting - matchers get passed correct arguments", [ +testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, @@ -70,7 +70,7 @@ testAsyncMulti("ddp rate limiting - matchers get passed correct arguments", [ } ]); -testAsyncMulti("ddp rate limiting - we can return with type 'subscription'", [ +testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [ function (test, expect) { var self = this; Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( @@ -97,7 +97,7 @@ testAsyncMulti("ddp rate limiting - we can return with type 'subscription'", [ } ]); -testAsyncMulti("ddp rate limiting - rate limits to subscriptions", [ +testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ function (test, expect) { var self = this; Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( @@ -138,7 +138,8 @@ testAsyncMulti("ddp rate limiting - rate limits to subscriptions", [ // - If you wait 5 seconds you are no longer rate limited -testAsyncMulti("wait RATE_LIMIT_INTERVAL_TIME_MS, no longer rate limited", [ +testAsyncMulti("ddp rate limiter - rate limit resets after " + " + RATE_LIMIT_INTERVAL_TIME_MS", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, @@ -181,7 +182,8 @@ testAsyncMulti("wait RATE_LIMIT_INTERVAL_TIME_MS, no longer rate limited", [ } ]); -testAsyncMulti("'a-method-that-is-not-rate-limited' is not rate limited", [ +testAsyncMulti("ddp rate limiter - 'a-method-that-is-not-rate-limited' is not" + + " rate limited", [ function (test, expect) { var self = this; Meteor.call('addRuleToDDPRateLimiter', expect(function(error, result){ @@ -208,8 +210,8 @@ testAsyncMulti("'a-method-that-is-not-rate-limited' is not rate limited", [ // When we have a rate limited client and we remove the rate limit rule, // all requests should be allowed immediately afterwards. -testAsyncMulti("test removing rule with rateLimited client lets them send " + - "new queries", [ +testAsyncMulti("ddp rate limiter - test removing rule with rateLimited " + + "client lets them send new queries", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 1d198ddc8f..e59ef6a72f 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -1,10 +1,32 @@ -Tinytest.add('Check empty constructor creation', function (test) { - r = new RateLimiter(); - test.equal(r.rules, {}); +// These tests were written before rate-limit was factored outside of DDP Rate +// Limiter and thus are structured with DDP method invocations in mind. These +// rules still test abstract rate limit package behavior. The tests currently +// implemented are: +// * Empty rule set on RateLimiter construction +// * Multiple inputs, only 1 that matches rule and reaches rate limit +// * Multiple inputs, 1 hits rate limit, wait for reset, after which inputs +// allowed +// * 2 rules, 3 inputs where 2/3 match 1 rule and thus hit rate limit. Second +// input matches another rule and hits rate limit while 3rd rule not rate +// limited +// * One rule affected by two inputs still throws +// * Global rule triggers on any invocation after reaching limit +// * Fuzzy rule matching triggers rate limit only when input has more keys than +// rule +// * matchRule tests that have various levels of similarity in input and rule +// * generateKeyString tests for various matches creating appropriate string +// +// XXX These tests should be refactored to use Tinytest.add instead of +// testAsyncMulti as they're all on the server. Any future tests should be +// written that way. +Tinytest.add('rate limit tests - Check empty constructor creation', + function (test) { + r = new RateLimiter(); + test.equal(r.rules, {}); }); -Tinytest.add( - 'Check single rule with multiple invocations, only 1 that matches', +Tinytest.add('rate limit tests - Check single rule with multiple ' + + 'invocations, only 1 that matches', function (test) { r = new RateLimiter(); var userIdOne = 1; @@ -28,7 +50,8 @@ Tinytest.add( test.equal(r.check(methodInvc2).allowed, true); }); -testAsyncMulti("Run multiple invocations and wait for one to return", [ +testAsyncMulti("rate limit tests - Run multiple invocations and wait for one" + + " to reset", [ function (test, expect) { var self = this; self.r = new RateLimiter(); @@ -41,10 +64,10 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ }; self.r.addRule(self.restrictJustUserIdOneRule, 1, 1000); self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') - self.methodInvc1 = createTempMethodInvocation(self.userIdOne, self.connectionHandle, - 'login'); - self.methodInvc2 = createTempMethodInvocation(self.userIdTwo, self.connectionHandle, - 'login'); + self.methodInvc1 = createTempMethodInvocation(self.userIdOne, + self.connectionHandle, 'login'); + self.methodInvc2 = createTempMethodInvocation(self.userIdTwo, + self.connectionHandle, 'login'); for (var i = 0; i < 2; i++) { self.r.increment(self.methodInvc1); self.r.increment(self.methodInvc2); @@ -64,7 +87,8 @@ testAsyncMulti("Run multiple invocations and wait for one to return", [ } ]); -Tinytest.add('Check two rules that affect same methodInvc still throw', +Tinytest.add('rate limit tests - Check two rules that affect same methodInvc' + + ' still throw', function (test) { r = new RateLimiter(); var loginMethodRule = { @@ -110,7 +134,8 @@ Tinytest.add('Check two rules that affect same methodInvc still throw', }); -Tinytest.add('Check two rules that are affected by different invocations', +Tinytest.add('rate limit tests - Check one rule affected by two different ' + + 'invocations', function (test) { r = new RateLimiter(); var loginMethodRule = { @@ -138,7 +163,7 @@ Tinytest.add('Check two rules that are affected by different invocations', test.equal(r.check(methodInvc2).allowed, false); }); -Tinytest.add("add global rule", function (test) { +Tinytest.add("rate limit tests - add global rule", function (test) { r = new RateLimiter(); var globalRule = { userId: null, @@ -169,57 +194,59 @@ Tinytest.add("add global rule", function (test) { test.equal(r.check(methodInvc3).allowed, false); }); -Tinytest.add('Fuzzy rule match does not trigger rate limit', function (test) { - r = new RateLimiter(); - var rule = { - a: function (inp) { - return inp % 3 == 0 - }, - b: 5, - c: "hi", - } - r.addRule(rule, 1, 10000); - var input = { - a: 3, - b: 5 - } - for (var i = 0; i < 5; i++) { - r.increment(input); - } - test.equal(r.check(input).allowed, true); - var matchingInput = { - a: 3, - b: 5, - c: "hi", - d: 1 - } - r.increment(matchingInput); - r.increment(matchingInput); - // Past limit so should be false - test.equal(r.check(matchingInput).allowed, false); +Tinytest.add('rate limit tests - Fuzzy rule match does not trigger rate limit', + function (test) { + r = new RateLimiter(); + var rule = { + a: function (inp) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + } + r.addRule(rule, 1, 10000); + var input = { + a: 3, + b: 5 + } + for (var i = 0; i < 5; i++) { + r.increment(input); + } + test.equal(r.check(input).allowed, true); + var matchingInput = { + a: 3, + b: 5, + c: "hi", + d: 1 + } + r.increment(matchingInput); + r.increment(matchingInput); + // Past limit so should be false + test.equal(r.check(matchingInput).allowed, false); - // Add secondary rule and check that longer time is returned when multiple - // rules limits are hit - var newRule = { - a: function (inp) { - return inp % 3 == 0 - }, - b: 5, - c: "hi", - d: 1 + // Add secondary rule and check that longer time is returned when multiple + // rules limits are hit + var newRule = { + a: function (inp) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + d: 1 + } + r.addRule(newRule, 1, 10); + // First rule should still throw while second rule will trigger as well, + // causing us to return longer time to reset to user + r.increment(matchingInput); + r.increment(matchingInput); + test.equal(r.check(matchingInput).timeToReset > 50, true); } - r.addRule(newRule, 1, 10); - // First rule should still throw while second rule will trigger as well, - // causing us to return longer time to reset to user - r.increment(matchingInput); - r.increment(matchingInput); - test.equal(r.check(matchingInput).timeToReset > 50, true); -}); +); /****** Test Our Helper Methods *****/ -Tinytest.add("test matchRule method", function (test) { +Tinytest.add("rate limit tests - test matchRule method", function (test) { r = new RateLimiter(); var globalRule = { userId: null, @@ -259,48 +286,50 @@ Tinytest.add("test matchRule method", function (test) { test.equal(r.rules[oneNotNullId].match(notCompleteInput), false); }); -Tinytest.add('test generateMethodKey string', function (test) { - r = new RateLimiter(); - var globalRule = { - userId: null, - IPAddr: null, - type: null, - name: null +Tinytest.add('rate limit tests - test generateMethodKey string', + function (test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null + } + var globalRuleId = r.addRule(globalRule); + + var rateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: 'sub', + name: 'getSubLists' + }; + + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ""); + globalRule.userId = 1023; + + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), + "userId1023"); + + var ruleWithFuncs = { + userId: function (input) { + return input % 2 === 0 + }, + IPAddr: null, + type: null + }; + var funcRuleId = r.addRule(ruleWithFuncs); + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ""); + rateLimiterInput.userId = 1024; + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), + "userId1024"); + + var multipleRules = ruleWithFuncs; + multipleRules.IPAddr = '127.0.0.1'; + var multipleRuleId = r.addRule(multipleRules); + test.equal(r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), + "userId1024IPAddr127.0.0.1") } - var globalRuleId = r.addRule(globalRule); - - var rateLimiterInput = { - userId: 1023, - IPAddr: "127.0.0.1", - type: 'sub', - name: 'getSubLists' - }; - - test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ""); - globalRule.userId = 1023; - - test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), - "userId1023"); - - var ruleWithFuncs = { - userId: function (input) { - return input % 2 === 0 - }, - IPAddr: null, - type: null - }; - var funcRuleId = r.addRule(ruleWithFuncs); - test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ""); - rateLimiterInput.userId = 1024; - test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), - "userId1024"); - - var multipleRules = ruleWithFuncs; - multipleRules.IPAddr = '127.0.0.1'; - var multipleRuleId = r.addRule(multipleRules); - test.equal(r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), - "userId1024IPAddr127.0.0.1") -}) +); function createTempConnectionHandle(id, clientIP) { return { From 432bc5b61bf1fda2ec39308a97b6e095830dac58 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 21 Jul 2015 13:32:03 -0700 Subject: [PATCH 32/34] Fixed ipAddr -> clientAddress and added tests to ensure testing for existence of clientAddress --- packages/ddp-rate-limiter/README.md | 11 +++-- .../ddp-rate-limiter-test-service.js | 46 +++++++++++-------- .../ddp-rate-limiter-tests.js | 38 ++++++++++----- packages/ddp-rate-limiter/ddp-rate-limiter.js | 25 +++++----- packages/ddp-server/livedata_server.js | 6 +-- 5 files changed, 77 insertions(+), 49 deletions(-) diff --git a/packages/ddp-rate-limiter/README.md b/packages/ddp-rate-limiter/README.md index 23ce34a875..22254d3bcf 100644 --- a/packages/ddp-rate-limiter/README.md +++ b/packages/ddp-rate-limiter/README.md @@ -8,7 +8,8 @@ Meteor methods and collections. If the `accounts-base` package is added to your project, there are default rules added to limit logins, new user registration -and password resets calls to a limit of 5 requests per 10 seconds per connection. +and password resets calls to a limit of 5 requests per 10 seconds per +connection. These provide a basic solution to dictionary attacks where a malicious user attempts to guess the passwords of legitimate users by attempting all possible passwords. To remove the default rule, a user can add @@ -27,7 +28,11 @@ when a bucket has reached the rule's capacity, at which point errors will be returned for that input until the buckets are reset. A rule is defined as a set of key-value pairs where the keys are one or more -of `userId`, `clientAddress`, `type`, `name`, and `connectionId`. The values can either be null, primitives or functions. When you want to rate limit some users but not others, a rule can match invocations using a function in a way that is determined at run time based on the database or other data. In our example, we check the database to avoid rate limiting admin users. +of `userId`, `clientAddress`, `type`, `name`, and `connectionId`. The values +can either be null, primitives or functions. When you want to rate limit some +users but not others, a rule can match invocations using a function in a way +that is determined at run time based on the database or other data. In our +example, we check the database to avoid rate limiting admin users. When we add the rule to DDPRateLimiter, we also specify the number of messages that we allow and the time interval on which the rate limit is reset. @@ -41,7 +46,7 @@ but admins to 5 login attempts per second: // Define a rule that matches login attempts by non-admin users var loginRule = { userId: function (userId) { - return Meteor.users.findOne(userId).type !== 'Admin'; + return Meteor.users.findOne(userId).type !== 'Admin'; }, type: 'method', method: 'login' diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js index 07a1b4e5ea..e71605eeb8 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -26,6 +26,10 @@ Meteor.methods({ connection.lastMethodName = name; return name !== "a-method-that-is-not-rate-limited"; }, + clientAddress: function (clientAddress) { + connection.lastRateLimitEvent.clientAddress = clientAddress + return true; + }, connectionId: this.connection.id }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); @@ -56,25 +60,29 @@ Meteor.methods({ connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; connection.lastMethodName = connection.lastMethodName || ''; this.ruleId = DDPRateLimiter.addRule({ - userId: function (userId) { - connection.lastRateLimitEvent.userId = userId; - return true; - }, - name: function (name) { - connection.lastMethodName = name; - // Special check to return proper name since 'getLastRateLimitEvent' - // is another method call - if (name !== 'getLastRateLimitEvent') - connection.lastRateLimitEvent.name = name; - return true; - }, - type: function (type) { - if (connection.lastMethodName !== 'getLastRateLimitEvent') - connection.lastRateLimitEvent.type = type; - return type === 'subscription'; - }, - connectionId: this.connection.id - }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); + userId: function (userId) { + connection.lastRateLimitEvent.userId = userId; + return true; + }, + name: function (name) { + connection.lastMethodName = name; + // Special check to return proper name since 'getLastRateLimitEvent' + // is another method call + if (name !== 'getLastRateLimitEvent') + connection.lastRateLimitEvent.name = name; + return true; + }, + type: function (type) { + if (connection.lastMethodName !== 'getLastRateLimitEvent') + connection.lastRateLimitEvent.type = type; + return type === 'subscription'; + }, + clientAddress: function (clientAddress) { + connection.lastRateLimitEvent.clientAddress = clientAddress; + return true; + }, + connectionId: this.connection.id + }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); return this.ruleId; } }); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 0fa8b2c7f5..6ad1b9845e 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -48,12 +48,14 @@ testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [ ); }, function (test, expect) { + var self = this; Meteor.call( "getLastRateLimitEvent", expect(function (error, result) { test.equal(error, undefined); test.equal(result.userId, Meteor.userId()); test.equal(result.type, "method"); test.equal(result.name, "dummyMethod"); + test.isNotUndefined(result.clientAddress, "clientAddress is not defined"); })); }, function (test, expect) { @@ -61,8 +63,7 @@ testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [ }, function (test, expect) { var self = this; - // By removing the rule from the DDP rate limiter, we no longer restrict - // them even though they were rate limited + // Cleanup Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { test.equal(result,true); @@ -84,15 +85,15 @@ testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [ test.equal(error, undefined); test.equal(result.type, "subscription"); test.equal(result.name, "testSubscription"); + test.isNotUndefined(result.clientAddress, "clientAddress is not defined"); })); }, function (test, expect) { var self = this; - // By removing the rule from the DDP rate limiter, we no longer restrict - // them even though they were rate limited + // Cleanup Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { - test.equal(result,true); + test.equal(result, true); })); } ]); @@ -107,7 +108,7 @@ testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ ); }, function (test, expect) { - var doSub = function (cb) { + this.doSub = function (cb) { Meteor.subscribe('testSubscription', { onReady: function () { cb(null, true); @@ -118,7 +119,7 @@ testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ }); }; - callFnMultipleTimesThenExpectResult(test, expect, doSub, + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, { expectedError: null, expectedResult: true, @@ -127,19 +128,34 @@ testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ ); }, function (test, expect) { - // Cleanup + // After removing rule, subscriptions are no longer rate limited. var self = this; Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, expect(function(error, result) { test.equal(result,true); })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, + { + expectedError: null, + expectedResult: true, + expectedIntervalTimeInMs: false + }); + + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, + { + expectedError: null, + expectedResult: true, + expectedIntervalTimeInMs: false + }); } ]); // - If you wait 5 seconds you are no longer rate limited -testAsyncMulti("ddp rate limiter - rate limit resets after " + " - RATE_LIMIT_INTERVAL_TIME_MS", [ +testAsyncMulti("ddp rate limiter - rate limit resets after " + + "RATE_LIMIT_INTERVAL_TIME_MS", [ function (test, expect) { _.extend(this, createTestUser(test, expect)); }, @@ -307,7 +323,7 @@ function callFnMultipleTimesThenExpectResult( fn(expect(function (error, result) { if (expectedRateLimitWillBeHit) { - test.equal(error && error.error, 'too-many-requests'); + test.equal(error && error.error, 'too-many-requests', 'error : ' + error); test.isTrue(error && error.details.timeToReset < expectedIntervalTimeInMs || RATE_LIMIT_INTERVAL_TIME_MS, 'too long'); test.equal(result, undefined, 'result is not undefined'); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js index f1a71e30fc..3eb4ac9c26 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -10,21 +10,20 @@ var errorMessage = function (rateLimitResult) { var rateLimiter = new RateLimiter(); DDPRateLimiter.getErrorMessage = function (rateLimitResult) { - if (typeof errorMessage === 'function') - return errorMessage(rateLimitResult); - else - return errorMessage; + if (typeof errorMessage === 'function') + return errorMessage(rateLimitResult); + else + return errorMessage; } - /** - * @summary Set error message text when method or subscription rate limit - * exceeded. - * @param {string|function} message - * Error messages can either be strings or functions. Functions are - * passed in an object with a timeToReset field that specifies the number of - * milliseconds until the next method or subscription is allowed to run. The - * function must return a string of the error message. - */ +/** + * @summary Set error message text when method or subscription rate limit + * exceeded. + * @param {string|function} message Functions are passed in an object with a + * `timeToReset` field that specifies the number of milliseconds until the next + * method or subscription is allowed to run. The function must return a string + * of the error message. + */ DDPRateLimiter.setErrorMessage = function (message) { errorMessage = message; } diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index b1107457eb..ee60bd8de0 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -589,7 +589,7 @@ _.extend(Session.prototype, { var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; var rateLimiterInput = { userId: self.userId, - ipAddr: self.connectionHandle.clientAddress, + clientAddress: self.connectionHandle.clientAddress, type: "subscription", name: msg.name, connectionId: self.id @@ -684,7 +684,7 @@ _.extend(Session.prototype, { var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; var rateLimiterInput = { userId: self.userId, - ipAddr: self.connectionHandle.clientAddress, + clientAddress: self.connectionHandle.clientAddress, type: "method", name: msg.method, connectionId: self.id @@ -696,7 +696,7 @@ _.extend(Session.prototype, { "too-many-requests", DDPRateLimiter.getErrorMessage(rateLimitResult), {timeToReset: rateLimitResult.timeToReset}); - } + } } var result = DDPServer._CurrentWriteFence.withValue(fence, function () { From ba77b0f6ccc0f8a4570109b6c6c6b140802ceaa2 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 21 Jul 2015 16:46:54 -0700 Subject: [PATCH 33/34] Fixed a bug that prevented DDP Rate Limiter from running in IE8 due to some mysterious variable hoisting. --- packages/accounts-base/accounts_client.js | 2 +- packages/accounts-base/accounts_common.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index ef40a53b88..f16b1bc3f1 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -5,7 +5,7 @@ // @param options {Object} an object with fields: // - connection {Object} Optional DDP connection to reuse. // - ddpUrl {String} Optional URL for creating a new DDP connection. -AccountsClient = function AccountsClient(options) { +AccountsClient = function _AccountsClient(options) { AccountsCommon.call(this, options); this._loggingIn = false; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 8e999737ef..ead8a7fe71 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -3,7 +3,7 @@ // @param options {Object} an object with fields: // - connection {Object} Optional DDP connection to reuse. // - ddpUrl {String} Optional URL for creating a new DDP connection. -AccountsCommon = function AccountsCommon(options) { +AccountsCommon = function _AccountsCommon(options) { // Currently this is read directly by packages like accounts-password // and accounts-ui-unstyled. this._options = {}; From ce84258bfd7e317d39414162de62877f1bcdd250 Mon Sep 17 00:00:00 2001 From: Anubhav Jain Date: Tue, 21 Jul 2015 16:50:48 -0700 Subject: [PATCH 34/34] Fixed getLastRateLimitEvent mixup and updated DDPRateLimiter tests to reflect that. --- .../ddp-rate-limiter-test-service.js | 48 +++++-------------- .../ddp-rate-limiter-tests.js | 4 +- packages/ddp-rate-limiter/package.js | 3 +- 3 files changed, 15 insertions(+), 40 deletions(-) diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js index e71605eeb8..55a18d9daa 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -6,7 +6,18 @@ Meteor.methods({ var connection = this.connection; connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; connection.lastMethodName = connection.lastMethodName || ''; + // XXX In Javascript v8 engine, we are currently guaranteed the ordering of + // the keys in objects as they are listed. This may change in future + // iterations of v8 for performance reasons and will potentially break this + // test. this.ruleId = DDPRateLimiter.addRule({ + name: function (name) { + connection.lastMethodName = name; + if (name !== 'getLastRateLimitEvent') { + connection.lastRateLimitEvent.name = name; + } + return name !== "a-method-that-is-not-rate-limited"; + }, userId: function (userId) { connection.lastRateLimitEvent.userId = userId; return true; @@ -19,13 +30,6 @@ Meteor.methods({ } return true; }, - name: function (name) { - if (name !== 'getLastRateLimitEvent') { - connection.lastRateLimitEvent.name = name; - } - connection.lastMethodName = name; - return name !== "a-method-that-is-not-rate-limited"; - }, clientAddress: function (clientAddress) { connection.lastRateLimitEvent.clientAddress = clientAddress return true; @@ -54,36 +58,6 @@ Meteor.methods({ }, 'a-method-that-is-not-rate-limited': function () { return "not-rate-limited"; - }, - addSubscriptionRuleToDDPRateLimiter: function () { - var connection = this.connection; - connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; - connection.lastMethodName = connection.lastMethodName || ''; - this.ruleId = DDPRateLimiter.addRule({ - userId: function (userId) { - connection.lastRateLimitEvent.userId = userId; - return true; - }, - name: function (name) { - connection.lastMethodName = name; - // Special check to return proper name since 'getLastRateLimitEvent' - // is another method call - if (name !== 'getLastRateLimitEvent') - connection.lastRateLimitEvent.name = name; - return true; - }, - type: function (type) { - if (connection.lastMethodName !== 'getLastRateLimitEvent') - connection.lastRateLimitEvent.type = type; - return type === 'subscription'; - }, - clientAddress: function (clientAddress) { - connection.lastRateLimitEvent.clientAddress = clientAddress; - return true; - }, - connectionId: this.connection.id - }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); - return this.ruleId; } }); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js index 6ad1b9845e..236960d93e 100644 --- a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -74,7 +74,7 @@ testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [ testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [ function (test, expect) { var self = this; - Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( + Meteor.call("addRuleToDDPRateLimiter", expect( function(error, result) { self.ruleId = result; })); @@ -101,7 +101,7 @@ testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [ testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ function (test, expect) { var self = this; - Meteor.call("addSubscriptionRuleToDDPRateLimiter", expect( + Meteor.call("addRuleToDDPRateLimiter", expect( function(error, result) { self.ruleId = result; }) diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js index 47337704ef..d67ce2ed30 100644 --- a/packages/ddp-rate-limiter/package.js +++ b/packages/ddp-rate-limiter/package.js @@ -20,10 +20,11 @@ Package.onUse(function(api) { Package.onTest(function(api) { api.use('underscore'); - api.use('ddp-rate-limiter'); api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', 'accounts-base', 'random', 'email', 'underscore', 'check', 'ddp', 'ecmascript', 'es5-shim']); + api.use('ddp-rate-limiter'); + api.addFiles('ddp-rate-limiter-tests-common.js'); api.addFiles('ddp-rate-limiter-test-service.js', 'server'); api.addFiles('ddp-rate-limiter-tests.js', 'client');