mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"}, [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
21
packages/accounts-base/accounts_rate_limit.js
Normal file
21
packages/accounts-base/accounts_rate_limit.js
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
59
packages/ddp-rate-limiter/README.md
Normal file
59
packages/ddp-rate-limiter/README.md
Normal 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).
|
||||
66
packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js
Normal file
66
packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js
Normal 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 [];
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Common settings for DDPRateLimiter tests.
|
||||
RATE_LIMIT_NUM_CALLS = 5;
|
||||
RATE_LIMIT_INTERVAL_TIME_MS = 5000;
|
||||
335
packages/ddp-rate-limiter/ddp-rate-limiter-tests.js
Normal file
335
packages/ddp-rate-limiter/ddp-rate-limiter-tests.js
Normal 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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
93
packages/ddp-rate-limiter/ddp-rate-limiter.js
Normal file
93
packages/ddp-rate-limiter/ddp-rate-limiter.js
Normal 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);
|
||||
}
|
||||
31
packages/ddp-rate-limiter/package.js
Normal file
31
packages/ddp-rate-limiter/package.js
Normal 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');
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
72
packages/rate-limit/README.md
Normal file
72
packages/rate-limit/README.md
Normal 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.
|
||||
29
packages/rate-limit/package.js
Normal file
29
packages/rate-limit/package.js
Normal 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');
|
||||
});
|
||||
365
packages/rate-limit/rate-limit-tests.js
Normal file
365
packages/rate-limit/rate-limit-tests.js
Normal 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;
|
||||
}
|
||||
255
packages/rate-limit/rate-limit.js
Normal file
255
packages/rate-limit/rate-limit.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user