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>
This commit is contained in:
Will Hunt 2023-09-25 15:55:15 +01:00 committed by GitHub
parent d744974493
commit dc126afa6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 87 additions and 48 deletions

1
changelog.d/817.misc Normal file
View File

@ -0,0 +1 @@
Use quickjs instead of vm2 for evaluating JS transformation functions.

View File

@ -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"

View File

@ -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);

View File

@ -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,

View File

@ -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");

View File

@ -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"
};
}`;

View File

@ -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"