There are applications where I’ve needed a way to run some untrusted user code inside the browser. This can be quite dangerous, as it opens the door to XSS (cross-site scripting) attacks, so I’ve looked into some approaches of evaluating user-provided code safely. This can be helpful when you want to allow the user to write some functionality themselves, or to allow for plugins to be built for your web application.

iFrames

Retool and Tooljet both inject code into an iFrame on the page, using the srcDoc attribute. While I didn't look into Retool's implementation much, Tooljet seems to inject the following code into the iFrame:

<html>
<head>
	<script src="<https://unpkg.com/@babel/[email protected]/babel.min.js>"
	integrity="sha384-q/mWU54AdnQn35rIhX7g2MtszBgXHwH9exPcvCVnncKy5WoKc457RNDNmm23Fag7" crossorigin="anonymous"
	referrerpolicy="no-referrer" data-required="true"></script>
	<script src="<https://unpkg.com/[email protected]/umd/react.production.min.js>"
	integrity="sha384-bDWFfmoLfqL0ZuPgUiUz3ekiv8NyiuJrrk1wGblri8Nut8UVD6mj7vXhjnenE9vy" crossorigin="anonymous"
	referrerpolicy="no-referrer" data-required="true"></script>
	<script src="<https://unpkg.com/[email protected]/umd/react-dom.production.min.js>"
	integrity="sha384-mcyjbblFFAXUUcVbGLbJZR86Xd7La0uD1S7/Snd1tW0N+zhy97geTqVYDQ92c8tI" crossorigin="anonymous"
	referrerpolicy="no-referrer" data-required="true"></script>

</head>
<body style="margin:0">
<script data-required="true">
	let callbackFn = () => {}, props = {};

	// Implementing API for postMessage

	window.Tooljet = {
	componentId: window.frameElement.getAttribute("data-id"),
	subscribe: e => { e(props), callbackFn = e },
	runQuery: (e, t) => {
		window.parent.postMessage({
			from: "customComponent",
			message: "RUN_QUERY",
			queryName: e,
			parameters: JSON.stringify(t || {}),
			componentId: window.Tooljet.componentId
		}, "*") },

	updateProps: e => window.parent.postMessage({
		from: "customComponent",
		message: "UPDATE_DATA",
		updatedObj: e,
		componentId: window.Tooljet.componentId }, "*"),

	init: () => {
		window.parent.postMessage({
			from: "customComponent",
			message: "INIT",
			componentId: window.Tooljet.componentId }, "*"),

	window.addEventListener("message", (e => {

	if ("CODE_UPDATED" === e.data.message || "INIT_RESPONSE" === e.data.message) {
		const a = document.getElementsByTagName("script");

		for (let e = 0; e < a.length; e++)"true" !== a[e].getAttribute("data-required") && (a[e].parentNode.removeChild(a[e]), e -= 1);

		var t = document.getElementsByTagName("head")[0];

		script = document.createElement("script"),
		script.text = e.data.code,
		script.type = "text/babel",
		script.setAttribute("data-type", "module"),

		t.appendChild(script),
		window.dispatchEvent(
			new Event("DOMContentLoaded")
		),
		props = e.data.data,
		callbackFn(e.data.data)

	} else "DATA_UPDATED" === e.data.message && (props = e.data.data, callbackFn(e.data.data))

		}))

		}

	},

	window.addEventListener("load", (function () { window.Tooljet.init() }))

</script>

<script type="text/babel" data-required="true">
window.Tooljet.connectComponent = WrappedComponent => {
	class ConnectedComponent extends React.Component {
		constructor() {
			super(), this.state = {}
		}

		componentDidMount() {
			window.Tooljet.subscribe((e => this.setState({ data: e })))
		}

		render() {
			return <WrappedComponent
				data={this.state?.data ?? {}}
				updateData={(e) => Tooljet.updateProps(e)}
				runQuery={(e, params) => Tooljet.runQuery(e, params)}
			/>
		}
	}
	return ConnectedComponent;
}
</script>
</body>
</html>

This does the following:

  1. Implements a thin API (wrapper of postMessage) to interface with the parent page and makes this accessible in the window object.
  2. Listens for messages
  3. When a CODE_UPDATED message is received:
    1. Remove any script tags which do not have data-required="true"
    2. Append a new script tag in the head, with type="text/babel" and data-type="module". This is to enable both babel to identify the script tag and do transpilation, and the module enables ES6 imports.

Sandboxing (WASM)

Using quickjs-emscripten module:

import { getQuickJS } from "quickjs-emscripten";

/**
*
* IMPORTANT: REQUIRES A RUNTIME WHICH SUPPORTS TOP-LEVEL AWAIT
*
**/
const QuickJS = await getQuickJS();

function run(code) {
	const vm = QuickJS.newContext();
	const result = vm.evalCode(code);

	let final;
		if (result.error) {
			// console.log("Execution failed:", vm.dump(result.error));
			final = vm.dump(result.error.value);
			result.error.dispose();
		} else {
			//console.log("Success:", vm.dump(result.value));
			final = vm.dump(result.value);
			result.value.dispose();
		}
	vm.dispose();

	return final
}