-
Notifications
You must be signed in to change notification settings - Fork 0
feat(apps): introduce alternative node-runtime (PR 41019 replay) #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: hacktron-eval/apps-runtime-base
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'` |
| 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/*'], | ||
| }); |
| 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], | ||
| }); | ||
| }); | ||
| } |
| 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); | ||
| }, | ||
| }); |
| 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', | ||
| '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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The However, because it uses
Steps to ReproduceAn 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 AITriage: Reply |
||
|
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Steps to ReproduceFix with AITriage: Reply |
||
| 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())); | ||
| }); | ||
| }, | ||
| }; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Vulnerability DescriptionThe newly introduced Node.js runtime ( 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 Exploitation PathAn 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 Concrete ImpactThis 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 ReproduceAn 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 AITriage: Reply |
||
|
|
||
| registerErrorListeners(); | ||
|
|
||
| void startMainLoop(); | ||
| 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/**/*"] | ||
| } |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
node-runtimeintroduces a new sandbox implementation that explicitly allows apps torequiresensitive Node.js modules, includingnet,http, andhttps. 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
Fix with AI
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