-
Notifications
You must be signed in to change notification settings - Fork 9
Script Executor
This page gives an overview of how to enable certain functions of the universal engine codebase to be made available in ScriptTasks for the end user and which caveats to be aware of when doing so.
The JavaScript code execution in ScriptTasks can be used to define custom code at the design time of the process, that should be executed during runtime on the engine. Due to the PROCEED engine being mostly written in JavaScript itself, it is quite straight forward to do this on the engine side, e. g. with eval()
. However, executing custom third-party code always comes with security concerns. As an evil actor, you could simply run eval('process.exit()')
to end the process.
There are more sophisticated solutions, like using a VM. But even then there are ways to exploit this feature. For example, using the standard vm
built-in module from NodeJS allows an potential attacker to simply shutdown the running application with the following code snippet:
const vm = require('vm');
vm.runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('Never gets executed.');
The safest solution would be to create a completely isolated JavaScript context in a new thread, like the module isolated-vm does. This, however, requires a compiler since it is using C++ addons and it would not allow us to share code between the custom code and the engine code, which we want to achieve.
For that reason, we are using vm2. It can (relatively) safely execute code in a sandbox in the same process.
The arbitrary JS code inside the sandboxed Script Environment can by default contain only the standardized JavaScript methods (the ES version depends on the current runtime supports, usually ES8 because of async await
). Since this is often not enough, the PROCEED engine offers other functions to use inside a Script Task.
The general way to make a function available to ScriptTasks is to use the provideService
method of the neo-bpmn-engine
module. You can specify an identifier string and an object containing the function, which will be available later in the script context.
// universal engine code
const { http } = require('@proceed/system');
NeoEngine.provideService('network', { get: http.get.bind(http), post: http.post.bind(http) });
Note, that we must bind the methods, otherwise the
this
will be not set correctly.
Now we can make use of the service in a custom ScriptTask:
// ScriptTask code
const net = getService('network');
net.get('/endpoint', (req) => {});
While this would be possible, it can be easily exploited. Consider this:
// universal engine code
const system = require('@proceed/system');
NeoEngine.provideService('system', system);
Now we can simply overwrite any method/property permanently since we use the same module as the engine itself:
// ScriptTask code
const system = getService('system');
system.http = null;
Because the modules are created to be singletons in most cases. Consider this example of a logger module, which keeps internal state about the current log index for storage:
// universal engine code
class Logger {
index = 0;
log(message) {
data.write(`log-${this.index}`, message);
this.index += 1;
}
}
module.exports = new Logger();
After using this module in the engine internally the counter would be at a certain number, e.g. 10
. If we now make a deep copy of this module (e.g. with Object.assign({}, logger)
), we would have two instances with the same index 10
. One in the internal engine code (where we used it until now) and one for the ScriptTask. While the ScriptTask is not able to alter the internal Logger instance (unlike the previous example), we now have a problem if we call log()
on each of those. The first log on either Logger instance would write 'log-10'
to store with its message. The second instance, however, would overwrite the same key with its own message.
Using Object.freeze()
would disallow any changes on the provided objects to the ScriptTasks, but this would also render a lot of them useless, as the modules themselves can't change internal state either. Considering the same example as above, using Object.freeze(logger)
on the Logger instance would mean, that the log()
method cannot update the index
counter anymore.
It is, unfortunately. The requirements are, that we want to
- use the functions of the same module instances as the engine code (for reasons see above),
- not be able to change the modules in the ScriptTasks in any way,
- limit the access to certain functions of certain modules depending on the process at runtime,
- execute arbitrary code securely in a sandbox.
The best way to achieve this, is to provide newly created objects that only contain functions of a certain schema, like so:
// universal engine code
const { http } = require('@proceed/system');
NeoEngine.provideService('network', { get: http.get.bind(http), post: http.post.bind(http) });
The provideService()
methods then can safely Object.freeze()
this object as it is not identical to the module itself and only contains functions. The functions can reference other modules (which are not frozen) as long as they don't pass that reference to the outside world (the ScriptTask).
Other things to be aware of:
This code shows the vulnerability if a module is returned (or a reference to one).
// universal engine code
const testModule = { secret: 1 };
const otherModule = { fn: () => ({ a: testModule }) };
NeoEngine.provideService('otherModule', { fn: otherModule.fn.bind(otherModule) });
Now in the ScriptTask we cannot change otherModule but the function's return value is not protected, so we can change it:
// ScriptTask code
const otherModule = getService('otherModule');
const testModule = otherModule.fn().a;
testModule.secret = 2;
Similar to the case above, this could lead to a security issue:
// universal engine code
const testModule = { secret: 1 };
const otherModule = { fn: (callback) => callback(testModule) };
NeoEngine.provideService('otherModule', { fn: otherModule.fn.bind(otherModule) });
Now we can change the testModule again:
// ScriptTask code
const otherModule = getService('otherModule');
otherModule.fn((testModule) => {
testModule.secret = 2;
});