diff --git a/.changeset/integration-script-transpile-opt-out.md b/.changeset/integration-script-transpile-opt-out.md new file mode 100644 index 0000000000000..3e6451943d03f --- /dev/null +++ b/.changeset/integration-script-transpile-opt-out.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +--- + +Adds a `skipTranspile` flag (default `false`) to webhook integrations. When set to `true`, the integration script is stored as-is without Babel transpilation — matching the 9.0.0 default where Babel is removed entirely. Admins can flip the flag per-integration to validate strict-mode compatibility before upgrading. The field is deprecated and will be removed in 9.0.0. diff --git a/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts b/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts new file mode 100644 index 0000000000000..5e366cd1e18ea --- /dev/null +++ b/apps/meteor/app/integrations/server/lib/compileIntegrationScript.ts @@ -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 } { + if (!transpile) { + return validateOnly(script); + } + + return transpileWithBabel(script); +} + +function validateOnly( + script: string, +): { script: string; error?: undefined } | { script?: undefined; error: Pick } { + 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 } { + 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; + } +} diff --git a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts index d4b1a68af5796..b4656f629d834 100644 --- a/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/lib/validateOutgoingIntegration.ts @@ -1,11 +1,9 @@ -import { transformSync } from '@babel/core'; -import presetEnv from '@babel/preset-env'; import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; -import { pick } from '@rocket.chat/tools'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { compileIntegrationScript } from './compileIntegrationScript'; import { isScriptEngineFrozen } from './validateScriptEngine'; import { parseCSV } from '../../../../lib/utils/parseCSV'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -172,28 +170,20 @@ export const validateOutgoingIntegration = async function ( delete integrationData.triggerWords; } - // Only compile the script if it is enabled and using a sandbox that is not frozen + // 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() !== '' ) { - try { - const result = transformSync(integration.script, { - presets: [presetEnv], - compact: true, - minified: true, - comments: false, - }); - - // TODO: Webhook Integration Editor should inform the user if the script is compiled successfully - integrationData.scriptCompiled = result?.code ?? undefined; - integrationData.scriptError = undefined; - } catch (e) { - integrationData.scriptCompiled = undefined; - integrationData.scriptError = e instanceof Error ? pick(e, 'name', 'message', 'stack') : undefined; - } + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + integrationData.scriptCompiled = script; + integrationData.scriptError = error; } if (typeof integration.runOnEdits !== 'undefined') { diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index 9520c2b416039..49116d1335ee8 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -1,5 +1,3 @@ -import { transformSync } from '@babel/core'; -import presetEnv from '@babel/preset-env'; import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models'; @@ -7,11 +5,11 @@ import { Random } from '@rocket.chat/random'; import { removeEmpty } from '@rocket.chat/tools'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; +import { compileIntegrationScript } from '../../lib/compileIntegrationScript'; import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; @@ -92,9 +90,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn }); } + // 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; + const integrationData: IIncomingIntegration = { ...integration, scriptEngine: integration.scriptEngine ?? 'isolated-vm', + skipTranspile, type: 'webhook-incoming', channel: channels, overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false, @@ -104,27 +107,19 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn _createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), }; - // Only compile the script if it is enabled and using a sandbox that is not frozen if ( !isScriptEngineFrozen(integrationData.scriptEngine) && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '' ) { - try { - const result = transformSync(integration.script, { - presets: [presetEnv], - compact: true, - minified: true, - comments: false, - }); - - // TODO: Webhook Integration Editor should inform the user if the script is compiled successfully - integrationData.scriptCompiled = result?.code ?? undefined; - delete integrationData.scriptError; - } catch (e) { + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + if (error) { integrationData.scriptCompiled = undefined; - integrationData.scriptError = e instanceof Error ? _.pick(e, 'name', 'message', 'stack') : undefined; + integrationData.scriptError = error; + } else { + integrationData.scriptCompiled = script; + delete integrationData.scriptError; } } diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 90da52e610738..894b5b31bd405 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -1,5 +1,3 @@ -import { transformSync } from '@babel/core'; -import presetEnv from '@babel/preset-env'; import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models'; @@ -9,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles'; import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; +import { compileIntegrationScript } from '../../lib/compileIntegrationScript'; import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; const validChannelChars = ['@', '#']; @@ -84,49 +83,28 @@ export const updateIncomingIntegration = async ( const isFrozen = isScriptEngineFrozen(scriptEngine); - if (!isFrozen) { - let scriptCompiled: string | undefined; - let scriptError: Pick | undefined; - - if (integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { - try { - const result = transformSync(integration.script, { - presets: [presetEnv], - compact: true, - minified: true, - comments: false, - }); - - // TODO: Webhook Integration Editor should inform the user if the script is compiled successfully - scriptCompiled = result?.code ?? undefined; - scriptError = undefined; - await Integrations.updateOne( - { _id: integrationId }, - { - $set: { - scriptCompiled, - }, - $unset: { scriptError: 1 as const }, - }, - ); - } catch (e) { - scriptCompiled = undefined; - if (e instanceof Error) { - const { name, message, stack } = e; - scriptError = { name, message, stack }; - } - await Integrations.updateOne( - { _id: integrationId }, - { - $set: { - scriptError, - }, - $unset: { - scriptCompiled: 1 as const, - }, - }, - ); - } + // 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; + + if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + if (error) { + await Integrations.updateOne( + { _id: integrationId }, + { + $set: { scriptError: error, skipTranspile }, + $unset: { scriptCompiled: 1 as const }, + }, + ); + } else { + await Integrations.updateOne( + { _id: integrationId }, + { + $set: { scriptCompiled: script, skipTranspile }, + $unset: { scriptError: 1 as const }, + }, + ); } } @@ -192,6 +170,7 @@ export const updateIncomingIntegration = async ( ...(typeof integration.script !== 'undefined' && { script: integration.script }), scriptEnabled: integration.scriptEnabled, ...(scriptEngine && { scriptEngine }), + skipTranspile, }), ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, diff --git a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts index 29b150a60011a..aee22f252efab 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts @@ -87,6 +87,7 @@ export const updateOutgoingIntegration = async ( script: integration.script, scriptEnabled: integration.scriptEnabled, scriptEngine, + skipTranspile: integration.skipTranspile, ...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }), }), triggerWords: integration.triggerWords, diff --git a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts index 238b3fd543c9b..69d609b9b8fb6 100644 --- a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -367,6 +367,30 @@ describe('[Incoming Integrations]', () => { describe('Script integration tests', () => { let withScript: IIntegration; let withScriptDefaultContentType: IIntegration; + let withSkipTranspile: IIntegration; + + const sloppyModeScript = + 'const buildMessage = (obj) => {\n' + + ' \n' + + ' const template = `[#VALUE](${ obj.test })`;\n' + + ' \n' + + ' return {\n' + + ' text: template\n' + + ' };\n' + + ' };\n' + + ' \n' + + ' class Script {\n' + + ' process_incoming_request({ request }) {\n' + + ' msg = buildMessage(request.content);\n' + + ' \n' + + ' return {\n' + + ' content:{\n' + + ' text: msg.text\n' + + ' }\n' + + ' };\n' + + ' }\n' + + ' }\n' + + ' \n'; before(async () => { await updatePermission('manage-incoming-integrations', ['admin']); @@ -410,35 +434,38 @@ describe('[Incoming Integrations]', () => { scriptEnabled: true, overrideDestinationChannelEnabled: false, channel: '#general', - script: - 'const buildMessage = (obj) => {\n' + - ' \n' + - ' const template = `[#VALUE](${ obj.test })`;\n' + - ' \n' + - ' return {\n' + - ' text: template\n' + - ' };\n' + - ' };\n' + - ' \n' + - ' class Script {\n' + - ' process_incoming_request({ request }) {\n' + - ' msg = buildMessage(request.content);\n' + - ' \n' + - ' return {\n' + - ' content:{\n' + - ' text: msg.text\n' + - ' }\n' + - ' };\n' + - ' }\n' + - ' }\n' + - ' \n', + script: sloppyModeScript, }) .expect(200); withScriptDefaultContentType = res2.body.integration; + + // Same script but with skipTranspile: true — no Babel, class methods + // run in strict mode so `msg = buildMessage(...)` throws ReferenceError. + const res3 = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test with skipTranspile', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: true, + skipTranspile: true, + overrideDestinationChannelEnabled: false, + channel: '#general', + script: sloppyModeScript, + }) + .expect(200); + withSkipTranspile = res3.body.integration; }); after(async () => { - await Promise.all([removeIntegration(withScript._id, 'incoming'), removeIntegration(withScriptDefaultContentType._id, 'incoming')]); + await Promise.all([ + removeIntegration(withScript._id, 'incoming'), + removeIntegration(withScriptDefaultContentType._id, 'incoming'), + removeIntegration(withSkipTranspile._id, 'incoming'), + ]); }); it('should send a message if the payload is a application/x-www-form-urlencoded JSON AND the integration has a valid script', async () => { @@ -486,6 +513,22 @@ describe('[Incoming Integrations]', () => { expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === '[#VALUE](test)')).to.be.true; }); + + it('should create the skipTranspile integration with scriptCompiled and no scriptError', () => { + expect(withSkipTranspile).to.have.property('scriptCompiled'); + expect(withSkipTranspile).to.not.have.property('scriptError'); + expect(withSkipTranspile).to.have.property('skipTranspile', true); + }); + + it('should fail to execute the same sloppy-mode script when skipTranspile is true', async () => { + const payload = { test: 'test' }; + + await request + .post(`/hooks/${withSkipTranspile._id}/${withSkipTranspile.token}`) + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(400); + }); }); describe('With manage-own-incoming-integrations permission', () => { diff --git a/packages/core-typings/src/IIntegration.ts b/packages/core-typings/src/IIntegration.ts index 589d4e66478fc..a0b6e93b4c313 100644 --- a/packages/core-typings/src/IIntegration.ts +++ b/packages/core-typings/src/IIntegration.ts @@ -16,6 +16,14 @@ export interface IIncomingIntegration extends IRocketChatRecord { script?: string; scriptCompiled?: string; scriptError?: Pick; + /** + * When `true`, the integration script is stored as-is without Babel + * transpilation — matching the 9.0.0 default where Babel is removed + * entirely. Defaults to `false` (transpile with Babel for backwards + * compatibility). Deprecated field — removed in 9.0.0 together with + * the Babel transpilation path. + */ + skipTranspile?: boolean; name: string; enabled: boolean; @@ -56,6 +64,14 @@ export interface IOutgoingIntegration extends IRocketChatRecord { script?: string; scriptCompiled?: string; scriptError?: Pick; + /** + * When `true`, the integration script is stored as-is without Babel + * transpilation — matching the 9.0.0 default where Babel is removed + * entirely. Defaults to `false` (transpile with Babel for backwards + * compatibility). Deprecated field — removed in 9.0.0 together with + * the Babel transpilation path. + */ + skipTranspile?: boolean; runOnEdits?: boolean; retryFailedCalls?: boolean; diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts index 89d388191c26d..ac06a647bee05 100644 --- a/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts +++ b/packages/rest-typings/src/v1/integrations/IntegrationsCreateProps.ts @@ -9,6 +9,7 @@ export type IntegrationsCreateProps = channel: string; overrideDestinationChannelEnabled?: boolean; scriptEnabled: boolean; + skipTranspile?: boolean; script?: string; name: string; enabled: boolean; @@ -30,6 +31,7 @@ export type IntegrationsCreateProps = token?: string; scriptEnabled: boolean; + skipTranspile?: boolean; script?: string; runOnEdits?: boolean; @@ -69,6 +71,10 @@ const integrationsCreateSchema = { type: 'boolean', nullable: false, }, + skipTranspile: { + type: 'boolean', + nullable: true, + }, overrideDestinationChannelEnabled: { type: 'boolean', nullable: true, @@ -157,6 +163,10 @@ const integrationsCreateSchema = { type: 'boolean', nullable: false, }, + skipTranspile: { + type: 'boolean', + nullable: true, + }, script: { type: 'string', nullable: true, diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts index 6a2f5a4c5b657..995290da50d7c 100644 --- a/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts +++ b/packages/rest-typings/src/v1/integrations/IntegrationsUpdateProps.ts @@ -8,6 +8,7 @@ export type IntegrationsUpdateProps = integrationId: string; channel: string; scriptEnabled: boolean; + skipTranspile?: boolean; scriptEngine: 'isolated-vm'; overrideDestinationChannelEnabled?: boolean; script?: string; @@ -32,6 +33,7 @@ export type IntegrationsUpdateProps = token?: string; scriptEnabled: boolean; + skipTranspile?: boolean; scriptEngine: 'isolated-vm'; script?: string; runOnEdits?: boolean; @@ -71,6 +73,10 @@ const integrationsUpdateSchema = { type: 'boolean', nullable: false, }, + skipTranspile: { + type: 'boolean', + nullable: true, + }, scriptEngine: { type: 'string', nullable: false, @@ -167,6 +173,10 @@ const integrationsUpdateSchema = { type: 'boolean', nullable: false, }, + skipTranspile: { + type: 'boolean', + nullable: true, + }, scriptEngine: { type: 'string', nullable: false,