snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import type { IOutgoingIntegration, IIntegration } from '@rocket.chat/core-typings';
import { Integrations, IntegrationHistory } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import notifications from '../../notifications/core/lib/Notifications';
import { triggerHandler } from '../lib/triggerHandler';
export const clearIntegrationHistoryMethod = async (userId: string, integrationId: string): Promise<void> => {
let integration: IIntegration | null = null;
if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) {
integration = await Integrations.findOneById<IIntegration>(integrationId);
} else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) {
integration = await Integrations.findOne<IIntegration>({
'_id': integrationId,
'_createdBy._id': userId,
});
} else {
throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' });
}
if (!integration) {
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'clearIntegrationHistory' });
}
await IntegrationHistory.removeByIntegrationId(integrationId);
notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed', id: integrationId });
};
export const replayOutgoingIntegrationMethod = async (
userId: string,
{ integrationId, historyId }: { integrationId: string; historyId: string },
): Promise<void> => {
let integration: IOutgoingIntegration | null = null;
if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) {
integration = await Integrations.findOneById<IOutgoingIntegration>(integrationId);
} else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) {
const found = await Integrations.findOne<IOutgoingIntegration>({
'_id': integrationId,
'_createdBy._id': userId,
});
if (found && 'event' in found) {
integration = found;
}
}
if (!integration) {
throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'replayOutgoingIntegration' });
}
const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId);
if (!history) {
throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { method: 'replayOutgoingIntegration' });
}
await triggerHandler.replay(integration, history);
};

View File

@@ -0,0 +1,399 @@
import type {
IUser,
IRoom,
IMessage,
IOutgoingIntegration,
IIncomingIntegration,
IIntegration,
IIntegrationHistory,
} from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import type { serverFetch } from '@rocket.chat/server-fetch';
import { wrapExceptions } from '@rocket.chat/tools';
import { incomingLogger, outgoingLogger } from '../logger';
import type { IScriptClass, CompiledScript } from './definition';
import { updateHistory } from './updateHistory';
type OutgoingRequestBaseData = {
token: IOutgoingIntegration['token'];
bot: boolean;
trigger_word: string;
};
type OutgoingRequestSendMessageData = OutgoingRequestBaseData & {
channel_id: string;
channel_name: string;
message_id: string;
timestamp: Date;
user_id: string;
user_name: string;
text: string;
siteUrl: string;
alias?: string;
bot?: boolean;
isEdited?: true;
tmid?: string;
};
type OutgoingRequestUploadedFileData = OutgoingRequestBaseData & {
channel_id: string;
channel_name: string;
message_id: string;
timestamp: Date;
user_id: string;
user_name: string;
text: string;
user: IUser;
room: IRoom;
message: IMessage;
alias?: string;
bot?: boolean;
};
type OutgoingRequestRoomCreatedData = OutgoingRequestBaseData & {
channel_id: string;
channel_name: string;
timestamp: Date;
user_id: string;
user_name: string;
owner: IUser;
room: IRoom;
};
type OutgoingRequestRoomData = OutgoingRequestBaseData & {
channel_id: string;
channel_name: string;
timestamp: Date;
user_id: string;
user_name: string;
owner: IUser;
room: IRoom;
bot?: boolean;
};
type OutgoingRequestUserCreatedData = OutgoingRequestBaseData & {
timestamp: Date;
user_id: string;
user_name: string;
user: IUser;
bot?: boolean;
};
export type OutgoingRequestData =
| OutgoingRequestSendMessageData
| OutgoingRequestUploadedFileData
| OutgoingRequestRoomCreatedData
| OutgoingRequestRoomData
| OutgoingRequestUserCreatedData;
type OutgoingRequest = {
params: Record<never, never>;
method: 'POST';
url: string;
data: OutgoingRequestData;
auth: undefined;
headers: Record<string, string>;
};
type OutgoingRequestFromScript = {
url?: string;
headers?: Record<string, string>;
method?: string;
message?: {
text?: string;
channel?: string;
attachments?: {
color?: string;
author_name?: string;
author_link?: string;
author_icon?: string;
title?: string;
title_link?: string;
text?: string;
fields?: {
title?: string;
value?: string;
short?: boolean;
}[];
image_url?: string;
thumb_url?: string;
}[];
};
auth?: string;
data?: Record<string, any>;
};
type OutgoingRequestContext = {
integration: IOutgoingIntegration;
data: OutgoingRequestData;
historyId: IIntegrationHistory['_id'];
url: string;
};
type ProcessedOutgoingRequest = OutgoingRequest | OutgoingRequestFromScript;
type OutgoingResponseContext = {
integration: IOutgoingIntegration;
request: ProcessedOutgoingRequest;
response: Awaited<ReturnType<typeof serverFetch>>;
content: string;
historyId: IIntegrationHistory['_id'];
};
type IncomingIntegrationRequest = {
url: {
hash: string | null | undefined;
search: string | null | undefined;
query: Record<string, any>;
pathname: string | null | undefined;
path: string | null | undefined;
};
url_raw: string;
url_params: Record<string, string>;
content: Record<string, any>;
content_raw: string;
headers: Record<string, string>;
body: Record<string, any>;
user: Pick<Required<IUser>, '_id' | 'name' | 'username'>;
};
export abstract class IntegrationScriptEngine<IsIncoming extends boolean> {
protected compiledScripts: Record<IIntegration['_id'], CompiledScript>;
public get disabled(): boolean {
return this.isDisabled();
}
public get incoming(): IsIncoming {
return this.isIncoming;
}
constructor(private isIncoming: IsIncoming) {
this.compiledScripts = {};
}
public integrationHasValidScript(integration: IIntegration): boolean {
return Boolean(!this.disabled && integration.scriptEnabled && integration.scriptCompiled && integration.scriptCompiled.trim() !== '');
}
// PrepareOutgoingRequest will execute a script to build the request object that will be used for the actual integration request
// It may also return a message object to be sent to the room where the integration was triggered
public async prepareOutgoingRequest({ integration, data, historyId, url }: OutgoingRequestContext): Promise<ProcessedOutgoingRequest> {
const request: OutgoingRequest = {
params: {},
method: 'POST',
url,
data,
auth: undefined,
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36',
},
};
if (!(await this.hasScriptAndMethod(integration, 'prepare_outgoing_request'))) {
return request;
}
return this.executeOutgoingScript(integration, 'prepare_outgoing_request', { request }, historyId);
}
public async processOutgoingResponse({
integration,
request,
response,
content,
historyId,
}: OutgoingResponseContext): Promise<string | false | undefined> {
if (!(await this.hasScriptAndMethod(integration, 'process_outgoing_response'))) {
return;
}
const sandbox = {
request,
response: {
error: null,
status_code: response.status,
content,
content_raw: content,
headers: Object.fromEntries(response.headers),
},
};
const scriptResult = await this.executeOutgoingScript(integration, 'process_outgoing_response', sandbox, historyId);
if (scriptResult === false) {
return scriptResult;
}
if (scriptResult?.content) {
return scriptResult.content;
}
}
public async processIncomingRequest({
integration,
request,
}: {
integration: IIncomingIntegration;
request: IncomingIntegrationRequest;
}): Promise<any> {
return this.executeIncomingScript(integration, 'process_incoming_request', { request });
}
protected get logger(): ReturnType<Logger['section']> {
if (this.isIncoming) {
return incomingLogger;
}
return outgoingLogger;
}
protected async executeOutgoingScript(
integration: IOutgoingIntegration,
method: keyof IScriptClass,
params: Record<string, any>,
historyId: IIntegrationHistory['_id'],
): Promise<any> {
if (this.disabled) {
return;
}
const script = await wrapExceptions(() => this.getIntegrationScript(integration)).suppress((e: any) =>
updateHistory({
historyId,
step: 'execute-script-getting-script',
error: true,
errorStack: e,
}),
);
if (!script) {
return;
}
if (!script[method]) {
this.logger.error({
msg: 'Script method not found in the Integration',
method,
integration: integration.name,
});
await updateHistory({ historyId, step: `execute-script-no-method-${method}` });
return;
}
try {
await updateHistory({ historyId, step: `execute-script-before-running-${method}` });
const result = await this.runScriptMethod({
integrationId: integration._id,
script,
method,
params,
});
this.logger.debug({
msg: 'Script method result of the Integration',
method,
integration: integration.name,
result,
});
return result;
} catch (err: any) {
await updateHistory({
historyId,
step: `execute-script-error-running-${method}`,
error: true,
errorStack: err.stack.replace(/^/gm, ' '),
});
this.logger.error({
msg: 'Error running Script in the Integration',
integration: integration.name,
err,
});
this.logger.debug({
msg: 'Error running Script in the Integration',
integration: integration.name,
script: integration.scriptCompiled,
});
}
}
protected async executeIncomingScript(
integration: IIncomingIntegration,
method: keyof IScriptClass,
params: Record<string, any>,
): Promise<any> {
if (!this.integrationHasValidScript(integration)) {
return;
}
const script = await wrapExceptions(() => this.getIntegrationScript(integration)).catch((e) => {
this.logger.error({
msg: 'Error getting Integration script',
integration: integration.name,
err: e,
});
throw e;
});
if (!script[method]) {
this.logger.error({
msg: 'Script method not found in the Integration',
method,
integration: integration.name,
});
return;
}
return wrapExceptions(() =>
this.runScriptMethod({
integrationId: integration._id,
script,
method,
params,
}),
).catch((err: any) => {
this.logger.error({
msg: 'Error running Script in Trigger',
integration: integration.name,
script: integration.scriptCompiled,
err,
});
throw new Error('error-running-script');
});
}
protected async hasScriptAndMethod(integration: IIntegration, method: keyof IScriptClass): Promise<boolean> {
const script = await this.getScriptSafely(integration);
return typeof script?.[method] === 'function';
}
protected async getScriptSafely(integration: IIntegration): Promise<Partial<IScriptClass> | undefined> {
if (this.disabled || integration.scriptEnabled !== true || !integration.scriptCompiled || integration.scriptCompiled.trim() === '') {
return;
}
return wrapExceptions(() => this.getIntegrationScript(integration)).suppress();
}
protected abstract isDisabled(): boolean;
protected abstract runScriptMethod({
integrationId,
script,
method,
params,
}: {
integrationId: IIntegration['_id'];
script: IScriptClass;
method: keyof IScriptClass;
params: Record<string, any>;
}): Promise<any>;
protected abstract getIntegrationScript(integration: IIntegration): Promise<Partial<IScriptClass>>;
}

View File

@@ -0,0 +1,71 @@
import vm from 'node:vm';
import { transformSync } from '@babel/core';
import presetEnv from '@babel/preset-env';
/**
* Compile or validate a user-supplied integration script for storage in
* `scriptCompiled`.
*
* When `transpile` is `true` (the default, controlled by each integration's
* `skipTranspile` flag), the script is transpiled with `@babel/core +
* @babel/preset-env` — the historical behavior. When `false`, the script is
* validated with Node's built-in `vm.Script` and stored as-is, matching the
* 9.0.0 default where Babel transpilation is removed entirely.
*
* Integration scripts run inside `isolated-vm`, which embeds modern V8 and
* handles ES2023+ natively. The transpilation only exists to preserve the
* sloppy-mode semantics (implicit globals in class methods, `this` in nested
* functions, etc.) that early scripts relied on. Admins can flip
* `skipTranspile: true` per integration to test strict-mode compatibility
* before the 9.0.0 upgrade.
*
* Returns `{ script }` on success or `{ error }` with the same
* `{ name, message, stack }` shape persisted in `scriptError`.
*/
export function compileIntegrationScript(
script: string,
{ transpile }: { transpile: boolean },
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
if (!transpile) {
return validateOnly(script);
}
return transpileWithBabel(script);
}
function validateOnly(
script: string,
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
try {
new vm.Script(`(function(){${script}})`);
return { script };
} catch (e) {
if (e instanceof SyntaxError) {
const { name, message, stack } = e;
return { error: { name, message, stack } };
}
throw e;
}
}
function transpileWithBabel(
script: string,
): { script: string; error?: undefined } | { script?: undefined; error: Pick<Error, 'name' | 'message' | 'stack'> } {
try {
const result = transformSync(script, {
presets: [presetEnv],
compact: true,
minified: true,
comments: false,
});
return { script: result?.code ?? script };
} catch (e) {
if (e instanceof Error) {
const { name, message, stack } = e;
return { error: { name, message, stack } };
}
throw e;
}
}

View File

@@ -0,0 +1,24 @@
import type { IIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings';
export interface IScriptClass {
prepare_outgoing_request?: (params: Record<string, any>) => any;
process_outgoing_response?: (params: Record<string, any>) => any;
process_incoming_request?: (params: Record<string, any>) => any;
}
export type FullScriptClass = Required<IScriptClass>;
export type CompiledScript = {
script: Partial<IScriptClass>;
store: Record<string, any>;
_updatedAt: IIntegration['_updatedAt'];
};
export type CompatibilityScriptResult = IScriptClass & {
availableFunctions: (keyof IScriptClass)[];
};
export const isOutgoingIntegration = (
integration: Partial<IIntegration>,
): integration is IOutgoingIntegration & { type: 'webhook-outgoing' } =>
integration.type === 'webhook-outgoing' && 'event' in integration && integration.event !== undefined;

View File

@@ -0,0 +1,128 @@
import { EventEmitter } from 'node:events';
import { serverFetch as fetch, Response } from '@rocket.chat/server-fetch';
import ivm, { type Context } from 'isolated-vm';
import * as s from '../../../../../lib/utils/stringUtils';
const proxyObject = (obj: Record<string, any>, forbiddenKeys: string[] = []): Record<string, any> => {
return copyObject({
isProxy: true,
get: (key: string) => {
if (forbiddenKeys.includes(key)) {
return undefined;
}
const value = obj[key];
if (typeof value === 'function') {
return new ivm.Reference(async (...args: any[]) => {
const result = (obj[key] as any)(...args);
if (result && result instanceof Promise) {
return new Promise(async (resolve, reject) => {
try {
const awaitedResult = await result;
resolve(makeTransferable(awaitedResult));
} catch (e) {
reject(e);
}
});
}
return makeTransferable(result);
});
}
return makeTransferable(value);
},
});
};
const copyObject = (obj: Record<string, any> | any[]): Record<string, any> | any[] => {
if (Array.isArray(obj)) {
return obj.map((data) => copyData(data));
}
if (obj instanceof Response) {
return proxyObject(obj, ['clone']);
}
if (isSemiTransferable(obj)) {
return obj;
}
if (typeof obj[Symbol.iterator as any] === 'function') {
return copyObject(Array.from(obj as any));
}
if (obj instanceof EventEmitter) {
return {};
}
const keys = Object.keys(obj);
return {
...Object.fromEntries(
keys.map((key) => {
const data = obj[key];
if (typeof data === 'function') {
return [key, new ivm.Callback((...args: any[]) => obj[key](...args))];
}
return [key, copyData(data)];
}),
),
};
};
// Transferable data can be passed to isolates directly
const isTransferable = (data: any): data is ivm.Transferable => {
const dataType = typeof data;
if (data === ivm) {
return true;
}
if (['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes(dataType)) {
return true;
}
if (dataType !== 'object') {
return false;
}
return (
data instanceof ivm.Isolate ||
data instanceof ivm.Context ||
data instanceof ivm.Script ||
data instanceof ivm.ExternalCopy ||
data instanceof ivm.Callback ||
data instanceof ivm.Reference
);
};
// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation.
const isSemiTransferable = (data: any) => data instanceof ArrayBuffer;
const copyData = <T extends ivm.Transferable | Record<string, any> | any[]>(data: T) => (isTransferable(data) ? data : copyObject(data));
const makeTransferable = (data: any) => (isTransferable(data) ? data : new ivm.ExternalCopy(copyObject(data)).copyInto());
export const buildSandbox = (context: Context) => {
const { global: jail } = context;
jail.setSync('global', jail.derefInto());
jail.setSync('ivm', ivm);
jail.setSync('s', makeTransferable(s));
jail.setSync('console', makeTransferable(console));
jail.setSync(
'serverFetch',
new ivm.Reference(async (url: string, options?: any, allowSelfSignedCerts?: boolean) => {
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
const result = await fetch(url, { ...(options ?? {}), ignoreSsrfValidation: true }, allowSelfSignedCerts);
return makeTransferable(result);
}),
);
};

View File

@@ -0,0 +1,60 @@
export const getCompatibilityScript = (customScript?: string) => `
const Store = (function() {
const store = {};
return {
set(key, val) {
store[key] = val;
return val;
},
get(key) {
return store[key];
},
};
})();
const reproxy = (reference) => {
return new Proxy(reference, {
get(target, p, receiver) {
if (target !== reference || p === 'then') {
return Reflect.get(target, p, receiver);
}
const data = reference.get(p);
if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') {
return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } });
}
return data;
}
});
};
//url, options, allowSelfSignedCertificate
const fetch = async (...args) => {
const result = await serverFetch.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } });
if (result && typeof result === 'object' && result.isProxy) {
return reproxy(result);
}
return result;
};
${customScript}
(function() {
const instance = new Script();
const functions = {
...(typeof instance['prepare_outgoing_request'] === 'function' ? { prepare_outgoing_request : (...args) => instance.prepare_outgoing_request(...args) } : {}),
...(typeof instance['process_outgoing_response'] === 'function' ? { process_outgoing_response : (...args) => instance.process_outgoing_response(...args) } : {}),
...(typeof instance['process_incoming_request'] === 'function' ? { process_incoming_request : (...args) => instance.process_incoming_request(...args) } : {}),
};
return {
...functions,
availableFunctions: Object.keys(functions),
}
})();
`;

View File

@@ -0,0 +1,99 @@
import type { IIntegration, ValueOf } from '@rocket.chat/core-typings';
import { pick } from '@rocket.chat/tools';
import ivm, { type Reference } from 'isolated-vm';
import { IntegrationScriptEngine } from '../ScriptEngine';
import type { IScriptClass, CompatibilityScriptResult, FullScriptClass } from '../definition';
import { buildSandbox } from './buildSandbox';
import { getCompatibilityScript } from './getCompatibilityScript';
const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'ivm'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase());
export class IsolatedVMScriptEngine<IsIncoming extends boolean> extends IntegrationScriptEngine<IsIncoming> {
protected isDisabled(): boolean {
return DISABLE_INTEGRATION_SCRIPTS;
}
protected async callScriptFunction(
scriptReference: Reference<ValueOf<IScriptClass>>,
...params: Parameters<ValueOf<FullScriptClass>>
): Promise<any> {
return scriptReference.applySync(undefined, params, {
arguments: { copy: true },
result: { copy: true, promise: true },
});
}
protected async runScriptMethod({
script,
method,
params,
}: {
integrationId: IIntegration['_id'];
script: Partial<IScriptClass>;
method: keyof IScriptClass;
params: Record<string, any>;
}): Promise<any> {
const fn = script[method];
if (typeof fn !== 'function') {
throw new Error('integration-method-not-found');
}
return fn(params);
}
protected async getIntegrationScript(integration: IIntegration): Promise<Partial<IScriptClass>> {
if (this.disabled) {
throw new Error('integration-scripts-disabled');
}
const compiledScript = this.compiledScripts[integration._id];
if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) {
return compiledScript.script;
}
const script = integration.scriptCompiled;
try {
this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') });
this.logger.debug({ script });
const isolate = new ivm.Isolate({ memoryLimit: 8 });
const ivmScript = await isolate.compileScript(getCompatibilityScript(script));
const ivmContext = isolate.createContextSync();
buildSandbox(ivmContext);
const ivmResult: Reference<CompatibilityScriptResult> = await ivmScript.run(ivmContext, {
reference: true,
timeout: 3000,
});
const availableFunctions = await ivmResult.get('availableFunctions', { copy: true });
const scriptFunctions = Object.fromEntries(
availableFunctions.map((functionName) => {
const fnReference = ivmResult.getSync(functionName, { reference: true });
return [functionName, (...params: Parameters<ValueOf<FullScriptClass>>) => this.callScriptFunction(fnReference, ...params)];
}),
) as Partial<IScriptClass>;
this.compiledScripts[integration._id] = {
script: scriptFunctions,
store: {},
_updatedAt: integration._updatedAt,
};
return scriptFunctions;
} catch (err: any) {
this.logger.error({
msg: 'Error evaluating integration script',
integration: integration.name,
script,
err,
});
throw new Error('error-evaluating-script');
}
}
}

View File

@@ -0,0 +1,55 @@
import type { DeepWritable } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import type { Filter } from 'mongodb';
import { hasPermissionAsync } from '../../authorization/hasPermission';
export const mountIntegrationQueryBasedOnPermissions = async (userId: string) => {
if (!userId) {
throw new Meteor.Error('You must provide the userId to the "mountIntegrationQueryBasedOnPermissions" function.');
}
const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations');
const canViewAllIncomingIntegrations = await hasPermissionAsync(userId, 'manage-incoming-integrations');
const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations');
const canViewOnlyOwnIncomingIntegrations = await hasPermissionAsync(userId, 'manage-own-incoming-integrations');
const query: DeepWritable<Filter<any>> = {};
if (canViewAllOutgoingIntegrations && canViewAllIncomingIntegrations) {
return query;
}
if (canViewOnlyOwnOutgoingIntegrations && canViewOnlyOwnIncomingIntegrations) {
return { '_createdBy._id': userId };
}
query.$or = [];
if (canViewAllOutgoingIntegrations) {
query.$or.push({ type: 'webhook-outgoing' });
}
if (canViewAllIncomingIntegrations) {
query.$or.push({ type: 'webhook-incoming' });
}
if (canViewOnlyOwnOutgoingIntegrations) {
query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-outgoing' });
}
if (canViewOnlyOwnIncomingIntegrations) {
query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-incoming' });
}
return query;
};
export const mountIntegrationHistoryQueryBasedOnPermissions = async (userId: string, integrationId: string) => {
if (!userId) {
throw new Meteor.Error('You must provide the userId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.');
}
if (!integrationId) {
throw new Meteor.Error('You must provide the integrationId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.');
}
const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations');
const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations');
if (!canViewAllOutgoingIntegrations && canViewOnlyOwnOutgoingIntegrations) {
return { 'integration._id': integrationId, 'integration._createdBy._id': userId };
}
return { 'integration._id': integrationId };
};

View File

@@ -0,0 +1,840 @@
import type {
IIntegrationHistory,
IMessage,
IOutgoingIntegration,
IUser,
OutgoingIntegrationEvent,
IRoom,
RequiredField,
AtLeast,
} from '@rocket.chat/core-typings';
import { Integrations, Users, Rooms, Messages } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { wrapExceptions } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import type { OutgoingRequestData } from './ScriptEngine';
import { IsolatedVMScriptEngine } from './isolated-vm/isolated-vm';
import { updateHistory } from './updateHistory';
import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents';
import { settings } from '../../../settings';
import { processWebhookMessage } from '../../messages/processWebhookMessage';
import { notifyOnIntegrationChangedById } from '../../notifyListener';
import { getRoomByNameOrIdWithOptionToJoin } from '../../rooms/getRoomByNameOrIdWithOptionToJoin';
import { outgoingLogger } from '../logger';
type Trigger = Record<string, Record<string, any>>;
type MessageWithEditedAt = IMessage & { editedAt?: Date };
type ArgumentsObject = {
event?: OutgoingIntegrationEvent;
message?: MessageWithEditedAt;
room?: IRoom;
owner?: IUser;
user?: IUser;
};
type IntegrationData = {
token?: string;
bot: boolean;
trigger_word?: string;
channel_id?: string;
channel_name?: string;
message_id?: string;
timestamp?: Date;
user_id?: string;
user_name?: string;
text?: string;
siteUrl?: string;
alias?: string;
isEdited?: boolean;
tmid?: string;
user?: Partial<IUser>;
room?: IRoom;
message?: IMessage;
owner?: Partial<IUser>;
};
class RocketChatIntegrationHandler {
private successResults: number[];
private triggers: Trigger;
private ivmEngine: IsolatedVMScriptEngine<false>;
constructor() {
this.successResults = [200, 201, 202];
this.triggers = {};
this.ivmEngine = new IsolatedVMScriptEngine(false);
}
addIntegration(record: IOutgoingIntegration): void {
outgoingLogger.debug({ msg: 'Adding integration', integrationName: record.name, event: record.event });
let channels = [];
if (record.event && !outgoingEvents[record.event].use.channel) {
outgoingLogger.debug('The integration doesnt rely on channels.');
// We don't use any channels, so it's special ;)
channels = ['__any'];
} else if (_.isEmpty(record.channel)) {
outgoingLogger.debug('The integration had an empty channel property, so it is going on all the public channels.');
channels = ['all_public_channels'];
} else {
outgoingLogger.debug({ msg: 'The integration is going on these channels', channels: record.channel });
channels = ([] as string[]).concat(record.channel);
}
for (const channel of channels) {
if (!this.triggers[channel]) {
this.triggers[channel] = {};
}
this.triggers[channel][record._id] = record;
}
}
getEngine(_integration: any): IsolatedVMScriptEngine<false> {
return this.ivmEngine;
}
removeIntegration(record: AtLeast<IOutgoingIntegration, '_id'>): void {
for (const trigger of Object.values(this.triggers)) {
delete trigger[record._id];
}
}
isTriggerEnabled(trigger: IOutgoingIntegration): boolean {
for (const trig of Object.values(this.triggers)) {
if (trig[trigger._id]) {
return trig[trigger._id].enabled;
}
}
return false;
}
// Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful.
async sendMessage({
trigger,
nameOrId = '',
room,
message,
data,
}: {
trigger: IOutgoingIntegration;
nameOrId?: string;
room?: IRoom;
message: { channel: string; bot?: Record<string, any>; message: Partial<IMessage> };
data: IntegrationData;
}): Promise<{ channel: string; message: Partial<IMessage> }[] | undefined> {
let user: IUser | null = null;
// Try to find the user who we are impersonating
if (trigger.impersonateUser) {
user = await Users.findOneByUsernameIgnoringCase(data.user_name);
}
// If they don't exist (aka the trigger didn't contain a user) then we set the user based upon the
// configured username for the integration since this is required at all times.
if (!user) {
user = await Users.findOneByUsernameIgnoringCase(trigger.username);
}
if (!user) {
outgoingLogger.error({ msg: 'Integration user not found', username: trigger.username });
return;
}
let tmpRoom;
if (nameOrId || trigger.targetRoom || message.channel) {
tmpRoom =
(await getRoomByNameOrIdWithOptionToJoin({
user,
nameOrId: nameOrId || message.channel || trigger.targetRoom || '',
errorOnEmpty: false,
})) || room;
} else {
tmpRoom = room;
}
// If no room could be found, we won't be sending any messages but we'll warn in the logs
if (!tmpRoom) {
outgoingLogger.warn({
msg: 'The Integration doesnt have a room configured nor did it provide a room to send the message to.',
integrationName: trigger.name,
});
return;
}
outgoingLogger.debug({ msg: 'Found room for integration', integrationName: trigger.name, roomName: tmpRoom.name, roomType: tmpRoom.t });
message.bot = { i: trigger._id };
const defaultValues: {
alias: string;
avatar: string;
emoji: string;
channel: string;
} = {
alias: trigger.alias || '',
avatar: trigger.avatar || '',
emoji: trigger.emoji || '',
channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`,
};
return processWebhookMessage(message, user as RequiredField<IUser, 'username'>, defaultValues);
}
eventNameArgumentsToObject(...args: unknown[]) {
const argObject: ArgumentsObject = {
event: args[0] as OutgoingIntegrationEvent | undefined,
};
switch (argObject.event) {
case 'sendMessage':
if (args.length >= 3) {
argObject.message = args[1] as IMessage;
argObject.room = args[2] as IRoom;
}
break;
case 'fileUploaded':
if (args.length >= 2) {
const arghhh: Record<string, any> = args[1] as Record<string, any>;
argObject.user = arghhh.user as IUser;
argObject.room = arghhh.room as IRoom;
argObject.message = arghhh.message as IMessage;
}
break;
case 'roomArchived':
if (args.length >= 3) {
argObject.room = args[1] as IRoom;
argObject.user = args[2] as IUser;
}
break;
case 'roomCreated':
if (args.length >= 3) {
argObject.owner = args[1] as IUser;
argObject.room = args[2] as IRoom;
}
break;
case 'roomJoined':
if (args.length >= 3) {
argObject.user = args[1] as IUser;
argObject.room = args[2] as IRoom;
}
break;
case 'roomLeft':
if (args.length >= 3) {
argObject.user = (args[1] as { user: IUser })?.user;
argObject.room = args[2] as IRoom;
}
break;
case 'userCreated':
if (args.length >= 2) {
argObject.user = args[1] as IUser;
}
break;
default:
outgoingLogger.warn({ msg: 'Unhandled trigger event', event: argObject.event });
argObject.event = undefined;
break;
}
outgoingLogger.debug({
msg: 'Got the event arguments for the event',
event: argObject.event,
messageId: argObject.message?._id,
roomId: argObject.room?._id,
userId: argObject.user?._id || argObject.owner?._id,
});
return argObject;
}
mapEventArgsToData(data: IntegrationData, { event, message, room, owner, user }: ArgumentsObject) {
/* The "services" field contains sensitive information such as
the user's password hash. To prevent this information from being
sent to the webhook, we're checking and removing it by destructuring
the user and owner objects while discarding the "services" field.
*/
const omitServicesField = (object: IUser) => {
const { services, ...objectWithoutServicesField } = object;
return objectWithoutServicesField;
};
const userWithoutServicesField = user?.services ? omitServicesField(user) : user;
const ownerWithoutServicesField = owner?.services ? omitServicesField(owner) : owner;
if (!room || !message) {
outgoingLogger.warn({ msg: 'Integration called without room or message', event });
return;
}
switch (event) {
case 'sendMessage':
data.channel_id = room._id;
data.channel_name = room.name;
data.message_id = message._id;
data.timestamp = message.ts;
data.user_id = message.u._id;
data.user_name = message.u.username;
data.text = message.msg;
data.siteUrl = settings.get('Site_Url');
if (message.alias) {
data.alias = message.alias;
}
if (message.bot) {
data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense
}
if (message.editedAt) {
data.isEdited = true;
}
if (message.tmid) {
data.tmid = message.tmid;
}
break;
case 'fileUploaded':
data.channel_id = room._id;
data.channel_name = room.name;
data.message_id = message._id;
data.timestamp = message.ts;
data.user_id = message.u._id;
data.user_name = message.u.username;
data.text = message.msg;
data.user = userWithoutServicesField;
data.room = room;
data.message = message;
if (message.alias) {
data.alias = message.alias;
}
if (message.bot) {
data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense
}
break;
case 'roomCreated':
if (!owner) {
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
return;
}
data.channel_id = room._id;
data.channel_name = room.name;
data.timestamp = room.ts;
data.user_id = owner._id;
data.user_name = owner.username;
data.owner = ownerWithoutServicesField;
data.room = room;
break;
case 'roomArchived':
case 'roomJoined':
case 'roomLeft':
if (!user) {
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
return;
}
data.timestamp = new Date();
data.channel_id = room._id;
data.channel_name = room.name;
data.user_id = user._id;
data.user_name = user.username;
data.user = userWithoutServicesField;
data.room = room;
if (user.type === 'bot') {
data.bot = true;
}
break;
case 'userCreated':
if (!user) {
outgoingLogger.warn({ msg: 'Integration called without owner data', event });
return;
}
data.timestamp = user.createdAt;
data.user_id = user._id;
data.user_name = user.username;
data.user = userWithoutServicesField;
if (user.type === 'bot') {
data.bot = true;
}
break;
default:
break;
}
}
getTriggersToExecute(room?: IRoom, message?: MessageWithEditedAt) {
const triggersToExecute = new Set<IOutgoingIntegration>();
if (room) {
switch (room.t) {
case 'd':
if (this.triggers.all_direct_messages) {
for (const trigger of Object.values(this.triggers.all_direct_messages)) {
triggersToExecute.add(trigger);
}
}
room.uids
?.filter((uid) => this.triggers[`@${uid}`])
.forEach((uid) => {
for (const trigger of Object.values(this.triggers[`@${uid}`])) {
triggersToExecute.add(trigger);
}
});
room.usernames
?.filter((username) => username !== message?.u?.username && this.triggers[`@${username}`])
.forEach((username) => {
for (const trigger of Object.values(this.triggers[`@${username}`])) {
triggersToExecute.add(trigger);
}
});
break;
case 'c':
if (this.triggers.all_public_channels) {
for (const trigger of Object.values(this.triggers.all_public_channels)) {
triggersToExecute.add(trigger);
}
}
if (this.triggers[`#${room._id}`]) {
for (const trigger of Object.values(this.triggers[`#${room._id}`])) {
triggersToExecute.add(trigger);
}
}
if (room._id !== room.name && this.triggers[`#${room.name}`]) {
for (const trigger of Object.values(this.triggers[`#${room.name}`])) {
triggersToExecute.add(trigger);
}
}
break;
default:
if (this.triggers.all_private_groups) {
for (const trigger of Object.values(this.triggers.all_private_groups)) {
triggersToExecute.add(trigger);
}
}
if (this.triggers[`#${room._id}`]) {
for (const trigger of Object.values(this.triggers[`#${room._id}`])) {
triggersToExecute.add(trigger);
}
}
if (room._id !== room.name && this.triggers[`#${room.name}`]) {
for (const trigger of Object.values(this.triggers[`#${room.name}`])) {
triggersToExecute.add(trigger);
}
}
break;
}
}
return [...triggersToExecute];
}
async executeTriggers(...args: unknown[]) {
outgoingLogger.debug({ msg: 'Execute Trigger:', arg: args[0] });
const argObject = this.eventNameArgumentsToObject(...args);
const { event, message, room } = argObject;
// Each type of event should have an event and a room attached, otherwise we
// wouldn't know how to handle the trigger nor would we have anywhere to send the
// result of the integration
if (!event) {
return;
}
outgoingLogger.debug({ msg: 'Starting search for triggers for room', roomId: room ? room._id : '__any' });
const triggersToExecute = this.getTriggersToExecute(room, message);
if (this.triggers.__any) {
// For outgoing integration which don't rely on rooms.
for (const trigger of Object.values(this.triggers.__any)) {
triggersToExecute.push(trigger);
}
}
outgoingLogger.debug({ msg: 'Found triggers to iterate over', triggerCount: triggersToExecute.length, event });
for (const triggerToExecute of triggersToExecute) {
outgoingLogger.debug({
msg: 'Checking trigger execution eligibility',
triggerName: triggerToExecute.name,
isEnabled: triggerToExecute.enabled,
triggerEvent: triggerToExecute.event,
});
if (triggerToExecute.enabled === true && triggerToExecute.event === event) {
await this.executeTrigger(triggerToExecute, argObject);
}
}
}
async executeTrigger(trigger: IOutgoingIntegration, argObject: ArgumentsObject) {
if (!trigger.urls) {
return;
}
for (const url of trigger.urls) {
await this.executeTriggerUrl(url, trigger, argObject, 0);
}
}
// Ensure that any errors thrown by the script engine will contibue to be compatible with Meteor.Error
async wrapScriptEngineCall(getter: () => Promise<any>) {
return wrapExceptions(getter).catch((error) => {
if (error instanceof Error) {
throw new Meteor.Error(error.message);
}
throw error;
});
}
async executeTriggerUrl(url: string, trigger: IOutgoingIntegration, { event, message, room, owner, user }: ArgumentsObject, tries = 0) {
if (!this.isTriggerEnabled(trigger)) {
outgoingLogger.warn({ msg: 'Trigger is not enabled', triggerName: trigger.name, tries });
return;
}
outgoingLogger.debug({ msg: 'Starting to execute trigger', triggerName: trigger.name, triggerId: trigger._id });
let word;
// Not all triggers/events support triggerWords
if (event && outgoingEvents[event].use.triggerWords) {
if (trigger.triggerWords && trigger.triggerWords.length > 0) {
for (const triggerWord of trigger.triggerWords) {
if (!trigger.triggerWordAnywhere && message?.msg.indexOf(triggerWord) === 0) {
word = triggerWord;
break;
} else if (trigger.triggerWordAnywhere && message?.msg.includes(triggerWord)) {
word = triggerWord;
break;
}
}
// Stop if there are triggerWords but none match
if (!word) {
outgoingLogger.debug({ msg: 'Trigger word not found', triggerName: trigger.name });
return;
}
}
}
if (message && message.editedAt && !trigger.runOnEdits) {
outgoingLogger.debug({ msg: 'Trigger run on edits disabled and message was edited', triggerName: trigger.name });
return;
}
const historyId = await updateHistory({
step: 'start-execute-trigger-url',
integration: trigger,
event,
historyId: '',
});
const data: IntegrationData = {
token: trigger.token,
bot: false,
};
if (word) {
data.trigger_word = word;
}
this.mapEventArgsToData(data, { event, message, room, owner, user });
await updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word });
outgoingLogger.info({ msg: 'Will be executing integration', integrationName: trigger.name, url });
outgoingLogger.debug({ data });
const scriptEngine = this.getEngine(trigger);
const opts = await this.wrapScriptEngineCall(() =>
scriptEngine.prepareOutgoingRequest({
integration: trigger,
data: data as OutgoingRequestData,
url,
historyId,
}),
);
await updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true });
if (!opts) {
await updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true });
return;
}
if (opts.message) {
const prepareMessage = await this.sendMessage({ trigger, room, message: opts.message, data });
if (!prepareMessage) {
await updateHistory({ historyId, step: 'after-prepare-send-message-failed', finished: true });
return;
}
await updateHistory({
historyId,
step: 'after-prepare-send-message',
prepareSentMessage: prepareMessage,
});
}
if (!opts.url || !opts.method) {
await updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true });
return;
}
// based on HTTP.call implementation
if (opts.auth) {
if (opts.auth.indexOf(':') < 0) {
throw new Error('auth option should be of the form "username:password"');
}
const base64 = Buffer.from(opts.auth, 'ascii').toString('base64');
opts.headers.Authorization = `Basic ${base64}`;
}
await updateHistory({
historyId,
step: 'pre-http-call',
url: opts.url,
httpCallData: opts.data,
});
if (opts.data) {
opts.headers['Content-Type'] = 'application/json';
}
fetch(
opts.url,
{
method: opts.method,
headers: opts.headers,
...(opts.timeout && { timeout: opts.timeout }),
...(opts.data && { body: opts.data }),
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
size: 10 * 1024 * 1024,
},
settings.get('Allow_Invalid_SelfSigned_Certs'),
)
.then(async (res) => {
const content = await res.text();
if (!content) {
outgoingLogger.warn({ msg: 'Integration result is empty', integrationName: trigger.name, url });
} else {
outgoingLogger.info({ msg: 'Integration HTTP status', integrationName: trigger.name, url, status: res.status });
}
const data = (() => {
const contentType = (res.headers.get('content-type') || '').split(';')[0];
if (!['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].includes(contentType)) {
return null;
}
try {
return JSON.parse(content);
} catch (_error) {
return null;
}
})();
await updateHistory({
historyId,
step: 'after-http-call',
httpError: null,
httpResult: content,
});
const responseContent = await this.wrapScriptEngineCall(() =>
scriptEngine.processOutgoingResponse({
integration: trigger,
request: opts,
response: res,
content,
historyId,
}),
);
if (responseContent) {
const resultMessage = await this.sendMessage({
trigger,
room,
message: responseContent,
data,
});
if (!resultMessage) {
await updateHistory({ historyId, step: 'after-process-send-message-failed', finished: true });
return;
}
await updateHistory({
historyId,
step: 'after-process-send-message',
processSentMessage: resultMessage,
finished: true,
});
return;
}
if (responseContent === false) {
await updateHistory({ historyId, step: 'after-process-false-result', finished: true });
return;
}
// if the result contained nothing or wasn't a successful statusCode
if (!content || !this.successResults.includes(res.status)) {
if (content) {
outgoingLogger.error({
msg: 'Error for integration request',
integrationName: trigger.name,
url,
result: content,
});
if (res.status === 410) {
await updateHistory({ historyId, step: 'after-process-http-status-410', error: true });
outgoingLogger.error({ msg: 'Disabling integration due to 410 (Gone) status', integrationName: trigger.name });
await Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } });
void notifyOnIntegrationChangedById(trigger._id);
return;
}
if (res.status === 500) {
await updateHistory({ historyId, step: 'after-process-http-status-500', error: true });
outgoingLogger.error({
msg: 'Error 500 for integration request',
integrationName: trigger.name,
url,
content,
});
return;
}
}
if (trigger.retryFailedCalls && trigger.retryCount) {
if (tries < trigger.retryCount && trigger.retryDelay) {
await updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` });
let waitTime;
switch (trigger.retryDelay) {
case 'powers-of-ten':
// Try again in 0.1s, 1s, 10s, 1m40s, 16m40s, 2h46m40s, 27h46m40s, etc
waitTime = Math.pow(10, tries + 2);
break;
case 'powers-of-two':
// 2 seconds, 4 seconds, 8 seconds
waitTime = Math.pow(2, tries + 1) * 1000;
break;
case 'increments-of-two':
// 2 second, 4 seconds, 6 seconds, etc
waitTime = (tries + 1) * 2 * 1000;
break;
default:
const er = new Error("The integration's retryDelay setting is invalid.");
await updateHistory({
historyId,
step: 'failed-and-retry-delay-is-invalid',
error: true,
errorStack: er.stack,
});
return;
}
outgoingLogger.info({ msg: 'Retrying integration', integrationName: trigger.name, url, waitTime });
setTimeout(() => {
void this.executeTriggerUrl(url, trigger, { event, message, room, owner, user }, tries + 1);
}, waitTime);
} else {
await updateHistory({ historyId, step: 'too-many-retries', error: true });
}
} else {
await updateHistory({
historyId,
step: 'failed-and-not-configured-to-retry',
error: true,
});
}
return;
}
// process outgoing webhook response as a new message
if (content && this.successResults.includes(res.status)) {
if (data?.text || data?.attachments) {
const resultMsg = await this.sendMessage({ trigger, room, message: data, data });
if (!resultMsg) {
await updateHistory({ historyId, step: 'after-http-call-send-message-failed', finished: true });
return;
}
await updateHistory({
historyId,
step: 'url-response-sent-message',
resultMessage: resultMsg,
finished: true,
});
}
}
})
.catch(async (err) => {
outgoingLogger.error({ err });
await updateHistory({
historyId,
step: 'after-http-call',
httpError: err,
httpResult: null,
});
});
}
async replay(integration: IOutgoingIntegration, history: IIntegrationHistory) {
if (integration?.type !== 'webhook-outgoing') {
throw new Meteor.Error('integration-type-must-be-outgoing', 'The integration type to replay must be an outgoing webhook.');
}
if (!history?.data) {
throw new Meteor.Error('history-data-must-be-defined', 'The history data must be defined to replay an integration.');
}
const { event } = history;
let owner;
let message;
let room;
let user;
if (history.data.owner?._id) {
owner = await Users.findOneById(history.data.owner._id);
}
if (history.data.message_id) {
message = await Messages.findOneById(history.data.message_id);
}
if (history.data.channel_id) {
room = await Rooms.findOneById(history.data.channel_id);
}
if (history.data.user_id) {
user = await Users.findOneById(history.data.user_id);
}
if (!history.url) {
return;
}
return this.executeTriggerUrl(history.url, integration, {
event,
message: message || undefined,
room: room || undefined,
owner: owner || undefined,
user: user || undefined,
});
}
}
const triggerHandler = new RocketChatIntegrationHandler();
export { triggerHandler };

View File

@@ -0,0 +1,105 @@
import type { IIntegrationHistory, OutgoingIntegrationEvent, IIntegration, IMessage, AtLeast } from '@rocket.chat/core-typings';
import { IntegrationHistory } from '@rocket.chat/models';
import { omit } from '../../../../lib/utils/omit';
import { notifyOnIntegrationHistoryChangedById, notifyOnIntegrationHistoryChanged } from '../../notifyListener';
export const updateHistory = async ({
historyId,
step,
integration,
event,
data,
triggerWord,
ranPrepareScript,
prepareSentMessage,
processSentMessage,
resultMessage,
finished,
url,
httpCallData,
httpError,
httpResult,
error,
errorStack,
}: {
historyId: IIntegrationHistory['_id'];
step: IIntegrationHistory['step'];
integration?: IIntegration;
event?: OutgoingIntegrationEvent;
triggerWord?: string;
ranPrepareScript?: boolean;
prepareSentMessage?: { channel: string; message: Partial<IMessage> }[];
processSentMessage?: { channel: string; message: Partial<IMessage> }[];
resultMessage?: { channel: string; message: Partial<IMessage> }[];
finished?: boolean;
url?: string;
httpCallData?: Record<string, any>; // ProcessedOutgoingRequest.data
httpError?: any; // null or whatever error type `fetch` may throw
httpResult?: string | null;
error?: boolean;
errorStack?: any; // Error | Error['stack']
data?: Record<string, any>;
}) => {
const { user: userData, room: roomData, ...fullData } = data || {};
const history: AtLeast<IIntegrationHistory, 'type' | 'step'> = {
type: 'outgoing-webhook',
step,
// Usually is only added on initial insert
...(integration ? { integration } : {}),
// Usually is only added on initial insert
...(event ? { event } : {}),
...(fullData
? {
data: {
...fullData,
...(userData ? { user: omit(userData, 'services') } : {}),
...(roomData ? { room: roomData } : {}),
},
}
: {}),
...(triggerWord ? { triggerWord } : {}),
...(typeof ranPrepareScript !== 'undefined' ? { ranPrepareScript } : {}),
...(prepareSentMessage ? { prepareSentMessage } : {}),
...(processSentMessage ? { processSentMessage } : {}),
...(resultMessage ? { resultMessage } : {}),
...(typeof finished !== 'undefined' ? { finished } : {}),
...(url ? { url } : {}),
...(typeof httpCallData !== 'undefined' ? { httpCallData } : {}),
...(httpError ? { httpError } : {}),
...(typeof httpResult !== 'undefined' ? { httpResult: JSON.stringify(httpResult, null, 2) } : {}),
...(typeof error !== 'undefined' ? { error } : {}),
...(typeof errorStack !== 'undefined' ? { errorStack } : {}),
};
if (historyId) {
// Projecting just integration field to comply with existing listener behaviour
const integrationHistory = await IntegrationHistory.updateById(historyId, history, { projection: { 'integration._id': 1 } });
if (!integrationHistory) {
throw new Error('error-updating-integration-history');
}
void notifyOnIntegrationHistoryChanged(integrationHistory, 'updated', history);
return historyId;
}
// Can't create a new history without there being an integration
if (!history.integration) {
throw new Error('error-invalid-integration');
}
// TODO: Had to force type cast here because of function's signature
// It would be easier if we separate into create and update functions
const { insertedId } = await IntegrationHistory.create(history as IIntegrationHistory);
if (!insertedId) {
throw new Error('error-creating-integration-history');
}
void notifyOnIntegrationHistoryChangedById(insertedId, 'inserted');
return insertedId;
};

View File

@@ -0,0 +1,198 @@
import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { compileIntegrationScript } from './compileIntegrationScript';
import { isScriptEngineFrozen } from './validateScriptEngine';
import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents';
import { parseCSV } from '../../../../lib/utils/parseCSV';
import { hasPermissionAsync, hasAllPermissionAsync } from '../../authorization/hasPermission';
const scopedChannels = ['all_public_channels', 'all_private_groups', 'all_direct_messages'];
const validChannelChars = ['@', '#'];
function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOutgoingIntegration): void {
if (
!integration.event ||
!Match.test(integration.event, String) ||
integration.event.trim() === '' ||
!outgoingEvents[integration.event]
) {
throw new Meteor.Error('error-invalid-event-type', 'Invalid event type', {
function: 'validateOutgoing._verifyRequiredFields',
});
}
if (!integration.username || !Match.test(integration.username, String) || integration.username.trim() === '') {
throw new Meteor.Error('error-invalid-username', 'Invalid username', {
function: 'validateOutgoing._verifyRequiredFields',
});
}
if (outgoingEvents[integration.event].use.targetRoom && !integration.targetRoom) {
throw new Meteor.Error('error-invalid-targetRoom', 'Invalid Target Room', {
function: 'validateOutgoing._verifyRequiredFields',
});
}
if (!Match.test(integration.urls, [String])) {
throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', {
function: 'validateOutgoing._verifyRequiredFields',
});
}
integration.urls = integration.urls.filter((url) => url && url.trim() !== '');
if (integration.urls.length === 0) {
throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', {
function: 'validateOutgoing._verifyRequiredFields',
});
}
}
async function _verifyUserHasPermissionForChannels(userId: IUser['_id'], channels: string[]): Promise<void> {
for (let channel of channels) {
if (scopedChannels.includes(channel)) {
if (channel === 'all_public_channels') {
// No special permissions needed to add integration to public channels
} else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) {
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
});
}
} else {
let record;
const channelType = channel[0];
channel = channel.substr(1);
switch (channelType) {
case '#':
record = await Rooms.findOne({
$or: [{ _id: channel }, { name: channel }],
});
break;
case '@':
record = await Users.findOne({
$or: [{ _id: channel }, { username: channel }],
});
break;
}
if (!record) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
});
}
if (
!(await hasAllPermissionAsync(userId, ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'])) &&
!(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } }))
) {
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
function: 'validateOutgoing._verifyUserHasPermissionForChannels',
});
}
}
}
}
function _verifyRetryInformation(integration: IOutgoingIntegration): void {
if (!integration.retryFailedCalls) {
return;
}
// Don't allow negative retry counts
integration.retryCount =
integration.retryCount && parseInt(String(integration.retryCount)) > 0 ? parseInt(String(integration.retryCount)) : 4;
integration.retryDelay = !integration.retryDelay?.trim() ? 'powers-of-ten' : integration.retryDelay.toLowerCase();
}
export const validateOutgoingIntegration = async function (
integration: INewOutgoingIntegration | IUpdateOutgoingIntegration,
userId: IUser['_id'],
): Promise<IOutgoingIntegration> {
if (integration.channel && Match.test(integration.channel, String) && integration.channel.trim() === '') {
delete integration.channel;
}
// Moved to it's own function to satisfy the complexity rule
_verifyRequiredFields(integration);
let channels: string[] = [];
if (outgoingEvents[integration.event].use.channel) {
if (!Match.test(integration.channel, String)) {
throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', {
function: 'validateOutgoing',
});
} else {
channels = parseCSV(integration.channel);
for (const channel of channels) {
if (!validChannelChars.includes(channel[0]) && !scopedChannels.includes(channel.toLowerCase())) {
throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', {
function: 'validateOutgoing',
});
}
}
}
} else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) {
throw new Meteor.Error('error-invalid-permissions', 'Invalid permission for required Integration creation.', {
function: 'validateOutgoing',
});
}
const user = await Users.findOne({ username: integration.username });
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user (did you delete the `rocket.cat` user?)', { function: 'validateOutgoing' });
}
const integrationData: IOutgoingIntegration = {
...integration,
scriptEngine: integration.scriptEngine ?? 'isolated-vm',
type: 'webhook-outgoing',
channel: channels,
userId: user._id,
_createdAt: new Date(),
_createdBy: await Users.findOne(userId, { projection: { username: 1 } }),
};
if (outgoingEvents[integration.event].use.triggerWords && integration.triggerWords) {
if (!Match.test(integration.triggerWords, [String])) {
throw new Meteor.Error('error-invalid-triggerWords', 'Invalid triggerWords', {
function: 'validateOutgoing',
});
}
integrationData.triggerWords = integration.triggerWords.filter((word) => word && word.trim() !== '');
} else {
delete integrationData.triggerWords;
}
// Default to transpiling with Babel for backwards compatibility; integrations
// can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0).
const skipTranspile = integration.skipTranspile === true;
integrationData.skipTranspile = skipTranspile;
if (
!isScriptEngineFrozen(integrationData.scriptEngine) &&
integration.scriptEnabled === true &&
integration.script &&
integration.script.trim() !== ''
) {
const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile });
integrationData.scriptCompiled = script;
integrationData.scriptError = error;
}
if (typeof integration.runOnEdits !== 'undefined') {
// Verify this value is only true/false
integrationData.runOnEdits = integration.runOnEdits === true;
}
await _verifyUserHasPermissionForChannels(userId, channels);
_verifyRetryInformation(integrationData);
return integrationData;
};

View File

@@ -0,0 +1,26 @@
import type { IntegrationScriptEngine } from '@rocket.chat/core-typings';
import { wrapExceptions } from '@rocket.chat/tools';
const FREEZE_INTEGRATION_SCRIPTS_VALUE = String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase();
const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(FREEZE_INTEGRATION_SCRIPTS_VALUE);
export const validateScriptEngine = (engine?: IntegrationScriptEngine) => {
if (FREEZE_INTEGRATION_SCRIPTS) {
throw new Error('integration-scripts-disabled');
}
if (engine && engine !== 'isolated-vm') {
throw new Error('integration-scripts-unknown-engine');
}
const engineCode = 'ivm';
if (engineCode === FREEZE_INTEGRATION_SCRIPTS_VALUE) {
throw new Error('integration-scripts-isolated-vm-disabled');
}
return true;
};
export const isScriptEngineFrozen = (engine?: IntegrationScriptEngine) =>
wrapExceptions(() => !validateScriptEngine(engine)).catch(() => true);

View File

@@ -0,0 +1,6 @@
import { Logger } from '@rocket.chat/logger';
export const integrationLogger = new Logger('Integrations');
export const incomingLogger = integrationLogger.section('Incoming WebHook');
export const outgoingLogger = integrationLogger.section('Outgoing WebHook');

View File

@@ -0,0 +1,11 @@
import type { IOutgoingIntegration } from '@rocket.chat/core-typings';
import { Integrations } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { triggerHandler } from './lib/triggerHandler';
Meteor.startup(async () => {
await Integrations.find<IOutgoingIntegration>({ type: 'webhook-outgoing' }).forEach((data) =>
triggerHandler.addIntegration(data as IOutgoingIntegration),
);
});

View File

@@ -0,0 +1,23 @@
import { callbacks } from '../callbacks';
import { triggerHandler } from './lib/triggerHandler';
import { afterLeaveRoomCallback } from '../callbacks/afterLeaveRoomCallback';
const callbackHandler = function _callbackHandler(eventType: string) {
return function _wrapperFunction(...args: any[]) {
return triggerHandler.executeTriggers(eventType, ...args);
};
};
callbacks.add(
'afterSaveMessage',
(message, { room }) => callbackHandler('sendMessage')(message, room),
callbacks.priority.LOW,
'integrations-sendMessage',
);
callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated');
callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated');
callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW, 'integrations-userCreated');
callbacks.add('afterJoinRoom', callbackHandler('roomJoined'), callbacks.priority.LOW, 'integrations-roomJoined');
afterLeaveRoomCallback.add(callbackHandler('roomLeft'), callbacks.priority.LOW, 'integrations-roomLeft');
callbacks.add('afterRoomArchived', callbackHandler('roomArchived'), callbacks.priority.LOW, 'integrations-roomArchived');
callbacks.add('afterFileUpload', callbackHandler('fileUploaded'), callbacks.priority.LOW, 'integrations-fileUploaded');