[FIX] Rate limit incoming integrations (webhooks) (#15038)

pull/13864/head^2
Oliver Jägle 7 years ago committed by Diego Sampaio
parent a148644dbf
commit 34372fa99d
  1. 91
      app/api/server/api.js
  2. 2
      app/api/server/index.js
  3. 147
      app/integrations/server/api/api.js

@ -15,16 +15,17 @@ import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUse
const logger = new Logger('API', {});
const rateLimiterDictionary = {};
const defaultRateLimiterOptions = {
export const defaultRateLimiterOptions = {
numRequestsAllowed: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'),
intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'),
};
export let API = {};
class APIClass extends Restivus {
export class APIClass extends Restivus {
constructor(properties) {
super(properties);
this.apiPath = properties.apiPath;
this.authMethods = [];
this.fieldSeparator = '.';
this.defaultFieldsToExclude = {
@ -70,6 +71,12 @@ class APIClass extends Restivus {
this.authMethods.push(method);
}
shouldAddRateLimitToRoute(options) {
const { version } = this._config;
const { rateLimiterOptions } = options;
return (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS);
}
success(result = {}) {
if (_.isObject(result)) {
result.success = true;
@ -150,11 +157,41 @@ class APIClass extends Restivus {
};
}
getRateLimiter(route) {
return rateLimiterDictionary[route];
}
shouldVerifyRateLimit(route) {
return rateLimiterDictionary.hasOwnProperty(route)
&& settings.get('API_Enable_Rate_Limiter') === true
&& (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true)
&& !(this.userId && hasPermission(this.userId, 'api-bypass-rate-limit'));
}
enforceRateLimit(objectForRateLimitMatch, request, response) {
if (!this.shouldVerifyRateLimit(objectForRateLimitMatch.route)) {
return;
}
rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch);
const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch);
const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000);
response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed);
response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft);
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,
});
}
}
reloadRoutesToRefreshRateLimiter() {
const { version } = this._config;
this._routes.forEach((route) => {
const shouldAddRateLimitToRoute = (typeof route.options.rateLimiterOptions === 'object' || route.options.rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS);
if (shouldAddRateLimitToRoute) {
if (this.shouldAddRateLimitToRoute(route.options)) {
this.addRateLimiterRuleForRoutes({
routes: [route.path],
rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions,
@ -172,10 +209,6 @@ class APIClass extends Restivus {
if (!rateLimiterOptions.intervalTimeInMS) {
throw new Meteor.Error('You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint');
}
const nameRoute = (route) => {
const routeActions = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints);
return routeActions.map((endpoint) => `/api/${ apiVersion }/${ route }${ endpoint }`);
};
const addRateLimitRuleToEveryRoute = (routes) => {
routes.forEach((route) => {
rateLimiterDictionary[route] = {
@ -190,10 +223,24 @@ class APIClass extends Restivus {
});
};
routes
.map(nameRoute)
.map((route) => this.namedRoutes(route, endpoints, apiVersion))
.map(addRateLimitRuleToEveryRoute);
}
getFullRouteName(route, method, apiVersion = null) {
let prefix = `/${ this.apiPath || '' }`;
if (apiVersion) {
prefix += `${ apiVersion }/`;
}
return `${ prefix }${ route }${ method }`;
}
namedRoutes(route, endpoints, apiVersion) {
const routeActions = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints);
return routeActions.map((action) => this.getFullRouteName(route, action, apiVersion));
}
addRoute(routes, options, endpoints) {
// Note: required if the developer didn't provide options
if (typeof endpoints === 'undefined') {
@ -216,8 +263,7 @@ class APIClass extends Restivus {
routes = [routes];
}
const { version } = this._config;
const shouldAddRateLimitToRoute = (typeof options.rateLimiterOptions === 'object' || options.rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS);
if (shouldAddRateLimitToRoute) {
if (this.shouldAddRateLimitToRoute(options)) {
this.addRateLimiterRuleForRoutes({
routes,
rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions,
@ -233,6 +279,7 @@ class APIClass extends Restivus {
}
// Add a try/catch for each endpoint
const originalAction = endpoints[method].action;
const api = this;
endpoints[method].action = function _internalRouteActionHandler() {
const rocketchatRestApiEnd = metrics.rocketchatRestApi.startTimer({
method,
@ -249,25 +296,7 @@ class APIClass extends Restivus {
};
let result;
try {
const shouldVerifyRateLimit = rateLimiterDictionary.hasOwnProperty(objectForRateLimitMatch.route)
&& settings.get('API_Enable_Rate_Limiter') === true
&& (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true)
&& !(this.userId && hasPermission(this.userId, 'api-bypass-rate-limit'));
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,
});
}
}
api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response);
if (shouldVerifyPermissions && (!this.userId || !hasAllPermission(this.userId, options.permissionsRequired))) {
throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', {
@ -535,6 +564,7 @@ const createApi = function _createApi(enableCors) {
if (!API.v1 || API.v1._config.enableCors !== enableCors) {
API.v1 = new APIClass({
version: 'v1',
apiPath: 'api/',
useDefaultAuth: true,
prettyJson: process.env.NODE_ENV === 'development',
enableCors,
@ -545,6 +575,7 @@ const createApi = function _createApi(enableCors) {
if (!API.default || API.default._config.enableCors !== enableCors) {
API.default = new APIClass({
apiPath: 'api/',
useDefaultAuth: true,
prettyJson: process.env.NODE_ENV === 'development',
enableCors,

@ -32,4 +32,4 @@ import './v1/users';
import './v1/video-conference';
import './v1/autotranslate';
export { API } from './api';
export { API, APIClass, defaultRateLimiterOptions } from './api';

@ -3,7 +3,6 @@ import vm from 'vm';
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { Random } from 'meteor/random';
import { Restivus } from 'meteor/nimble:restivus';
import { Livechat } from 'meteor/rocketchat:livechat';
import Fiber from 'fibers';
import Future from 'fibers/future';
@ -13,59 +12,9 @@ import moment from 'moment';
import { logger } from '../logger';
import { processWebhookMessage } from '../../../lib';
import { API } from '../../../api';
import { API, APIClass, defaultRateLimiterOptions } from '../../../api';
import * as Models from '../../../models';
const Api = new Restivus({
enableCors: true,
apiPath: 'hooks/',
auth: {
user() {
const payloadKeys = Object.keys(this.bodyParams);
const payloadIsWrapped = (this.bodyParams && this.bodyParams.payload) && payloadKeys.length === 1;
if (payloadIsWrapped && this.request.headers['content-type'] === 'application/x-www-form-urlencoded') {
try {
this.bodyParams = JSON.parse(this.bodyParams.payload);
} catch ({ message }) {
return {
error: {
statusCode: 400,
body: {
success: false,
error: message,
},
},
};
}
}
this.integration = Models.Integrations.findOne({
_id: this.request.params.integrationId,
token: decodeURIComponent(this.request.params.token),
});
if (!this.integration) {
logger.incoming.info('Invalid integration id', this.request.params.integrationId, 'or token', this.request.params.token);
return {
error: {
statusCode: 404,
body: {
success: false,
error: 'Invalid integration id or token provided.',
},
},
};
}
const user = Models.Users.findOne({
_id: this.integration.userId,
});
return { user };
},
},
});
import { settings } from '../../../settings/server';
const compiledScripts = {};
function buildSandbox(store = {}) {
@ -368,6 +317,98 @@ function integrationInfoRest() {
};
}
class WebHookAPI extends APIClass {
/* Webhooks are not versioned, so we must not validate we know a version before adding a rate limiter */
shouldAddRateLimitToRoute(options) {
const { rateLimiterOptions } = options;
return (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS);
}
shouldVerifyRateLimit(/* route */) {
return settings.get('API_Enable_Rate_Limiter') === true
&& (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true);
}
/*
There is only one generic route propagated to Restivus which has URL-path-parameters for the integration and the token.
Since the rate-limiter operates on absolute routes, we need to add a limiter to the absolute url before we can validate it
*/
enforceRateLimit(objectForRateLimitMatch, request, response) {
const { method, url } = request;
const route = url.replace(`/${ this.apiPath }`, '');
const nameRoute = this.getFullRouteName(route, [method.toLowerCase()]);
// We'll be creating rate limiters on demand (when validating for the first time).
// This is possible since *all* integration hooks should be rate limited the same way.
// This way, we'll not have to add new limiters as new integrations are added
if (!this.getRateLimiter(nameRoute)) {
this.addRateLimiterRuleForRoutes({
routes: [route],
rateLimiterOptions: defaultRateLimiterOptions,
endpoints: {
post: executeIntegrationRest,
get: executeIntegrationRest,
},
});
}
const integrationForRateLimitMatch = objectForRateLimitMatch;
integrationForRateLimitMatch.route = nameRoute;
super.enforceRateLimit(integrationForRateLimitMatch, request, response);
}
}
const Api = new WebHookAPI({
enableCors: true,
apiPath: 'hooks/',
auth: {
user() {
const payloadKeys = Object.keys(this.bodyParams);
const payloadIsWrapped = (this.bodyParams && this.bodyParams.payload) && payloadKeys.length === 1;
if (payloadIsWrapped && this.request.headers['content-type'] === 'application/x-www-form-urlencoded') {
try {
this.bodyParams = JSON.parse(this.bodyParams.payload);
} catch ({ message }) {
return {
error: {
statusCode: 400,
body: {
success: false,
error: message,
},
},
};
}
}
this.integration = Models.Integrations.findOne({
_id: this.request.params.integrationId,
token: decodeURIComponent(this.request.params.token),
});
if (!this.integration) {
logger.incoming.info('Invalid integration id', this.request.params.integrationId, 'or token', this.request.params.token);
return {
error: {
statusCode: 404,
body: {
success: false,
error: 'Invalid integration id or token provided.',
},
},
};
}
const user = Models.Users.findOne({
_id: this.integration.userId,
});
return { user };
},
},
});
Api.addRoute(':integrationId/:userId/:token', { authRequired: true }, {
post: executeIntegrationRest,
get: executeIntegrationRest,

Loading…
Cancel
Save