CVE-2023-43656 (published 2023-09-27T21:15Z)

matrix-hookshot is a Matrix bot for connecting to external services like GitHub, GitLab, JIRA, and more. Instances that have enabled transformation functions (those that have `generic.allowJsTransformationFunctions` in their config), may be vulnerable to an attack where it is possible to break out of the `vm2` sandbox and as a result Hookshot will be vulnerable to this. This problem is only likely to affect users who have allowed untrusted users to apply their own transformation functions. If you have only enabled a limited set of trusted users, this threat is reduced (though not eliminated). Version 4.5.0 and above of hookshot include a new sandbox library which should better protect users. Users are advised to upgrade. Users unable to upgrade should disable `generic.allowJsTransformationFunctions` in the config.

CVSS v3 base score: 9.0, CRITICAL (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H)

Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/matrix-org/matrix-hookshot
commit_details: repo_url: https://github.com/matrix-org/matrix-hookshot
author: Will Hunt
author_date: 2023-09-25 15:55:15+01:00
msg: Replace vm2 with quickjs (#817)

* quickjs test

* Replace vm2 with quickjs

* initalise -> initialise

* Remove unused transformation timeout time

* Don't assume quickModule is set

Also use whether it's set as the indicator of whether transformation
functions are allowed, instead of checking the config

* Refactor GenericHookConnectionState validation

- Do it in the constructor instead of in callers
- Make hookId mandatory so as to not drop it on some state updates
- Conflate a state event's state key with a connection state's name,
  which was already the case in practice

* Refactor validateState

* Drop explicit any

Better to infer the type instead

* Always validate transformation fn

* Fix test

* Add changelog

* Fix disposal, validation, and printing

* Fix transformation error string formatting

Also refactor similar code

* Let invalid transformations run & fail

instead of pretending that one was never set

* Restore transformation timeout time

* Don't execute transformation fn when validating it

Instead, only compile it

* Revert unrelated changes

---------

Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
repository: repo_url: https://github.com/matrix-org/matrix-hookshot
repo_name: matrix-org/matrix-hookshot
description: A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.
repo_language: TypeScript
owner: matrix-org
file_changes: filename: 817.misc
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Use quickjs instead of vm2 for evaluating JS transformation functions.

programming_language: Markdown
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -65,11 +65,11 @@
     "nyc": "^15.1.0",
     "p-queue": "^6.6.2",
     "prom-client": "^14.2.0",
+    "quickjs-emscripten": "^0.23.0",
     "reflect-metadata": "^0.1.13",
     "source-map-support": "^0.5.21",
     "string-argv": "^0.3.1",
     "tiny-typed-emitter": "^2.1.0",
-    "vm2": "^3.9.18",
     "winston": "^3.3.3",
     "xml2js": "^0.5.0",
     "yaml": "^2.2.2"

programming_language: CoffeeScript
filename: BridgeApp.ts
change_type: ModificationType.MODIFY
diff: @@ -10,6 +10,7 @@ import { LogService } from "matrix-bot-sdk";
 import { getAppservice } from "../appservice";
 import BotUsersManager from "../Managers/BotUsersManager";
 import * as Sentry from '@sentry/node';
+import { GenericHookConnection } from "../Connections";
 
 Logger.configure({console: "info"});
 const log = new Logger("App");
@@ -49,6 +50,10 @@ async function start() {
         log.info("Sentry reporting enabled");
     }
 
+    if (config.generic?.allowJsTransformationFunctions) {
+        await GenericHookConnection.initialiseQuickJS();
+    }
+
     const botUsersManager = new BotUsersManager(config, appservice);
 
     const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);

programming_language: TypeScript
filename: GenericHook.ts
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,7 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, P
 import { Logger } from "matrix-appservice-bridge";
 import { MessageSenderClient } from "../MatrixSender"
 import markdownit from "markdown-it";
-import { VMScript as Script, NodeVM } from "vm2";
+import { QuickJSRuntime, QuickJSWASMModule, newQuickJSWASMModule, shouldInterruptAfterDeadline } from "quickjs-emscripten";
 import { MatrixEvent } from "../MatrixEvent";
 import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
 import { ApiError, ErrCode } from "../api";
@@ -65,6 +65,11 @@ const SANITIZE_MAX_BREADTH = 50;
  */
 @Connection
 export class GenericHookConnection extends BaseConnection implements IConnection {
+    private static quickModule?: QuickJSWASMModule;
+
+    public static async initialiseQuickJS() {
+        GenericHookConnection.quickModule = await newQuickJSWASMModule();
+    }
 
     /**
      * Ensures a JSON payload is compatible with Matrix JSON requirements, such
@@ -107,27 +112,26 @@ export class GenericHookConnection extends BaseConnection implements IConnection
         return obj;
     }
 
-    static validateState(state: Record<string, unknown>, allowJsTransformationFunctions?: boolean): GenericHookConnectionState {
+    static validateState(state: Record<string, unknown>): GenericHookConnectionState {
         const {name, transformationFunction} = state;
-        let transformationFunctionResult: string|undefined;
-        if (transformationFunction) {
-            if (allowJsTransformationFunctions !== undefined && !allowJsTransformationFunctions) {
-                throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
-            }
-            if (typeof transformationFunction !== "string") {
-                throw new ApiError('Transformation functions must be a string', ErrCode.BadValue);
-            }
-            transformationFunctionResult = transformationFunction;
-        }
         if (!name) {
             throw new ApiError('Missing name', ErrCode.BadValue);
         }
         if (typeof name !== "string" || name.length < 3 || name.length > 64) {
             throw new ApiError("'name' must be a string between 3-64 characters long", ErrCode.BadValue);
         }
+        // Use !=, not !==, to check for both undefined and null
+        if (transformationFunction != undefined) {
+            if (!this.quickModule) {
+                throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
+            }
+            if (typeof transformationFunction !== "string") {
+                throw new ApiError('Transformation functions must be a string', ErrCode.BadValue);
+            }
+        }
         return {
             name,
-            ...(transformationFunctionResult && {transformationFunction: transformationFunctionResult}),
+            ...(transformationFunction && {transformationFunction}),
         };
     }
 
@@ -163,7 +167,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
             throw Error('Generic Webhooks are not configured');
         }
         const hookId = randomUUID();
-        const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false);
+        const validState = GenericHookConnection.validateState(data);
         await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name);
         await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
         const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent);
@@ -197,8 +201,11 @@ export class GenericHookConnection extends BaseConnection implements IConnection
         GenericHookConnection.LegacyCanonicalEventType,
     ];
 
-    private transformationFunction?: Script;
+    private transformationFunction?: string;
     private cachedDisplayname?: string;
+    /**
+     * @param state Should be a pre-validated state object returned by {@link validateState}
+     */
     constructor(
         roomId: string,
         private state: GenericHookConnectionState,
@@ -210,8 +217,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection
         private readonly intent: Intent,
     ) {
         super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
-        if (state.transformationFunction && config.allowJsTransformationFunctions) {
-            this.transformationFunction = new Script(state.transformationFunction);
+        if (state.transformationFunction && GenericHookConnection.quickModule) {
+            this.transformationFunction = state.transformationFunction;
         }
     }
 
@@ -259,12 +266,26 @@ export class GenericHookConnection extends BaseConnection implements IConnection
     }
 
     public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
-        const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record<string, unknown>, this.config.allowJsTransformationFunctions || false);
+        const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record<string, unknown>);
         if (validatedConfig.transformationFunction) {
-            try {
-                this.transformationFunction = new Script(validatedConfig.transformationFunction);
-            } catch (ex) {
-                await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId);
+            const ctx = GenericHookConnection.quickModule!.newContext();
+            const codeEvalResult = ctx.evalCode(`function f(data) {${validatedConfig.transformationFunction}}`, undefined, { compileOnly: true });
+            if (codeEvalResult.error) {
+                const errorString = JSON.stringify(ctx.dump(codeEvalResult.error), null, 2);
+                codeEvalResult.error.dispose();
+                ctx.dispose();
+
+                const errorPrefix = "Could not compile transformation function:";
+                await this.intent.sendEvent(this.roomId, {
+                    msgtype: "m.text",
+                    body: errorPrefix + "\n\n```json\n\n" + errorString + "\n\n```",
+                    formatted_body: `<p>${errorPrefix}</p><p><pre><code class=\\"language-json\\">${errorString}</code></pre></p>`,
+                    format: "org.matrix.custom.html",
+                });
+            } else {
+                codeEvalResult.value.dispose();
+                ctx.dispose();
+                this.transformationFunction = validatedConfig.transformationFunction;
             }
         } else {
             this.transformationFunction = undefined;
@@ -281,8 +302,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection
         } else if (typeof safeData?.text === "string") {
             msg.plain = safeData.text;
         } else {
-            msg.plain = "Received webhook data:\n\n" + "```json\n\n" + JSON.stringify(data, null, 2) + "\n\n```";
-            msg.html = `<p>Received webhook data:</p><p><pre><code class=\\"language-json\\">${JSON.stringify(data, null, 2)}</code></pre></p>`
+            const dataString = JSON.stringify(data, null, 2);
+            const dataPrefix = "Received webhook data:";
+            msg.plain = dataPrefix + "\n\n```json\n\n" + dataString + "\n\n```";
+            msg.html = `<p>${dataPrefix}</p><p><pre><code class=\\"language-json\\">${dataString}</code></pre></p>`
         }
 
         if (typeof safeData?.html === "string") {
@@ -304,17 +327,27 @@ export class GenericHookConnection extends BaseConnection implements IConnection
         if (!this.transformationFunction) {
             throw Error('Transformation function not defined');
         }
-        const vm = new NodeVM({
-            console: 'off',
-            wrapper: 'none',
-            wasm: false,
-            eval: false,
-            timeout: TRANSFORMATION_TIMEOUT_MS,
-        });
-        vm.setGlobal('HookshotApiVersion', 'v2');
-        vm.setGlobal('data', data);
-        vm.run(this.transformationFunction);
-        const result = vm.getGlobal('result');
+        let result;
+        const ctx = GenericHookConnection.quickModule!.newContext();
+        ctx.runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + TRANSFORMATION_TIMEOUT_MS));
+        try {
+            ctx.setProp(ctx.global, 'HookshotApiVersion', ctx.newString('v2'));
+            const ctxResult = ctx.evalCode(`const data = ${JSON.stringify(data)};\n\n${this.state.transformationFunction}`);
+
+            if (ctxResult.error) {
+                const e = Error(`Transformation failed to run: ${JSON.stringify(ctx.dump(ctxResult.error))}`);
+                ctxResult.error.dispose();
+                throw e;
+            } else {
+                const value = ctx.getProp(ctx.global, 'result');
+                result = ctx.dump(value);
+                value.dispose();
+                ctxResult.value.dispose();
+            }
+        } finally {
+            ctx.global.dispose();
+            ctx.dispose();
+        }
 
         // Legacy v1 api
         if (typeof result === "string") {
@@ -437,7 +470,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
     public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
         // Apply previous state to the current config, as provisioners might not return "unknown" keys.
         config = { ...this.state, ...config };
-        const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false);
+        const validatedConfig = GenericHookConnection.validateState(config);
         await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
             {
                 ...validatedConfig,

programming_language: TypeScript
filename: GenericHookTest.ts
change_type: ModificationType.MODIFY
diff: @@ -56,6 +56,9 @@ function handleMessage(mq: LocalMQ): Promise<IMatrixSendMessage> {
 }
 
 describe("GenericHookConnection", () => {
+    before(async () => {
+        await GenericHookConnection.initialiseQuickJS();
+    })
     it("will handle simple hook events", async () => {
         const [connection, mq] = createGenericHook();
         await testSimpleWebhook(connection, mq, "data");

programming_language: TypeScript
filename: GenericWebhookConfig.tsx
change_type: ModificationType.MODIFY
diff: @@ -20,7 +20,7 @@ const EXAMPLE_SCRIPT = `if (data.counter === undefined) {
     };
   } else {
     result = {
-          plain: \`*Everything is fine*, the counter is under by\${data.maxValue - data.counter}\`,
+          plain: \`*Everything is fine*, the counter is under by \${data.maxValue - data.counter}\`,
           version: "v2"
     };
   }`;

programming_language: TypeScript
filename: yarn.lock
change_type: ModificationType.MODIFY
diff: @@ -1754,12 +1754,12 @@ acorn-jsx@^5.3.2:
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-acorn-walk@^8.1.1, acorn-walk@^8.2.0:
+acorn-walk@^8.1.1:
   version "8.2.0"
   resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
   integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
 
-acorn@^8.4.1, acorn@^8.7.0:
+acorn@^8.4.1:
   version "8.7.1"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
   integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
@@ -5240,6 +5240,11 @@ queue-microtask@^1.2.2:
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
+quickjs-emscripten@^0.23.0:
+  version "0.23.0"
+  resolved "https://registry.yarnpkg.com/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#94f412d0ee5f3021fc12ddf6c0b00bd8ce0d28b9"
+  integrity sha512-CIP+NDRYDDqbT3cTiN8Bon1wsZ7IgISVYCJHYsPc86oxszpepVMPXFfttyQgn1u1okg1HPnCnM7Xv1LrCO/VmQ==
+
 railroad-diagrams@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
@@ -6166,14 +6171,6 @@ vite@^4.1.5:
   optionalDependencies:
     fsevents "~2.3.2"
 
-vm2@^3.9.18:
-  version "3.9.18"
-  resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.18.tgz#d919848bee191a0410c5cc1c5aac58adfd03ce9a"
-  integrity sha512-iM7PchOElv6Uv6Q+0Hq7dcgDtWWT6SizYqVcvol+1WQc+E9HlgTCnPozbQNSP3yDV9oXHQOEQu530w2q/BCVZg==
-  dependencies:
-    acorn "^8.7.0"
-    acorn-walk "^8.2.0"
-
 w3c-keyname@^2.2.4:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"

programming_language: Shell