import vm from 'vm'; import { Meteor } from 'meteor/meteor'; import { HTTP } from 'meteor/http'; import { Random } from 'meteor/random'; import { Livechat } from 'meteor/rocketchat:livechat'; import Fiber from 'fibers'; import Future from 'fibers/future'; import _ from 'underscore'; import s from 'underscore.string'; import moment from 'moment'; import { incomingLogger } from '../logger'; import { processWebhookMessage } from '../../../lib/server'; import { API, APIClass, defaultRateLimiterOptions } from '../../../api/server'; import * as Models from '../../../models/server'; import { Integrations } from '../../../models/server/raw'; import { settings } from '../../../settings/server'; const compiledScripts = {}; function buildSandbox(store = {}) { const sandbox = { scriptTimeout(reject) { return setTimeout(() => reject('timed out'), 3000); }, _, s, console, moment, Fiber, Promise, Livechat, Store: { set(key, val) { store[key] = val; return val; }, get(key) { return store[key]; }, }, HTTP(method, url, options) { try { return { result: HTTP.call(method, url, options), }; } catch (error) { return { error, }; } }, }; Object.keys(Models) .filter((k) => !k.startsWith('_')) .forEach((k) => { sandbox[k] = Models[k]; }); return { store, sandbox }; } function getIntegrationScript(integration) { const compiledScript = compiledScripts[integration._id]; if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { return compiledScript.script; } const script = integration.scriptCompiled; const { sandbox, store } = buildSandbox(); try { incomingLogger.info({ msg: 'Will evaluate script of Trigger', name: integration.name }); incomingLogger.debug(script); const vmScript = vm.createScript(script, 'script.js'); vmScript.runInNewContext(sandbox); if (sandbox.Script) { compiledScripts[integration._id] = { script: new sandbox.Script(), store, _updatedAt: integration._updatedAt, }; return compiledScripts[integration._id].script; } } catch (err) { incomingLogger.error({ msg: 'Error evaluating Script in Trigger', name: integration.name, script, err, }); throw API.v1.failure('error-evaluating-script'); } if (!sandbox.Script) { incomingLogger.error({ msg: 'Class "Script" not in Trigger', name: integration.name }); throw API.v1.failure('class-script-not-found'); } } function createIntegration(options, user) { incomingLogger.info({ msg: 'Add integration', name: options.name }); incomingLogger.debug(options); Meteor.runAsUser(user._id, function () { switch (options.event) { case 'newMessageOnChannel': if (options.data == null) { options.data = {}; } if (options.data.channel_name != null && options.data.channel_name.indexOf('#') === -1) { options.data.channel_name = `#${options.data.channel_name}`; } return Meteor.call('addOutgoingIntegration', { username: 'rocket.cat', urls: [options.target_url], name: options.name, channel: options.data.channel_name, triggerWords: options.data.trigger_words, }); case 'newMessageToUser': if (options.data.username.indexOf('@') === -1) { options.data.username = `@${options.data.username}`; } return Meteor.call('addOutgoingIntegration', { username: 'rocket.cat', urls: [options.target_url], name: options.name, channel: options.data.username, triggerWords: options.data.trigger_words, }); } }); return API.v1.success(); } function removeIntegration(options, user) { incomingLogger.info('Remove integration'); incomingLogger.debug(options); const integrationToRemove = Promise.await(Integrations.findOneByUrl(options.target_url)); if (!integrationToRemove) { return API.v1.failure('integration-not-found'); } Meteor.runAsUser(user._id, () => Meteor.call('deleteOutgoingIntegration', integrationToRemove._id)); return API.v1.success(); } function executeIntegrationRest() { incomingLogger.info({ msg: 'Post integration:', name: this.integration.name }); incomingLogger.debug({ urlParams: this.urlParams, bodyParams: this.bodyParams }); if (this.integration.enabled !== true) { return { statusCode: 503, body: 'Service Unavailable', }; } const defaultValues = { channel: this.integration.channel, alias: this.integration.alias, avatar: this.integration.avatar, emoji: this.integration.emoji, }; if (this.integration.scriptEnabled && this.integration.scriptCompiled && this.integration.scriptCompiled.trim() !== '') { let script; try { script = getIntegrationScript(this.integration); } catch (e) { incomingLogger.error(e); return API.v1.failure(e.message); } this.request.setEncoding('utf8'); const content_raw = this.request.read(); const request = { url: { hash: this.request._parsedUrl.hash, search: this.request._parsedUrl.search, query: this.queryParams, pathname: this.request._parsedUrl.pathname, path: this.request._parsedUrl.path, }, url_raw: this.request.url, url_params: this.urlParams, content: this.bodyParams, content_raw, headers: this.request.headers, body: this.request.body, user: { _id: this.user._id, name: this.user.name, username: this.user.username, }, }; try { const { sandbox } = buildSandbox(compiledScripts[this.integration._id].store); sandbox.script = script; sandbox.request = request; const result = Future.fromPromise( vm.runInNewContext( ` new Promise((resolve, reject) => { Fiber(() => { scriptTimeout(reject); try { resolve(script.process_incoming_request({ request: request })); } catch(e) { reject(e); } }).run(); }).catch((error) => { throw new Error(error); }); `, sandbox, { timeout: 3000, }, ), ).wait(); if (!result) { incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger has no data', name: this.integration.name, }); return API.v1.success(); } if (result && result.error) { return API.v1.failure(result.error); } this.bodyParams = result && result.content; this.scriptResponse = result.response; if (result.user) { this.user = result.user; } incomingLogger.debug({ msg: 'Process Incoming Request result of Trigger', name: this.integration.name, result: this.bodyParams, }); } catch (err) { incomingLogger.error({ msg: 'Error running Script in Trigger', name: this.integration.name, script: this.integration.scriptCompiled, err, }); return API.v1.failure('error-running-script'); } } // TODO: Turn this into an option on the integrations - no body means a success // TODO: Temporary fix for https://github.com/RocketChat/Rocket.Chat/issues/7770 until the above is implemented if (!this.bodyParams || (_.isEmpty(this.bodyParams) && !this.integration.scriptEnabled)) { // return RocketChat.API.v1.failure('body-empty'); return API.v1.success(); } this.bodyParams.bot = { i: this.integration._id }; try { const message = processWebhookMessage(this.bodyParams, this.user, defaultValues); if (_.isEmpty(message)) { return API.v1.failure('unknown-error'); } if (this.scriptResponse) { incomingLogger.debug({ msg: 'response', response: this.scriptResponse }); } return API.v1.success(this.scriptResponse); } catch ({ error, message }) { return API.v1.failure(error || message); } } function addIntegrationRest() { return createIntegration(this.bodyParams, this.user); } function removeIntegrationRest() { return removeIntegration(this.bodyParams, this.user); } function integrationSampleRest() { incomingLogger.info('Sample Integration'); return { statusCode: 200, body: [ { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 1', trigger_word: 'Sample', }, { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 2', trigger_word: 'Sample', }, { token: Random.id(24), channel_id: Random.id(), channel_name: 'general', timestamp: new Date(), user_id: Random.id(), user_name: 'rocket.cat', text: 'Sample text 3', trigger_word: 'Sample', }, ], }; } function integrationInfoRest() { incomingLogger.info('Info integration'); return { statusCode: 200, body: { success: true, }, }; } 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, userId) { 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, userId); } } 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 = Promise.await( Integrations.findOne({ _id: this.request.params.integrationId, token: decodeURIComponent(this.request.params.token), }), ); if (!this.integration) { incomingLogger.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, }, ); Api.addRoute( ':integrationId/:token', { authRequired: true }, { post: executeIntegrationRest, get: executeIntegrationRest, }, ); Api.addRoute( 'sample/:integrationId/:userId/:token', { authRequired: true }, { get: integrationSampleRest, }, ); Api.addRoute( 'sample/:integrationId/:token', { authRequired: true }, { get: integrationSampleRest, }, ); Api.addRoute( 'info/:integrationId/:userId/:token', { authRequired: true }, { get: integrationInfoRest, }, ); Api.addRoute( 'info/:integrationId/:token', { authRequired: true }, { get: integrationInfoRest, }, ); Api.addRoute( 'add/:integrationId/:userId/:token', { authRequired: true }, { post: addIntegrationRest, }, ); Api.addRoute( 'add/:integrationId/:token', { authRequired: true }, { post: addIntegrationRest, }, ); Api.addRoute( 'remove/:integrationId/:userId/:token', { authRequired: true }, { post: removeIntegrationRest, }, ); Api.addRoute( 'remove/:integrationId/:token', { authRequired: true }, { post: removeIntegrationRest, }, );