Merge branch 'anubhav-rate-limit' into devel

Adds rate limiting to Meteor in the form of a default rule in accounts_rate_limit for user creation, login attempts and password resets. It also adds a DDPRateLimiter that allows users to add rules to methods and subscriptions.
This commit is contained in:
Anubhav Jain
2015-07-22 09:54:00 -07:00
21 changed files with 1922 additions and 338 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -398,4 +398,17 @@ 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.
<h3 id="accounts_rate_limit"><span>Rate Limiting</span></h3>
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 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.
{{/template}}

View File

@@ -180,4 +180,41 @@ 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.
<h2 id="ddpratelimiter"><span>DDPRateLimiter</span></h2>
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.
Here's example of defining a rule and adding it into the `DDPRateLimiter`:
```javascript
// 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'
}
// Add the rule, allowing up to 5 messages every 1000 milliseconds.
DDPRateLimiter.addRule(loginRule, 5, 1000);
```
{{> autoApiBox "DDPRateLimiter.removeRule"}}
{{> autoApiBox "DDPRateLimiter.setErrorMessage"}}
{{/template}}
{{> auto}}

View File

@@ -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"}, [
@@ -53,7 +54,8 @@ var toc = [
],
"Meteor.Error",
"Meteor.call",
"Meteor.apply"
"Meteor.apply",
{name: "DDPRateLimiter", id: "ddpratelimiter"}
],
{name: "Check", id: "check_package"}, [

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 = {};

View File

@@ -0,0 +1,21 @@
var Ap = AccountsCommon.prototype;
// Add a default rule of limiting logins, creating new users and password reset
// to 5 times every 10 seconds per connection.
var defaultRateLimiterRuleId = DDPRateLimiter.addRule({
userId: null,
clientAddress: null,
type: 'method',
name: function (name) {
return _.contains(['login', 'createUser', 'resetPassword',
'forgotPassword'], name);
},
connectionId: function (connectionId) {
return true;
}
}, 5, 10000);
// Removes default rate limiting rule
Ap.removeDefaultRateLimit = function () {
return DDPRateLimiter.removeRule(defaultRateLimiterRuleId);
}

View File

@@ -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

View File

@@ -0,0 +1,59 @@
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
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
`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`, `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
For example, let's add a rule for all login methods that restrict all users
but admins to 5 login attempts per second:
```javascript
// 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'
}
// Add the rule, allowing up to 5 messages every 1000 milliseconds.
DDPRateLimiter.addRule(loginRule, 5, 1000);
```
For more information, check out the documentation on the [DDP Rate Limiter]
(http://docs.meteor.com/#ddpratelimiter).

View File

@@ -0,0 +1,66 @@
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
// testing the rate limit.
addRuleToDDPRateLimiter: function () {
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;
},
type: function (type) {
// Special check to return proper name since 'getLastRateLimitEvent'
// is another method call
if (connection.lastMethodName !== 'getLastRateLimitEvent'){
connection.lastRateLimitEvent.type = type;
}
return true;
},
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;
},
getLastRateLimitEvent: function () {
return this.connection.lastRateLimitEvent;
},
// 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.printRules());
},
removeUserByUsername: function (username) {
Meteor.users.remove({username: username});
},
dummyMethod: function () {
return "yup";
},
'a-method-that-is-not-rate-limited': function () {
return "not-rate-limited";
}
});
Meteor.publish("testSubscription", function () {
return [];
});

View File

@@ -0,0 +1,3 @@
// Common settings for DDPRateLimiter tests.
RATE_LIMIT_NUM_CALLS = 5;
RATE_LIMIT_INTERVAL_TIME_MS = 5000;

View File

@@ -0,0 +1,335 @@
// Test that we do hit the default login rate limit.
testAsyncMulti("ddp rate limiter - default rate limit", [
function (test, expect) {
_.extend(this, createTestUser(test, expect));
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
function (test, expect) {
var self = this;
callFnMultipleTimesThenExpectResult(test, expect,
Meteor.loginWithPassword.bind(Meteor, self.username, 'fakePassword'),
{
expectedError: 403,
expectedResult: undefined,
expectedRateLimitWillBeHit: true,
expectedIntervalTimeInMs: 10000
}
);
},
function (test, expect) {
Meteor.call("removeUserByUsername", this.username, expect(function () {}));
}
]);
testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [
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) {
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) {
Meteor.call("removeUserByUsername", this.username, expect(function () {}));
},
function (test, expect) {
var self = this;
// Cleanup
Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId,
expect(function(error, result) {
test.equal(result,true);
}));
}
]);
testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [
function (test, expect) {
var self = this;
Meteor.call("addRuleToDDPRateLimiter", 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");
test.isNotUndefined(result.clientAddress, "clientAddress is not defined");
}));
},
function (test, expect) {
var self = this;
// Cleanup
Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId,
expect(function(error, result) {
test.equal(result, true);
}));
}
]);
testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [
function (test, expect) {
var self = this;
Meteor.call("addRuleToDDPRateLimiter", expect(
function(error, result) {
self.ruleId = result;
})
);
},
function (test, expect) {
this.doSub = function (cb) {
Meteor.subscribe('testSubscription', {
onReady: function () {
cb(null, true);
},
onStop: function (error) {
cb(error, undefined);
}
});
};
callFnMultipleTimesThenExpectResult(test, expect, this.doSub,
{
expectedError: null,
expectedResult: true,
expectedRateLimitWillBeHit: true
}
);
},
function (test, expect) {
// 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", [
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("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){
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("ddp rate limiter - test removing rule with rateLimited " +
"client lets them send new queries", [
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) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
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);
}));
},
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 (test, expect) {
Meteor.call("removeUserByUsername", this.username, expect(function () {}));
}
]);
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 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,
expectedIntervalTimeInMs}) {
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', 'error : ' + error);
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);
}
}));
}

View File

@@ -0,0 +1,93 @@
// Rate Limiter built into DDP with a default error message. See README or
// online documentation for more details.
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 errorMessage === 'function')
return errorMessage(rateLimitResult);
else
return errorMessage;
}
/**
* @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;
}
/**
* @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 {number} timeInterval time interval in milliseconds after which
* rule's counters are reset. Default = 1000.
*/
DDPRateLimiter.addRule = function (matcher, numRequests, timeInterval) {
return rateLimiter.addRule(matcher, numRequests, timeInterval);
};
DDPRateLimiter.printRules = function () {
return rateLimiter.rules;
}
/**
* @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 rateLimiter.removeRule(id);
}
// This is accessed inside livedata_server.js, but shouldn't be called by any
// user.
DDPRateLimiter._increment = function (input) {
rateLimiter.increment(input);
}
DDPRateLimiter._check = function (input) {
return rateLimiter.check(input);
}

View File

@@ -0,0 +1,31 @@
Package.describe({
name: 'ddp-rate-limiter',
version: '0.0.1',
// Brief, one-line summary of the package.
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.
// 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('underscore');
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');
});

View File

@@ -580,7 +580,37 @@ _.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,
clientAddress: self.connectionHandle.clientAddress,
type: "subscription",
name: msg.name,
connectionId: 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),
{timeToReset: rateLimitResult.timeToReset})
});
return;
}
}
var handler = self.server.publish_handlers[msg.name];
self._startSubscription(handler, msg.id, msg.params, msg.name);
},
@@ -644,7 +674,31 @@ _.extend(Session.prototype, {
connection: self.connectionHandle,
randomSeed: randomSeed
});
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,
clientAddress: self.connectionHandle.clientAddress,
type: "method",
name: msg.method,
connectionId: self.id
};
DDPRateLimiter._increment(rateLimiterInput);
var rateLimitResult = DDPRateLimiter._check(rateLimiterInput)
if (!rateLimitResult.allowed) {
throw new Meteor.Error(
"too-many-requests",
DDPRateLimiter.getErrorMessage(rateLimitResult),
{timeToReset: rateLimitResult.timeToReset});
}
}
var result = DDPServer._CurrentWriteFence.withValue(fence, function () {
return DDP._CurrentInvocation.withValue(invocation, function () {
return maybeAuditArgumentChecks(

View File

@@ -15,7 +15,7 @@ Package.onUse(function (api) {
// common functionality
api.use('ddp-common', 'server'); // heartbeat
api.use('ddp-rate-limiter', 'server', {weak: true});
// Transport
api.use('ddp-client', 'server');
api.imply('ddp-client');

View File

@@ -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.

View File

@@ -0,0 +1,29 @@
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.use('underscore');
api.use('random');
api.addFiles('rate-limit.js');
api.export("RateLimiter");
});
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');
api.use('ddp-common');
api.addFiles('rate-limit-tests.js');
});

View File

@@ -0,0 +1,365 @@
// 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('rate limit tests - Check single rule with multiple ' +
'invocations, only 1 that matches',
function (test) {
r = new RateLimiter();
var userIdOne = 1;
var restrictJustUserIdOneRule = {
userId: myUserId,
IPAddr: null,
method: null
};
r.addRule(restrictJustUserId1Rule, 1, 1000);
var connectionHandle = createTempConnectionHandle(123, '127.0.0.1');
var methodInvc1 = createTempMethodInvocation(userIdOne, 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).allowed, false);
test.equal(r.check(methodInvc2).allowed, true);
});
testAsyncMulti("rate limit tests - Run multiple invocations and wait for one" +
" to reset", [
function (test, expect) {
var self = this;
self.r = new RateLimiter();
self.userIdOne = 1;
self.userIdTwo = 2;
self.restrictJustUserIdOneRule = {
userId: myUserId,
IPAddr: null,
method: null
};
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');
for (var i = 0; i < 2; i++) {
self.r.increment(self.methodInvc1);
self.r.increment(self.methodInvc2);
}
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) {
var self = this;
for (var i = 0; i < 100; i++) {
self.r.increment(self.methodInvc2);
}
test.equal(self.r.check(self.methodInvc1).allowed, true);
test.equal(self.r.check(self.methodInvc2).allowed, true);
}
]);
Tinytest.add('rate limit tests - Check two rules that affect same methodInvc' +
' still throw',
function (test) {
r = new RateLimiter();
var loginMethodRule = {
userId: null,
IPAddr: null,
method: 'login'
};
var onlyLimitEvenUserIdRule = {
userId: function (userId) {
return userId % 2 === 0
},
IPAddr: null,
method: null
};
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');
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).allowed, true);
// However, this triggers userId rule since this userId is even
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).allowed, false);
// 3rd Method Invocation isn't affected by either rules.
test.equal(r.check(methodInvc3).allowed, true);
});
Tinytest.add('rate limit tests - Check one rule affected by two different ' +
'invocations',
function (test) {
r = new RateLimiter();
var loginMethodRule = {
userId: null,
IPAddr: null,
method: 'login'
}
r.addRule(loginMethodRule, 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);
}
// This throws us over the limit since both increment the login rule
// counter
r.increment(methodInvc1);
test.equal(r.check(methodInvc1).allowed, false);
test.equal(r.check(methodInvc2).allowed, false);
});
Tinytest.add("rate limit tests - 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');
// 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('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
}
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("rate limit tests - test matchRule method", 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].match(rateLimiterInput), true);
var oneNotNullRule = {
userId: 102,
IPAddr: null,
type: null,
name: null
}
var oneNotNullId = r.addRule(oneNotNullRule);
test.equal(r.rules[oneNotNullId].match(RateLimiterInput), false);
oneNotNullRule.userId = 1023;
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[oneNotNullId].match(notCompleteInput), false);
});
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")
}
);
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;
}

View File

@@ -0,0 +1,255 @@
// 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;
// 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();
self.options = options;
self._matchers = matchers;
self._lastResetTime = new Date().getTime();
// Dictionary of input keys to counters
self.counters = {};
};
_.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;
return _.every(self._matchers, function (matcher, key) {
if (matcher !== null) {
if (!(_.has(input,key))) {
return false;
} else {
if (typeof matcher === 'function') {
if (!(matcher(input[key]))) {
return false;
}
} else {
if (matcher !== input[key]) {
return false;
}
}
}
}
return true;
});
},
// 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;
var returnString = "";
_.each(self._matchers, function (matcher, key) {
if (matcher !== null) {
if (typeof matcher === 'function') {
if (matcher(input[key])) {
returnString += key + input[key];
}
} else {
returnString += key + input[key];
}
}
});
return returnString;
},
// 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);
var timeSinceLastReset = new Date().getTime() - self._lastResetTime;
var timeToNextReset = self.options.intervalTime - timeSinceLastReset;
return {
key: keyString,
timeSinceLastReset: timeSinceLastReset,
timeToNextReset: timeToNextReset
};
},
// 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;
// Delete the old counters dictionary to allow for garbage collection
self.counters = {};
self._lastResetTime = new Date().getTime();
}
});
// 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 events allowed,
// last reset time and the rule reset interval in milliseconds.
self.rules = {};
}
/**
* 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 following structure
* { 'allowed': boolean - is this input allowed
* '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.
*/
RateLimiter.prototype.check = function (input) {
var self = this;
var reply = {
allowed: true,
timeToReset: 0,
numInvocationsLeft: Infinity
};
var matchedRules = self._findAllMatchingRules(input);
_.each(matchedRules, function (rule) {
var ruleResult = rule.apply(input);
var numInvocations = rule.counters[ruleResult.key];
if (ruleResult.timeToNextReset < 0) {
// Reset all the counters since the rule has reset
rule.resetCounter();
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.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) {
reply.timeToReset = ruleResult.timeToNextReset;
reply.numInvocationsLeft = rule.options.numRequestsAllowed -
numInvocations;
}
}
});
return reply;
}
/**
* 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. 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 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,
intervalTime) {
var self = this;
var options = {
numRequestsAllowed: numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL,
intervalTime: intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS
}
var newRule = new Rule(options, rule);
this.rules[newRule.id] = newRule;
return newRule.id;
}
/**
* Increment counters in every rule that match to this input
* @param {object} input Dictionary object containing attributes that may
* match to rules
*/
RateLimiter.prototype.increment = function (input) {
var self = this;
// Only increment rule counters that match this input
var matchedRules = self._findAllMatchingRules(input);
_.each(matchedRules, function (rule) {
var ruleResult = rule.apply(input);
if (ruleResult.timeSinceLastReset > rule.options.intervalTime) {
// Reset all the counters since the rule has reset
rule.resetCounter();
}
// Check whether the key exists, incrementing it if so or otherwise
// adding the key and setting its value to 1
if (_.has(rule.counters, ruleResult.key))
rule.counters[ruleResult.key]++;
else
rule.counters[ruleResult.key] = 1;
});
}
// Returns an array of all rules that apply to provided input
RateLimiter.prototype._findAllMatchingRules = function (input) {
var self = this;
return _.filter(self.rules, function(rule) {
return rule.match(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.
*/
RateLimiter.prototype.removeRule = function (id) {
var self = this;
if (self.rules[id]) {
delete self.rules[id];
return true;
} else {
return false;
}
}