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.
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:
postMessage
) to interface with the parent page and makes this accessible in the window
object.CODE_UPDATED
message is received:
data-required="true"
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.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
}