-
Notifications
You must be signed in to change notification settings - Fork 46
chore(blog): Blog on rustyscript implementation in superposition #1064
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
Open
Datron
wants to merge
3
commits into
main
Choose a base branch
from
rustyscript-blog-post
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+292
−0
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,292 @@ | ||
| --- | ||
| slug: js-sandbox-in-rust | ||
| title: Rust JS Sandbox - Running Untrusted JavaScript Inside Superposition | ||
| description: Designing and implementing validation and value-compute functions: how we run user-defined JS inside Rust, seamlessly, and in isolation. | ||
| tags: [technical deepdive, rust, js, functions] | ||
| --- | ||
|
|
||
| TL;DR - We use Rustyscript to sandbox and execute user-submitted JS code inside our Rust application | ||
|
|
||
| [Superposition](https://github.com/juspay/superposition) is a context-aware configuration management system that ensures correct, safe, and reliable config changes. One of the ways we've achieved this goal is by using jsonschema types for all configuration values with a default value. We call this concept [Default Configs](/docs/basic-concepts/context-aware-config/default-config). We soon realized an issue - though the type is right, that does not guarantee the correctness of the actual value. | ||
|
|
||
|
Collaborator
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. Can we add example of URL to make the argument stronger ?
Collaborator
Author
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. Its in the example below |
||
| # Where types aren't enough | ||
|
|
||
| Take the following configuration: | ||
|
|
||
| ```json | ||
| { | ||
| "image_url": "https://example.com/image/cat.png" | ||
| } | ||
| ``` | ||
|
|
||
| The strictest JSON schema for the value of image_url would be: | ||
| ```json | ||
| { | ||
| "type": "string", | ||
| "pattern": "^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$" | ||
| } | ||
| ``` | ||
| This schema validates the type and shape and that is one part of correctness, but we're missing the most critical aspect - does the URL exist? Does it return what we're expecting? | ||
|
|
||
| # User Defined Validation Functions! | ||
|
|
||
| We introduced *Functions* - a way for any end user to write validation logic and link them to a Default Config or [Dimension](/docs/basic-concepts/context-aware-config/dimensions) to ensure the correctness of that value (should they want to do that). These functions are written in our frontend monaco editor and saved to a database with some other information. The function looks like this: | ||
|
|
||
| ```javascript | ||
| async function execute(payload) { | ||
| return true; // boolean | ||
| } | ||
| ``` | ||
|
|
||
| If the function returns false, the value being set is invalid and Superposition will not allow it to be saved as a configuration change. | ||
|
|
||
| # First implementation - Node.js Isolates | ||
|
|
||
| Since we just needed a boolean return value, we wrapped the user code with Isolates and ran it as a Node.js script. If the function returned false, the code forced the script to terminate with a custom exit code. When this was run through Rust's `std::process::Command`, we checked the exit code and interpreted the return value of the function. Users also asked for logs, so console.log would output to stdout, which was also parsed and returned to the user. Running under Node.js let us install npm packages (e.g. axios for HTTP) and use it alongside the script we were running. | ||
|
|
||
|
Datron marked this conversation as resolved.
|
||
| When a function is being edited/created, we also ensured that the function the user provided is valid. | ||
|
|
||
| This was a simple solution we tried initially and it worked well - until users wanted more capabilities. One capability was **Value Compute Functions** - functions that returned a vector of values. Users wanted to select a value from a dynamic list of potential values, fetched from internal systems at Juspay, that changes frequently. Another downside to our Node.js isolate solution was a lot of wrapper code to ensure that we properly isolated user code. We didn't validate if the code that was being stored was valid syntactically, and if Node.js could not execute it, we didn't return that error to the user. | ||
|
|
||
| # Other options | ||
|
|
||
| The team had previously looked into [`mlua`](https://crates.io/crates/mlua)(which we decided to not pursue since Superposition users were more familiar with Javascript and looked at ways to run Javascript from within Rust to have better interoperability. Two options we considered were [`rquickjs`](https://crates.io/crates/rquickjs) and [`Rustyscript`](https://crates.io/crates/rustyscript); we went with Rustyscript because of its [documentation](https://rscarson.github.io/rustyscript-book/), features, and better compatibility with Node.js. | ||
|
|
||
| # Running Javascript in Rust with Rustyscript | ||
|
|
||
| Rustyscript is powerful and extremely customizable for running user code - it has a permissions model, functions that you can expose from Rust to use in JS land, and some very nice built-in functionality like console, crypto, http, etc. | ||
|
|
||
| Here's a function that validates a URL by fetching it and checking that the response status is `200`: | ||
|
|
||
| ```Javascript | ||
| async function execute(payload) { | ||
| const { value_validate } = payload; | ||
| // value_validate is an object that contains all arguments a validation function would need | ||
| const { key, value, type, environment } = value_validate; | ||
| let response = await fetch(value); | ||
| return response.status === 200; | ||
| } | ||
| ``` | ||
|
|
||
| All functions in Superposition are named `execute`. This is because, while validating the user input, we wrap this code with some helpers and the final code that is passed to Rustyscript looks like: | ||
|
|
||
| ```Javascript | ||
|
|
||
| // wrapper code | ||
| let logBuffer = []; | ||
| const originalConsole = console; | ||
| const customConsole = { | ||
| log: (...args) => { | ||
| logBuffer.push("[log] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); | ||
| }, | ||
| info: (...args) => { | ||
| logBuffer.push("[info] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); | ||
| }, | ||
| warn: (...args) => { | ||
| logBuffer.push("[warn] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); | ||
| }, | ||
| error: (...args) => { | ||
| logBuffer.push("[error] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); | ||
| }, | ||
| debug: (...args) => { | ||
| logBuffer.push("[debug] " + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); | ||
| } | ||
| }; | ||
|
|
||
| Object.defineProperty(globalThis, 'console', { | ||
| value: customConsole, | ||
| writable: false, | ||
| configurable: false | ||
| }); | ||
|
|
||
| function getLogBuffer() { | ||
| return logBuffer; | ||
| } | ||
|
|
||
| function clearLogBuffer() { | ||
| logBuffer = []; | ||
| } | ||
|
|
||
| // actual user function | ||
| async function execute(payload) { | ||
| const { value_validate } = payload; | ||
| // value_validate is an object that contains all arguments a validation function would need | ||
| const { key, value, type, environment } = value_validate; | ||
| let response = await fetch(value); | ||
| return response.status === 200; | ||
| } | ||
|
|
||
| export { execute, getLogBuffer, clearLogBuffer }; | ||
| ``` | ||
|
|
||
| Note the console override — it routes every log call into an in-memory buffer that we expose via `getLogBuffer`. | ||
|
|
||
| We validate the syntax of the above Javascript with `rustyscript::validate`, which takes a string and tells you if it is valid syntax or not. If it is not valid, we return an error to the user. This is done before we even try to run the function: | ||
|
|
||
| ```rust | ||
| rustyscript::validate(&type_check_code).map_err(|err| { | ||
| log::error!("Invalid function syntax: {:?}", err); | ||
| bad_argument!("Invalid function syntax: {}", err) | ||
| })?; | ||
| ``` | ||
|
|
||
| This validates the syntax, but is `execute` a functio and does it return a value that Superposition expects from this category of function? With rustyscript, we can simply just run the function with some default inputs and inspect its return value. This checks: | ||
|
|
||
| - `execute` exists | ||
| - `execute` is a function | ||
| - `execute` returns a value of the expected type (boolean for validation functions, array for value-compute functions) | ||
| - the function has handled potential null/undefined values correctly | ||
|
|
||
| To run a function in Rustyscript we need to: | ||
|
|
||
| - Create a basic runtime | ||
| - Load a Javascript module | ||
| - Call a function and consume the resulting value as a Rust type | ||
|
|
||
| ```rust | ||
| let type_check_code = generate_wrapped_code(&stubbed_code); | ||
|
|
||
| rustyscript::validate(&type_check_code).map_err(|err| { | ||
| log::error!("Invalid function syntax: {:?}", err); | ||
| bad_argument!("Invalid function syntax: {}", err) | ||
| })?; | ||
|
|
||
| let module = Module::new("type_check.js", &type_check_code); | ||
| let runtime_options = RuntimeOptions { | ||
| timeout: Duration::from_millis(1500), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let mut runtime = Runtime::new(runtime_options).map_err(|e| { | ||
| let err_str = e.to_string(); | ||
| validation_error!("Failed to create runtime: {}", err_str) | ||
| })?; | ||
|
|
||
| let module_handle = runtime.load_module(&module).map_err(|e| { | ||
| let err_str = e.to_string(); | ||
| validation_error!("Failed to load module: {}", err_str) | ||
| })?; | ||
|
|
||
| let tokio_runtime = runtime.tokio_runtime(); | ||
| tokio_runtime | ||
| .block_on(async { | ||
| let environment = FunctionEnvironment { | ||
| context: serde_json::Map::new(), | ||
| overrides: serde_json::Map::new(), | ||
| }; | ||
| match fn_type { | ||
| FunctionType::ValueCompute => { | ||
| let payload = FunctionExecutionRequest::ValueComputeFunctionRequest { | ||
| name: String::new(), | ||
| prefix: String::new(), | ||
| r#type: superposition_types::api::functions::KeyType::ConfigKey, | ||
| environment, | ||
| }; | ||
| let serde_json::Value::Array(_) = runtime | ||
| .call_function_async::<serde_json::Value>( | ||
| Some(&module_handle), | ||
| "execute", | ||
| json_args!(payload), | ||
| ) | ||
| .await? else { | ||
| return Err(rustyscript::Error::Runtime("The value compute function did not return an array".to_string())); | ||
| }; | ||
| Ok(()) | ||
| } | ||
| other => { | ||
| let payload = match other { | ||
| FunctionType::ValueValidation => FunctionExecutionRequest::ValueValidationFunctionRequest { | ||
| key: String::new(), | ||
| value: serde_json::Value::String(String::new()), | ||
| r#type: | ||
| KeyType::ConfigKey, | ||
| environment, | ||
| }, | ||
| FunctionType::ContextValidation => FunctionExecutionRequest::ContextValidationFunctionRequest { | ||
| environment, | ||
| trigger_reason: ContextValidationTrigger::Context, | ||
| }, | ||
| FunctionType::ChangeReasonValidation => FunctionExecutionRequest::ChangeReasonValidationFunctionRequest { | ||
| change_reason: ChangeReason::default(), | ||
| }, | ||
| _ => { | ||
| return Err(rustyscript::Error::Runtime("An invalid situation occurred in the runtime".to_string())) | ||
| } | ||
| }; | ||
| let serde_json::Value::Bool(_) = runtime | ||
| .call_function_async::<serde_json::Value>( | ||
| Some(&module_handle), | ||
| "execute", | ||
| json_args!(payload), | ||
| ) | ||
| .await? else { | ||
| return Err(rustyscript::Error::Runtime("The function did not return a boolean".to_string())) | ||
| }; | ||
| Ok(()) | ||
| } | ||
|
|
||
| } | ||
| }) | ||
| .map_err(|e| { | ||
| let err_str = e.to_string(); | ||
| validation_error!("Function validation failed: {}", err_str) | ||
| })?; | ||
| ``` | ||
|
|
||
| Now when this function needs to be run to validate any input or change, it is run with actual inputs that the system infers based on context. This context could be derived or explicitly stated by the user. | ||
|
|
||
| When running this function with user inputs, we use the same code as above, but instead of using a stubbed payload, we use the actual payload that is being validated. The code looks like this: | ||
|
|
||
| ```rust | ||
| // wrap user function with code shown above | ||
| let wrapped_code = generate_wrapped_code(&code.0); | ||
| let module = Module::new("function.js", &wrapped_code); | ||
|
|
||
| let runtime_options = RuntimeOptions { | ||
| timeout: Duration::from_millis(1500), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let mut runtime = Runtime::new(runtime_options).map_err(|e| { | ||
| let err_str = e.to_string(); | ||
| (format!("Failed to create runtime: {}", err_str), None) | ||
| })?; | ||
|
|
||
| let payload = FunctionPayload { | ||
| version: runtime_version, | ||
| payload: args.clone(), | ||
| }; | ||
| let module_handle = runtime | ||
| .load_module(&module) | ||
| .map_err(|err| (err.to_string(), None))?; | ||
|
|
||
| let tokio_runtime = runtime.tokio_runtime(); | ||
| // execute function, fn_output takes the value of Value::Bool for the example | ||
| let fn_output = tokio_runtime | ||
| .block_on(async { | ||
| runtime | ||
| .call_function_async::<serde_json::Value>( | ||
| Some(&module_handle), | ||
| "execute", | ||
| json_args!(payload), | ||
| ) | ||
| .await | ||
| }) | ||
| .map_err(|err| (err.to_string(), None))?; | ||
|
|
||
| // get logs | ||
| let stdout = runtime | ||
| .call_function::<Vec<String>>(Some(&module_handle), "getLogBuffer", json_args!()) | ||
| .map_err(|err| (err.to_string(), None))? | ||
| .join("\n"); | ||
|
|
||
| runtime | ||
| .call_function::<()>(Some(&module_handle), "clearLogBuffer", json_args!()) | ||
| .map_err(|err| (err.to_string(), None))?; | ||
| ``` | ||
|
|
||
| # Conclusion | ||
|
|
||
| Rustyscript can do a lot more than what we've shared here - I recommend taking a look at the [documentation](https://rscarson.github.io/rustyscript-book/index.html) if you've never used it before. For Superposition, Rustyscript's ability to run isolated JavaScript seamlessly from within Rust has unlocked richer correctness guarantees — we now support both validation and value-compute functions with full syntax checking and captured logs, without spawning a subprocess. | ||
|
|
||
| If you like the idea of Superposition, do take a look at our [docs](https://juspay.io/superposition/docs) to learn more. Have questions? Join our [Slack](https://superpositionjp.slack.com) to interact with the team. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Can we add a section for what all we sandboxed ?
Uh oh!
There was an error while loading. Please reload this page.
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.
We sandboxed functions only is my understanding, can you give an example of what this section would be
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.
I think @sauraww means to ask what all capabilities are available for functions to execute (basically the rest of the world is denied right) ?
Is that right @sauraww ?
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.
yes @knutties