Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/rocketchat-api/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Package.onUse(function(api) {
api.use([
'ecmascript',
'nimble:restivus',
'rate-limit',
'rocketchat:lib',
'rocketchat:models',
'rocketchat:integrations',
Expand Down
80 changes: 76 additions & 4 deletions packages/rocketchat-api/server/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import { Accounts } from 'meteor/accounts-base';
import { RocketChat } from 'meteor/rocketchat:lib';
import { Restivus } from 'meteor/nimble:restivus';
import { Logger } from 'meteor/rocketchat:logger';
import { RateLimiter } from 'meteor/rate-limit';
import _ from 'underscore';

const logger = new Logger('API', {});
const rateLimiterDictionary = {};
const defaultRateLimiterOptions = {
numRequestsAllowed: RocketChat.settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'),
intervalTimeInMS: RocketChat.settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'),
};

class API extends Restivus {
constructor(properties) {
Expand Down Expand Up @@ -117,6 +123,35 @@ class API extends Restivus {
};
}

addRateLimiterRuleForRoutes({ routes, rateLimiterOptions, endpoints, apiVersion }) {
if (!rateLimiterOptions.numRequestsAllowed) {
throw new Meteor.Error('You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint');
}
if (!rateLimiterOptions.intervalTimeInMS) {
throw new Meteor.Error('You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint');
}
const nameRoute = (route) => {
const routeActions = Object.keys(endpoints);
return routeActions.map((endpoint) => `/api/${ apiVersion }/${ route }${ endpoint }`);
};
const addRateLimitRuleToEveryRoute = (routes) => {
routes.forEach((route) => {
rateLimiterDictionary[route] = {
rateLimiter: new RateLimiter(),
options: rateLimiterOptions,
};
const rateLimitRule = {
IPAddr: (input) => input,
route,
};
rateLimiterDictionary[route].rateLimiter.addRule(rateLimitRule, rateLimiterOptions.numRequestsAllowed, rateLimiterOptions.intervalTimeInMS);
});
};
routes
.map(nameRoute)
.map(addRateLimitRuleToEveryRoute);
}

addRoute(routes, options, endpoints) {
// Note: required if the developer didn't provide options
if (typeof endpoints === 'undefined') {
Expand All @@ -128,16 +163,22 @@ class API extends Restivus {
if (!_.isArray(routes)) {
routes = [routes];
}

const { version } = this._config;

const shouldAddRateLimitToRoute = ((typeof options.rateLimiterOptions === 'object' || options.rateLimiterOptions === undefined) && version && !process.env.TEST_MODE && defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS);
if (shouldAddRateLimitToRoute) {
this.addRateLimiterRuleForRoutes({
routes,
rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions,
endpoints,
apiVersion: version,
});
}
routes.forEach((route) => {
// Note: This is required due to Restivus calling `addRoute` in the constructor of itself
Object.keys(endpoints).forEach((method) => {
if (typeof endpoints[method] === 'function') {
endpoints[method] = { action: endpoints[method] };
}

// Add a try/catch for each endpoint
const originalAction = endpoints[method].action;
endpoints[method].action = function _internalRouteActionHandler() {
Expand All @@ -149,14 +190,35 @@ class API extends Restivus {
});

logger.debug(`${ this.request.method.toUpperCase() }: ${ this.request.url }`);
const requestIp = this.request.headers['x-forwarded-for'] || this.request.connection.remoteAddress || this.request.socket.remoteAddress || this.request.connection.socket.remoteAddress;
const objectForRateLimitMatch = {
IPAddr: requestIp,
route: `${ this.request.route }${ this.request.method.toLowerCase() }`,
};
let result;
try {
const shouldVerifyRateLimit = rateLimiterDictionary.hasOwnProperty(objectForRateLimitMatch.route)
&& (!this.userId || !RocketChat.authz.hasPermission(this.userId, 'api-bypass-rate-limit'))
&& ((process.env.NODE_ENV === 'development' && RocketChat.settings.get('API_Enable_Rate_Limiter_Dev') === true) || process.env.NODE_ENV !== 'development');
if (shouldVerifyRateLimit) {
rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch);
const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch);
const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000);
this.response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed);
this.response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft);
this.response.setHeader('X-RateLimit-Reset', new Date().getTime() + attemptResult.timeToReset);
if (!attemptResult.allowed) {
throw new Meteor.Error('error-too-many-requests', `Error, too many requests. Please slow down. You must wait ${ timeToResetAttempsInSeconds } seconds before trying this endpoint again.`, {
timeToReset: attemptResult.timeToReset,
seconds: timeToResetAttempsInSeconds,
});
}
}
result = originalAction.apply(this);
} catch (e) {
logger.debug(`${ method } ${ route } threw an error:`, e.stack);
result = RocketChat.API.v1.failure(e.message, e.error);
}

result = result || RocketChat.API.v1.success();

rocketchatRestApiEnd({
Expand Down Expand Up @@ -427,5 +489,15 @@ RocketChat.settings.get('API_Enable_CORS', (key, value) => {
createApi(value);
});

RocketChat.settings.get('API_Enable_Rate_Limiter_Limit_Time_Default', (key, value) => {
defaultRateLimiterOptions.intervalTimeInMS = value;
createApi(value);
});

RocketChat.settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default', (key, value) => {
defaultRateLimiterOptions.numRequestsAllowed = value;
createApi(value);
});

// also create the API immediately
createApi(!!RocketChat.settings.get('API_Enable_CORS'));
3 changes: 3 additions & 0 deletions packages/rocketchat-api/server/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ RocketChat.settings.addGroup('General', function() {
this.section('REST API', function() {
this.add('API_Upper_Count_Limit', 100, { type: 'int', public: false });
this.add('API_Default_Count', 50, { type: 'int', public: false });
this.add('API_Enable_Rate_Limiter_Dev', true, { type: 'boolean', public: false });
this.add('API_Enable_Rate_Limiter_Limit_Calls_Default', 10, { type: 'int', public: false });
this.add('API_Enable_Rate_Limiter_Limit_Time_Default', 60000, { type: 'int', public: false });
this.add('API_Allow_Infinite_Count', true, { type: 'boolean', public: false });
this.add('API_Enable_Direct_Message_History_EndPoint', false, { type: 'boolean', public: false });
this.add('API_Enable_Shields', true, { type: 'boolean', public: false });
Expand Down
1 change: 1 addition & 0 deletions packages/rocketchat-authorization/server/startup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Meteor.startup(function() {
{ _id: 'add-user-to-joined-room', roles : ['admin', 'owner', 'moderator'] },
{ _id: 'add-user-to-any-c-room', roles : ['admin'] },
{ _id: 'add-user-to-any-p-room', roles : [] },
{ _id: 'api-bypass-rate-limit', roles : ['admin', 'bot'] },
{ _id: 'archive-room', roles : ['admin', 'owner'] },
{ _id: 'assign-admin-role', roles : ['admin'] },
{ _id: 'ban-user', roles : ['admin', 'owner', 'moderator'] },
Expand Down
11 changes: 9 additions & 2 deletions packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@
"API_Allow_Infinite_Count": "Allow Getting Everything",
"API_Allow_Infinite_Count_Description": "Should calls to the REST API be allowed to return everything in one call?",
"API_Analytics": "Analytics",
"api-bypass-rate-limit": "Bypass rate limit for REST API",
"API_CORS_Origin": "CORS Origin",
"API_Default_Count": "Default Count",
"API_Default_Count_Description": "The default count for REST API results if the consumer did not provided any.",
Expand All @@ -276,13 +277,19 @@
"API_EmbedDisabledFor_Description": "Comma-separated list of usernames to disable the embedded link previews.",
"API_EmbedIgnoredHosts": "Embed Ignored Hosts",
"API_EmbedIgnoredHosts_Description": "Comma-separated list of hosts or CIDR addresses, eg. localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16",
"API_Enable_Personal_Access_Tokens": "Enable Personal Access Tokens to REST API",
"API_EmbedSafePorts": "Safe Ports",
"API_Enable_Personal_Access_Tokens_Description": "Enable personal access tokens for use with the REST API",
"API_EmbedSafePorts_Description": "Comma-separated list of ports allowed for previewing.",
"API_Enable_CORS": "Enable CORS",
"API_Enable_Direct_Message_History_EndPoint": "Enable Direct Message History Endpoint",
"API_Enable_Direct_Message_History_EndPoint_Description": "This enables the `/api/v1/im.history.others` which allows the viewing of direct messages sent by other users that the caller is not part of.",
"API_Enable_Rate_Limiter_Dev": "Enable Rate Limiter in development",
"API_Enable_Rate_Limiter_Dev_Description": "Should limit the amount of calls to the endpoints in the development environment?",
"API_Enable_Rate_Limiter_Limit_Calls_Default": "Default number calls to the rate limiter",
"API_Enable_Rate_Limiter_Limit_Calls_Default_Description": "Number of default calls for each endpoint of the REST API, allowed within the time range defined below",
"API_Enable_Rate_Limiter_Limit_Time_Default": "Default time limit for the rate limiter (in ms)",
"API_Enable_Rate_Limiter_Limit_Time_Default_Description": "Default timeout to limit the number of calls at each endpoint of the REST API(in ms)",
"API_Enable_Personal_Access_Tokens": "Enable Personal Access Tokens to REST API",
"API_Enable_Personal_Access_Tokens_Description": "Enable personal access tokens for use with the REST API",
"API_Enable_Shields": "Enable Shields",
"API_Enable_Shields_Description": "Enable shields available at `/api/v1/shield.svg`",
"API_GitHub_Enterprise_URL": "Server URL",
Expand Down