Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fruity-items-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/apps': minor
'@rocket.chat/meteor': minor
---

Adds an alternative runtime runner for apps. It can be enabled via environment variable `APPS_ENGINE_RUNTIME_BACKEND='node'`
24 changes: 21 additions & 3 deletions .github/workflows/ci-test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ jobs:
run: echo "DEBUG_LOG_LEVEL=2" >> "$GITHUB_ENV"

- name: Start httpbin container and wait for it to be ready
if: inputs.type == 'api' || inputs.type == 'api-livechat'
if: startsWith(inputs.type, 'api')
run: |
docker compose -f docker-compose-ci.yml up -d httpbin

Expand All @@ -186,7 +186,7 @@ jobs:
# behavior on (rate-limiter bypass, short cache TTLs) while letting
# the deprecation logger log without throwing. Other suites use the
# docker-compose default of TEST_MODE='true'.
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
run: |
# when we are testing CE, we only need to start the rocketchat container
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait
Expand All @@ -197,7 +197,8 @@ jobs:
ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }}
TRANSPORTER: ${{ inputs.transporter }}
COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }}
TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }}
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
APPS_ENGINE_RUNTIME_BACKEND: ${{ inputs.type == 'api-apps-node' && 'node' || '' }}
run: |
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait

Expand Down Expand Up @@ -234,6 +235,23 @@ jobs:
ls -la "$COVERAGE_DIR"
exit "${s:-0}"

# This step should be temporary, only here until we remove the deno-runtime
- name: E2E Test API (apps + node-runtime)
if: (inputs.type == 'api-apps-node' && inputs.release == 'ee')
working-directory: ./apps/meteor
env:
WEBHOOK_TEST_URL: 'http://httpbin'
IS_EE: 'true'
run: |
set -o xtrace

npm run testapi:apps || s=$?

docker compose -f ../../docker-compose-ci.yml stop

ls -la "$COVERAGE_DIR"
exit "${s:-0}"

- name: E2E Test API (Livechat)
if: inputs.type == 'api-livechat'
working-directory: ./apps/meteor
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,27 @@ jobs:
CR_PAT: ${{ secrets.CR_PAT }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

test-api-apps-node-ee:
name: 🔨 Test API Apps (node-runtime - EE)
needs: [checks, build-gh-docker-publish, release-versions]

uses: ./.github/workflows/ci-test-e2e.yml
with:
type: api-apps-node
release: ee
transporter: 'nats://nats:4222'
enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }}
mongodb-version: "['8.0']"
coverage: '8.0'
node-version: ${{ needs.release-versions.outputs.node-version }}
deno-version: ${{ needs.release-versions.outputs.deno-version }}
lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }}
gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }}
secrets:
CR_USER: ${{ secrets.CR_USER }}
CR_PAT: ${{ secrets.CR_PAT }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

test-ui-ee:
name: 🔨 Test UI (EE)
needs: [checks, build-gh-docker-publish, release-versions]
Expand Down
15 changes: 15 additions & 0 deletions apps/meteor/.mocharc.api.apps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

/*
* Mocha configuration for REST API integration tests.
*/

module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: false,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
reporter: 'tests/end-to-end/reporter.ts',
spec: ['tests/end-to-end/apps/*'],
});
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"test:e2e:nyc": "nyc report --reporter=lcovonly",
"testapi": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.js",
"testapi:livechat": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.livechat.js",
"testapi:apps": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --config ./.mocharc.api.apps.js",
"testunit": "yarn .testunit:definition && yarn .testunit:jest && yarn .testunit:server:cov",
"testunit-watch": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' mocha --watch --config ./.mocharc.js",
"typecheck": "meteor lint && cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --skipLibCheck",
Expand Down
1 change: 1 addition & 0 deletions docker-compose-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ services:
image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${DOCKER_TAG}${DOCKER_TAG_SUFFIX_ROCKETCHAT:-}
environment:
- 'TEST_MODE=${TEST_MODE:-true}'
- APPS_ENGINE_RUNTIME_BACKEND=${APPS_ENGINE_RUNTIME_BACKEND:-}
- DEBUG=${DEBUG:-}
- EXIT_UNHANDLEDPROMISEREJECTION=true
- MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0
Expand Down
2 changes: 1 addition & 1 deletion packages/apps/deno-runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { stdoutTransport } from './lib/transports/stdoutTransport';

if (!process.argv.includes('--subprocess')) {
console.error(`
This is a Deno wrapper for Rocket.Chat Apps. It is not meant to be executed stand-alone;
This is the Deno wrapper for the Rocket.Chat Apps runtime. It is not meant to be executed stand-alone;
It is instead meant to be executed as a subprocess by the Apps-Engine framework.
`);

Expand Down
10 changes: 10 additions & 0 deletions packages/apps/node-runtime/src/error-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Messenger from '@rocket.chat/apps/base-runtime/dist/lib/messenger';

export default function registerErrorListeners() {
process.on('uncaughtException', (error: Error, origin: 'uncaughtException' | 'unhandledRejection') => {
Messenger.sendNotification({
method: origin,
params: [error.stack || error],
});
});
}
19 changes: 19 additions & 0 deletions packages/apps/node-runtime/src/lib/loader-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { registerHooks } from 'node:module';
import path from 'node:path';

// This file compiles to dist/lib/loader-hook.js.
// Three levels up from dist/lib/ lands on packages/apps/ — the @rocket.chat/apps package root.
const appsPackageDir = path.resolve(__dirname, '../../..');

const PACKAGE_PREFIX = '@rocket.chat/apps';

registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === PACKAGE_PREFIX || specifier.startsWith(`${PACKAGE_PREFIX}/`)) {
const subpath = specifier.slice(PACKAGE_PREFIX.length).replace(/^\//, '');
const localPath = subpath ? path.join(appsPackageDir, subpath) : appsPackageDir;
return nextResolve(localPath, context);
}
return nextResolve(specifier, context);
},
});
37 changes: 37 additions & 0 deletions packages/apps/node-runtime/src/lib/require.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const ALLOWED_MODULES = [
'path',
'url',
'crypto',
'buffer',
'stream',
'net',
'http',
'https',
Comment on lines +7 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INFO Intentional Network Access in Apps-Engine Runtime

The node-runtime introduces a new sandbox implementation that explicitly allows apps to require sensitive Node.js modules, including net, http, and https. This allows any installed app to perform arbitrary network requests, leading to Server-Side Request Forgery (SSRF) and potential data exfiltration. The sandbox fails to restrict network access, which is a core requirement for a secure Apps-Engine environment.

Steps to Reproduce
An attacker can create a malicious Rocket.Chat app that uses `require('net')` or `require('http')` to make requests to internal services or external endpoints, bypassing any intended network restrictions. Example: `const http = require('http'); http.get('http://internal-service:8080/data', (res) => { ... });`
Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: packages/apps/node-runtime/src/lib/require.ts
Lines: 7-9
Severity: info

Vulnerability: Intentional Network Access in Apps-Engine Runtime

Description:
The `node-runtime` introduces a new sandbox implementation that explicitly allows apps to `require` sensitive Node.js modules, including `net`, `http`, and `https`. This allows any installed app to perform arbitrary network requests, leading to Server-Side Request Forgery (SSRF) and potential data exfiltration. The sandbox fails to restrict network access, which is a core requirement for a secure Apps-Engine environment.

Proof of Concept:
An attacker can create a malicious Rocket.Chat app that uses `require('net')` or `require('http')` to make requests to internal services or external endpoints, bypassing any intended network restrictions. Example: `const http = require('http'); http.get('http://internal-service:8080/data', (res) => { ... });`

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron

'zlib',
'util',
'punycode',
'os',
'querystring',
'fs',
// External libraries
'uuid',
'@rocket.chat/apps-engine',
];

// As the apps are bundled, the only times they will call require are
// 1. To require native modules
// 2. To require external npm packages we may provide
// 3. To require apps-engine files
export const sandboxRequire = (module: string) => {
// Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto'
const normalized = module.replace('node:', '');

// We allow variants like 'fs', 'node:fs' or 'node:fs/promises', or even '@rocket.chat/apps-engine/**'
if (!ALLOWED_MODULES.some((mod) => normalized.startsWith(mod))) {
throw new Error(`Module ${module} is not allowed`);
}
Comment on lines +30 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH Weak Prefix Matching in sandboxRequire Allows Sandbox Escape and Path Traversal

The sandboxRequire function in packages/apps/node-runtime/src/lib/require.ts attempts to restrict the modules that a sandboxed Rocket.Chat app can import by checking if the requested module name starts with any of the allowed module names in ALLOWED_MODULES.

However, because it uses .startsWith(mod) without ensuring proper boundary separation (such as checking for equality, or verifying that the next character is / or :), it is vulnerable to prefix-matching bypasses and path traversal:

  1. Prefix-matching bypass: Any module name that begins with an allowed module name as a prefix will be permitted. For example, if 'fs' is allowed, then 'fs-extra' (if installed) is also allowed. If 'path' is allowed, then 'path-to-regexp' is allowed.
  2. Path traversal: An attacker can construct a path starting with an allowed module name followed by directory traversal sequences, such as '@rocket.chat/apps-engine/../../package.json'. Since this starts with '@rocket.chat/apps-engine', it passes the validation check. Node's require() will then resolve the path relative to the @rocket.chat/apps-engine package, allowing the app to load and execute arbitrary JavaScript files within the allowed read directories (such as the node-runtime package or other files in the monorepo).
Steps to Reproduce

An app running in the sandboxed Node.js runtime can execute:

const p = require('@rocket.chat/apps-engine/../../package.json');

This will successfully bypass the allowed modules check and load the package.json file.

Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: packages/apps/node-runtime/src/lib/require.ts
Lines: 30-32
Severity: high

Vulnerability: Weak Prefix Matching in sandboxRequire Allows Sandbox Escape and Path Traversal

Description:
The `sandboxRequire` function in `packages/apps/node-runtime/src/lib/require.ts` attempts to restrict the modules that a sandboxed Rocket.Chat app can import by checking if the requested module name starts with any of the allowed module names in `ALLOWED_MODULES`.

However, because it uses `.startsWith(mod)` without ensuring proper boundary separation (such as checking for equality, or verifying that the next character is `/` or `:`), it is vulnerable to prefix-matching bypasses and path traversal:
1. **Prefix-matching bypass**: Any module name that begins with an allowed module name as a prefix will be permitted. For example, if `'fs'` is allowed, then `'fs-extra'` (if installed) is also allowed. If `'path'` is allowed, then `'path-to-regexp'` is allowed.
2. **Path traversal**: An attacker can construct a path starting with an allowed module name followed by directory traversal sequences, such as `'@rocket.chat/apps-engine/../../package.json'`. Since this starts with `'@rocket.chat/apps-engine'`, it passes the validation check. Node's `require()` will then resolve the path relative to the `@rocket.chat/apps-engine` package, allowing the app to load and execute arbitrary JavaScript files within the allowed read directories (such as the `node-runtime` package or other files in the monorepo).

Proof of Concept:
An app running in the sandboxed Node.js runtime can execute:
```javascript
const p = require('@rocket.chat/apps-engine/../../package.json');
```
This will successfully bypass the allowed modules check and load the package.json file.

Affected Code:
	if (!ALLOWED_MODULES.some((mod) => normalized.startsWith(mod))) {
		throw new Error(`Module ${module} is not allowed`);
	}

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron


// This is THE purpose of this function, we can't escape a dinamyc require call
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, import/no-dynamic-require, @typescript-eslint/no-require-imports
return require(normalized);
};
Comment on lines +25 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH Sandbox Bypass and Remote Code Execution via Weak Module Validation in sandboxRequire

The sandboxRequire function uses an insecure startsWith check to validate allowed modules. This allows an attacker to bypass the sandbox by requiring arbitrary modules that share a prefix with an allowed module (e.g., fs-extra via fs) or by using path traversal to load arbitrary files from the filesystem when an allowed package prefix (e.g., @rocket.chat/apps-engine) is used. An attacker with the ability to deploy or run a custom application can leverage this to execute arbitrary code on the host system.

Steps to Reproduce
// Inside a sandboxed Rocket.Chat app:
const payload = require('@rocket.chat/apps-engine/../../../../../../tmp/malicious.js');
Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: packages/apps/node-runtime/src/lib/require.ts
Lines: 25-37
Severity: high

Vulnerability: Sandbox Bypass and Remote Code Execution via Weak Module Validation in sandboxRequire

Description:
The `sandboxRequire` function uses an insecure `startsWith` check to validate allowed modules. This allows an attacker to bypass the sandbox by requiring arbitrary modules that share a prefix with an allowed module (e.g., `fs-extra` via `fs`) or by using path traversal to load arbitrary files from the filesystem when an allowed package prefix (e.g., `@rocket.chat/apps-engine`) is used. An attacker with the ability to deploy or run a custom application can leverage this to execute arbitrary code on the host system.

Proof of Concept:
// Inside a sandboxed Rocket.Chat app:
const payload = require('@rocket.chat/apps-engine/../../../../../../tmp/malicious.js');

Affected Code:
export const sandboxRequire = (module: string) => {
	// Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto'
	const normalized = module.replace('node:', '');

	// We allow variants like 'fs', 'node:fs' or 'node:fs/promises', or even '@rocket.chat/apps-engine/**'
	if (!ALLOWED_MODULES.some((mod) => normalized.startsWith(mod))) {
		throw new Error(`Module ${module} is not allowed`);
	}

	// This is THE purpose of this function, we can't escape a dinamyc require call
	// eslint-disable-next-line @typescript-eslint/no-unsafe-return, import/no-dynamic-require, @typescript-eslint/no-require-imports
	return require(normalized);
};

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron

16 changes: 16 additions & 0 deletions packages/apps/node-runtime/src/lib/transports/stdoutTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Transport } from '@rocket.chat/apps/base-runtime/dist/lib/messenger';

/**
* Transport that writes messages to the process' standard output.
*
* This is the transport used when the runtime is executed as a subprocess by
* the Apps-Engine framework, and it is specific to platforms that expose a
* Node-compatible `process.stdout`.
*/
export const stdoutTransport: Transport = {
async send(message: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) => {
process.stdout.write(message, (err) => (err ? reject(err) : resolve()));
});
},
};
30 changes: 30 additions & 0 deletions packages/apps/node-runtime/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import './lib/loader-hook';

import { setSandboxGlobals, setSandboxRequire } from '@rocket.chat/apps/base-runtime/dist/handlers/app/construct';
import { setTransport } from '@rocket.chat/apps/base-runtime/dist/lib/messenger';
import { startMainLoop } from '@rocket.chat/apps/base-runtime/dist/mainLoop';

import registerErrorListeners from './error-handlers';
import { sandboxRequire } from './lib/require';
import { stdoutTransport } from './lib/transports/stdoutTransport';

if (!process.argv.includes('--subprocess')) {
console.error(`
This is the Node wrapper for the Rocket.Chat Apps runtime. It is not meant to be executed stand-alone;
It is instead meant to be executed as a subprocess by the Apps-Engine framework.
`);

process.exit(1);
}

// This runtime communicates with the Apps-Engine host through stdout
setTransport(stdoutTransport);

// The sandbox `require` handed to the app is Node's own global `require`; it
// needs no extra globals beyond the common ones the base eval shell binds.
setSandboxRequire(sandboxRequire);
setSandboxGlobals({});
Comment on lines +23 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL Sandbox Escape and Arbitrary Code Execution via Insecure Node.js Runtime Sandbox

Vulnerability Description

The newly introduced Node.js runtime (node-runtime) for Rocket.Chat Apps executes third-party app code using new Function in the main process context without any VM-level isolation or sandboxing.

While the Deno runtime can leverage Deno's native permission model (e.g., restricting file system and network access), Node.js has no such built-in restrictions. By using new Function to wrap and execute the app's code, the runtime exposes all Node.js global variables (such as process and global) to the executing app.

Exploitation Path

An attacker who is able to install or run a malicious Rocket.Chat App can easily escape the intended sandbox and execute arbitrary system commands on the host by accessing process.mainModule.require('child_process') or similar APIs.

Concrete Impact

This leads to complete compromise of the host system running the Rocket.Chat server (Remote Code Execution with the privileges of the Rocket.Chat process).

Steps to Reproduce

An attacker can install a malicious Rocket.Chat App containing the following code:

const payload = () => {
    try {
        const require = process.mainModule.require;
        const execSync = require('child_process').execSync;
        const output = execSync('id').toString();
        console.error("EXPLOITED: " + output);
    } catch (e) {
        console.error("Failed to exploit: " + e.message);
    }
};
payload();
Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: packages/apps/node-runtime/src/main.ts
Lines: 23-26
Severity: critical

Vulnerability: Sandbox Escape and Arbitrary Code Execution via Insecure Node.js Runtime Sandbox

Description:
### Vulnerability Description
The newly introduced Node.js runtime (`node-runtime`) for Rocket.Chat Apps executes third-party app code using `new Function` in the main process context without any VM-level isolation or sandboxing. 

While the Deno runtime can leverage Deno's native permission model (e.g., restricting file system and network access), Node.js has no such built-in restrictions. By using `new Function` to wrap and execute the app's code, the runtime exposes all Node.js global variables (such as `process` and `global`) to the executing app.

### Exploitation Path
An attacker who is able to install or run a malicious Rocket.Chat App can easily escape the intended sandbox and execute arbitrary system commands on the host by accessing `process.mainModule.require('child_process')` or similar APIs.

### Concrete Impact
This leads to complete compromise of the host system running the Rocket.Chat server (Remote Code Execution with the privileges of the Rocket.Chat process).

Proof of Concept:
An attacker can install a malicious Rocket.Chat App containing the following code:

```javascript
const payload = () => {
    try {
        const require = process.mainModule.require;
        const execSync = require('child_process').execSync;
        const output = execSync('id').toString();
        console.error("EXPLOITED: " + output);
    } catch (e) {
        console.error("Failed to exploit: " + e.message);
    }
};
payload();
```

Affected Code:
// The sandbox `require` handed to the app is Node's own global `require`; it
// needs no extra globals beyond the common ones the base eval shell binds.
setSandboxRequire(sandboxRequire);
setSandboxGlobals({});

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron


registerErrorListeners();

void startMainLoop();
18 changes: 18 additions & 0 deletions packages/apps/node-runtime/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"baseUrl": ".",
"types": ["node"],
"lib": ["es2023"],
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2023",
"declaration": true,
"paths": {
"@rocket.chat/apps/*": ["../*"]
}
},
"include": ["./src/**/*"]
}
9 changes: 6 additions & 3 deletions packages/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
"files": [
"dist/",
"base-runtime/",
"node-runtime/",
"deno-runtime/",
".deno-cache/"
],
"scripts": {
"build": "run-s build:clean build:default build:base-runtime build:deno-cache",
"build:clean": "rimraf dist base-runtime/dist",
"build": "run-s build:clean build:default build:base-runtime build:node-runtime build:deno-cache",
"build:clean": "rimraf dist base-runtime/dist node-runtime/dist",
"build:default": "tsc -p tsconfig.json",
"build:base-runtime": "tsc -p base-runtime/tsconfig.json",
"build:node-runtime": "tsc -p node-runtime/tsconfig.json",
"build:deno-cache": "node scripts/deno-cache.js",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"lint": "eslint .",
Expand All @@ -24,7 +26,8 @@
"testunit": "run-s test:node test:deno test:base-runtime",
"typecheck:default": "tsc -p tsconfig.json --noEmit",
"typecheck:base-runtime": "tsc -p base-runtime/tsconfig.json --noEmit",
"typecheck": "run-s typecheck:default typecheck:base-runtime"
"typecheck:node-runtime": "tsc -p node-runtime/tsconfig.json --noEmit",
"typecheck": "run-s typecheck:default typecheck:base-runtime typecheck:node-runtime"
},
"dependencies": {
"@msgpack/msgpack": "3.0.0-beta2",
Expand Down
10 changes: 9 additions & 1 deletion packages/apps/src/server/managers/AppRuntimeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AppManager } from '../AppManager';
import type { IParseAppPackageResult } from '../compiler';
import type { IRuntimeController } from '../runtime/IRuntimeController';
import { DenoRuntimeSubprocessController } from '../runtime/deno/AppsEngineDenoRuntime';
import { NodeRuntimeSubprocessController } from '../runtime/node/AppsEngineNodeRuntime';
import type { IAppStorageItem } from '../storage';

export type AppRuntimeParams = {
Expand All @@ -18,9 +19,16 @@ export type ExecRequestOptions = {
timeout?: number;
};

const defaultRuntimeFactory = (manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) =>
const { APPS_ENGINE_RUNTIME_BACKEND = 'deno' } = process.env;

export const nodeRuntimeFactory = (manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) =>
new NodeRuntimeSubprocessController(manager, appPackage, storageItem);

export const denoRuntimeFactory = (manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) =>
new DenoRuntimeSubprocessController(manager, appPackage, storageItem);

const defaultRuntimeFactory = APPS_ENGINE_RUNTIME_BACKEND === 'node' ? nodeRuntimeFactory : denoRuntimeFactory;

export class AppRuntimeManager {
private readonly subprocesses: Record<string, IRuntimeController> = {};

Expand Down
75 changes: 0 additions & 75 deletions packages/apps/src/server/runtime/AppsEngineNodeRuntime.ts

This file was deleted.

Loading
Loading