Skip to content

Commit

Permalink
Add initial version of the rate limiter module.
Browse files Browse the repository at this point in the history
  • Loading branch information
Kami committed May 13, 2011
0 parents commit c7dc1a3
Show file tree
Hide file tree
Showing 8 changed files with 600 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
node_modules/
2 changes: 2 additions & 0 deletions .npmignore
@@ -0,0 +1,2 @@
node_modules/
.gitignore
14 changes: 14 additions & 0 deletions LICENSE
@@ -0,0 +1,14 @@
Licensed to Cloudkick, Inc ('Cloudkick') under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
Cloudkick licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
6 changes: 6 additions & 0 deletions Makefile
@@ -0,0 +1,6 @@
CWD=`pwd`

test:
whiskey --tests "${CWD}/tests/test-rate-limiter.js"

.PHONY: test
18 changes: 18 additions & 0 deletions lib/index.js
@@ -0,0 +1,18 @@
/*
* Licensed to Cloudkick, Inc ('Cloudkick') under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* Cloudkick licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

exports.RateLimiter = require('./rate-limiter').RateLimiter;
215 changes: 215 additions & 0 deletions lib/rate-limiter.js
@@ -0,0 +1,215 @@
/*
* Licensed to Cloudkick, Inc ('Cloudkick') under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* Cloudkick licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

var sprintf = require('sprintf').sprintf;

var misc = require('util/misc');

/**
* A simple class for IP address based request rate limiting.
* @constructor
*/
function RateLimiter() {
this._limits = {};
this._limitsData = {};
}

/**
* Add a new limit.
*
* @param {RegExp} path Regular expression for the path.
* @param {String} method HTTP method name or 'all' for all.
* @param {Number} requestCount The maximum number of request user can make
* in the time period defined bellow.
* @param {Number} requestPeriod Time period in seconds.
* @param {Boolean} returnErr true to return an error message, false to drop
* the requests without returning any error message
* (defaults to false).
*/
RateLimiter.prototype.addLimit = function(path, method,
requestCount,
requestPeriod,
returnErr) {
method = method.toLowerCase() || 'all';
var key = this._getKeyForLimit(path, method);

if (!misc.inArray(method, ['get','post','put','head','delete','all'])) {
throw new Error(sprintf('Invalid method: %s', method));
}

if (this._limits.hasOwnProperty(key)) {
throw new Error(sprintf('Limit for path %s and method %s already exists',
path, method));
}

if (requestCount < 1 || requestPeriod < 1) {
throw new Error('requestCount and requestPeriod values must be ' +
'bigger or equal to 1.');
}

var limit = {
'path_re': path,
'method': method,
'request_count': requestCount,
'request_period': requestPeriod,
'return_err': returnErr
};

this._limits[key] = limit;
this._limitsData[key] = {};
};

/**
* Remove a limit.
* Note: This will also remove any existing limit for requests currently in
* progress.
*
* @param {RegExp} path Regular expression for the path.
* @param {String} method HTTP method name or 'all' for all.
*/
RateLimiter.prototype.removeLimit = function(path, method) {
var key = this._getKeyForLimit(path, method);

if (!this._limits.hasOwnProperty(key)) {
throw new Error(sprintf('Limit for path %s and method %s does not exist',
path, method));
}

delete this._limits[key];
delete this._limitsData[key];
};

/**
* Reset access counter for the provided IP address.
*
* @param {RegExp} path Regular expression for the path.
* @param {String} method HTTP method name or 'all' for all.
* @param {String} ipAddress IP address for which the counter will be reset.
*/
RateLimiter.prototype.resetIpAddressAccessCounter = function(path, method,
ipAddress) {
var key = this._getKeyForLimit(path, method);

if (!this._limits.hasOwnProperty(key)) {
throw new Error(sprintf('Limit for path %s and method %s does not exist',
path, method));
}

if (!this._limitsData[key].hasOwnProperty(ipAddress)) {
throw new Error(sprintf('No recorded data for IP %s exists.', ipAddress));
}

this._limitsData[key][ipAddress]['access_count'] = 0;
};

/**
* Return a key for the provided path and method combination.
*
* @param {RegExp} path Regular expression for the path.
* @param {String} method HTTP method name or 'all' for all.
*/
RateLimiter.prototype._getKeyForLimit = function(path, method) {
var key = sprintf('%s.%s', path.toString(), method.toLowerCase());
return key;
};

/**
* Process a request and if a limit has been reached, drop it, otherwise call
* the callback provided by the user.
*
* @param {HttpServerRequest} req Request object.
* @param {HttpServerResponse} res Response object.
* @param {Function} A callback which is called with req and res if a limit
* hasn't been reached.
*/
RateLimiter.prototype.processRequest = function(req, res, callback) {
var tmp;

if (typeof req === 'function') {
// Allow user to pass in callback as the first argument.
// This comes handy when creating a continuation.
tmp = callback;
callback = req;
req = res;
res = tmp;
}

var path = req.url;
var method = req.method.toLowerCase();
var ipAddress = req.socket.remoteAddress;

var now = Math.round(new Date() / 1000);
var limitReached = false;
var limit, limitData, ipLimitData, code, headers, errMsg;

for (var key in this._limits) {
if (this._limits.hasOwnProperty(key)) {
limit = this._limits[key];
limitData = this._limitsData[key];

if (!limitData.hasOwnProperty(ipAddress)) {
limitData[ipAddress] = {
'access_count': 0,
'expire': null
};
}

ipLimitData = limitData[ipAddress];

if (!path.match(limit['path_re']) || (limit['method'] !== 'all' &&
limit['method'] !== method)) {
continue;
}
else {
if (!ipLimitData['expire'] || ipLimitData['expire'] < now) {
ipLimitData['access_count'] = 0;
ipLimitData['expire'] = (now + limit['request_period']);
}

if ((ipLimitData['access_count'] >= limit['request_count']) &&
(ipLimitData['expire'] > now) && (!limitReached)) {
// Limit has been reached, end the request, but don't return yet,
// because we need to update the counters for other matching limits.
limitReached = true;
code = 403;
headers = {'Retry-After': (ipLimitData.expire - now) };

if (limit['return_err']) {
errMsg = sprintf('A limit of %d requests in %s seconds ' +
'has been reached. Request aborted.',
limit['request_count'],
limit['request_period']);
}
else {
errMsg = '';
}

res.writeHead(code, headers);
res.end(errMsg);
}

ipLimitData['access_count']++;
}
}
}

if (!limitReached) {
callback(req, res);
}
};

exports.RateLimiter = RateLimiter;
33 changes: 33 additions & 0 deletions package.json
@@ -0,0 +1,33 @@
{
"name": "rate-limiter",
"description": "A module for rate limiting HTTP(s) requests based on the client IP address.",
"version": "0.1.0",
"author": "Cloudkick, Inc. <tomaz+npm@cloudkick.com> http://www.cloudkick.com",
"keywords": [ "rate", "limiter", "rate limiting", "flood prevention"],
"homepage": "https://github.com/cloudkick/rate-limiter",
"license": "Apache 2.0",
"repository": {
"type": "git",
"url": "git://github.com/cloudkick/rate-limiter.git"
},
"modules": {
"rate-limiter": "./lib/rat-limiter"
},
"directories": {
"lib": "./lib",
"example": "./example"
},
"scripts": {
"test": "make test"
},
"dependencies": {
"sprintf": ">= 0.1.1"
},
"devDependencies": {
"whiskey": "= 0.3.2"
},
"engines": {
"node": ">= 0.4.0"
},
"main": "./index"
}

0 comments on commit c7dc1a3

Please sign in to comment.