diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 34556dfb..f79b530c 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -271,6 +271,9 @@ export interface BridgeGenericWebhooksConfigYAML { urlPrefix: string; userIdPrefix?: string; allowJsTransformationFunctions?: boolean; + transformationFeatures?: { + allowloadMatrixScript?: boolean; + }; waitForComplete?: boolean; enableHttpGet?: boolean; } @@ -286,6 +289,11 @@ export class BridgeConfigGenericWebhooks { public readonly allowJsTransformationFunctions?: boolean; public readonly waitForComplete?: boolean; public readonly enableHttpGet: boolean; + + public readonly transformationFeatures?: { + allowloadMatrixScript?: boolean; + }; + constructor(yaml: BridgeGenericWebhooksConfigYAML) { this.enabled = yaml.enabled || false; this.enableHttpGet = yaml.enableHttpGet || false; @@ -298,6 +306,7 @@ export class BridgeConfigGenericWebhooks { this.userIdPrefix = yaml.userIdPrefix; this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; this.waitForComplete = yaml.waitForComplete; + this.transformationFeatures = yaml.transformationFeatures; } @hideKey() diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index dea2ff00..cc15e27c 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -36,7 +36,7 @@ export interface GenericHookSecrets { export type GenericHookResponseItem = GetConnectionsResponseItem; -const MatrixUriEventRegex = /^matrix:(roomid|r)\/(.+:.+)\/[a-zA-Z0-9+/]+/; +const MatrixUriEventRegex = /^matrix:(roomid|r)\/(.+:.+)\/(\$.+)/; /** */ export interface GenericHookAccountData { @@ -61,6 +61,8 @@ const TRANSFORMATION_TIMEOUT_MS = 500; const SANITIZE_MAX_DEPTH = 5; const SANITIZE_MAX_BREADTH = 25; +const SCRIPT_EXECUTE_LIMIT = 10; + /** * Handles rooms connected to a generic webhook. */ @@ -201,15 +203,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection private readonly messageClient: MessageSenderClient, private readonly config: BridgeConfigGenericWebhooks, private readonly as: Appservice) { - super(roomId, stateKey, GenericHookConnection.CanonicalEventType); - if (state.transformationFunction && config.allowJsTransformationFunctions) { - this.transformationFunction = new Script(state.transformationFunction); - } + super(roomId, stateKey, GenericHookConnection.CanonicalEventType); + if (state.transformationFunction && config.allowJsTransformationFunctions) { + this.transformationFunction = GenericHookConnection.compileScript(state.transformationFunction); } + } - public get priority(): number { - return this.state.priority || super.priority; - } + public static compileScript(scriptSrc: string) { + // We do this so that any `await` calls at the top level work. + return new Script(`return (async () => { ${scriptSrc} })();`); + } + + public get priority(): number { + return this.state.priority || super.priority; + } public isInterestedInStateEvent(eventType: string, stateKey: string) { @@ -257,7 +264,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection const validatedConfig = GenericHookConnection.validateState(stateEv.content as Record, this.config.allowJsTransformationFunctions || false); if (validatedConfig.transformationFunction) { try { - this.transformationFunction = new Script(validatedConfig.transformationFunction); + this.transformationFunction = GenericHookConnection.compileScript(validatedConfig.transformationFunction); } catch (ex) { await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex); } @@ -294,8 +301,30 @@ export class GenericHookConnection extends BaseConnection implements IConnection // TODO: Transform Slackdown into markdown. return msg; } + + public async loadMatrixScript(scriptPath: string) { + if (typeof scriptPath !== "string") { + throw Error('loadMatrixScript takes a string') + } + const matrixUri = MatrixUriEventRegex.exec(scriptPath); + if (!matrixUri) { + throw Error('Not a valid matrix path. Use the event URI scheme from https://spec.matrix.org/v1.3/appendices/#matrix-uri-scheme'); + } + const [ _, type, prefixlessRoomId, eventId ] = matrixUri; + let roomId = "!" + prefixlessRoomId; + if (type === "r") { + // RoomAlias -> resolve + roomId = await this.as.botClient.resolveRoom("#" + prefixlessRoomId); + } + const eventData = (await this.as.botClient.getEvent(roomId, eventId)).content as GenericHookConnectionState; - public executeTransformationFunction(data: unknown): {plain: string, html?: string, msgtype?: string}|null { + if (typeof eventData.transformationFunction !== "string") { + throw Error('Event did not contain a transformation function!'); + } + return eventData.transformationFunction; + } + + public async executeTransformationFunction(data: unknown): Promise<{plain: string, html?: string, msgtype?: string}|null> { if (!this.transformationFunction) { throw Error('Transformation function not defined'); } @@ -306,31 +335,37 @@ export class GenericHookConnection extends BaseConnection implements IConnection eval: false, timeout: TRANSFORMATION_TIMEOUT_MS, }); + vm.setGlobal('HookshotApiVersion', 'v2'); vm.setGlobal('data', data); - vm.setGlobal('loadMatrixScript', async (scriptPath: string) => { - if (typeof scriptPath !== "string") { - throw Error('loadMatrixScript takes a string') - } - const matrixUri = MatrixUriEventRegex.exec(scriptPath); - if (!matrixUri) { - throw Error('Not a valid matrix path. Use the event URI scheme from https://spec.matrix.org/v1.3/appendices/#matrix-uri-scheme'); - } - let roomId = "!" + matrixUri[2]; - if (matrixUri[1] === "r") { - // RoomAlias -> resolve - roomId = await this.as.botClient.resolveRoom("#" + matrixUri[2]); - } - const eventData = await this.as.botClient.getEvent(roomId, matrixUri[3]); - if (typeof eventData.content.transformationFunction !== "string") { - throw Error('Event did not contain a transformation function!'); - } - // Add a callback to run the script in a seperate context. - return () => { - vm.run(eventData.content.transformationFunction); - } - }); - vm.run(this.transformationFunction); + + if (this.config.transformationFeatures?.allowloadMatrixScript) { + let executions = 0; + vm.setGlobal('loadMatrixScript', async (scriptPath: string) => { + // TODO: Cache scripts. + // TODO: Hot-path scripts which we already have loaded in from other connections. + // Prevent a script from nesting too hard. + if (executions > SCRIPT_EXECUTE_LIMIT) { + throw Error('Execution limit for loadMatrixScript reached'); + } + executions++; + const script = await this.loadMatrixScript(scriptPath); + const innerVM = new NodeVM({ + console: 'off', + wrapper: 'none', + wasm: false, + eval: false, + timeout: TRANSFORMATION_TIMEOUT_MS, + }); + innerVM.setGlobal('HookshotApiVersion', 'v2'); + innerVM.setGlobal('data', data); + await innerVM.run(script); + return innerVM.getGlobal('result'); + }); + } + + const script = this.transformationFunction; + await vm.run(script); const result = vm.getGlobal('result'); // Legacy v1 api @@ -379,7 +414,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection content = this.transformHookData(data); } else { try { - const potentialContent = this.executeTransformationFunction(data); + const potentialContent = await this.executeTransformationFunction(data); if (potentialContent === null) { // Explitly no action return true;