diff --git a/packages/rocketchat-api/package.js b/packages/rocketchat-api/package.js index 7f7c77940787e..1384ef6670d63 100644 --- a/packages/rocketchat-api/package.js +++ b/packages/rocketchat-api/package.js @@ -9,6 +9,7 @@ Package.onUse(function(api) { api.use([ 'ecmascript', 'nimble:restivus', + 'rate-limit', 'rocketchat:lib', 'rocketchat:models', 'rocketchat:integrations', diff --git a/packages/rocketchat-api/server/api.js b/packages/rocketchat-api/server/api.js index ff10ec4a4b1c0..e32407d689c32 100644 --- a/packages/rocketchat-api/server/api.js +++ b/packages/rocketchat-api/server/api.js @@ -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) { @@ -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') { @@ -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() { @@ -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({ @@ -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')); diff --git a/packages/rocketchat-api/server/settings.js b/packages/rocketchat-api/server/settings.js index 6b4c06e7a83a8..6e17f1c91e05d 100644 --- a/packages/rocketchat-api/server/settings.js +++ b/packages/rocketchat-api/server/settings.js @@ -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 }); diff --git a/packages/rocketchat-authorization/server/startup.js b/packages/rocketchat-authorization/server/startup.js index 83e2a4ff30085..f768a3770af46 100644 --- a/packages/rocketchat-authorization/server/startup.js +++ b/packages/rocketchat-authorization/server/startup.js @@ -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'] }, diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index a21236df02f32..7d250a6c2e965 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -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.", @@ -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",