Executing untrusted JavaScript code in a browser
Executing a JS script provided by a user is not an easy task. Indeed, a common security rule is to never trust user input (whatever the input is). In a browser, a JavaScript code can do lots of dangerous thinks. In our case we want to prevent the script from doing:
- DOM manipulation
- JavaScript context modification
- Stealing personal data by using http requests
- Too much resources consumption (such as infinite loop)
#Web Worker
A worker provides a way to execute a script in background. Workers run in another global context that is different from the current window. Thus, the worker thread can perform tasks without interfering with the user interface. The script that runs in the worker can't access the DOM nor the JavaScript context of the main thread.
You can kill a worker if needed. In our case, this is very useful as it allows us to kill the worker if the evaluation of the script lasts too long. Thus we can prevent the script from freezing the computer.
Data is sent between workers and the main thread via messages (postMessage
method, and the onmessage
event handler). So the evaluation of the script is asynchronous. For convenience, we can use a Promise
.
The app.ts
file:
class ScriptEvaluator {
private worker: Worker = null;
private getWorker() {
if (this.worker === null) {
this.worker = new Worker("WorkerScript.js");
}
return this.worker;
}
public killWorker() {
this.worker.terminate();
this.worker = null;
}
public evalAsync(script: string, timeout = 1000): Promise<string> {
let worker = this.getWorker();
return new Promise((resolve, reject) => {
// Handle timeout
let handle = setTimeout(() => {
this.killWorker();
reject("timeout");
}, timeout);
// Send the script to eval to the worker
worker.postMessage([script]);
// Handle result
worker.onmessage = e => {
clearTimeout(handle);
resolve(e.data);
};
worker.onerror = e => {
clearTimeout(handle);
reject((e as any).message);
}
});
}
}
The WorkerScript.ts
file:
onmessage = e => {
let data: string = e.data[0];
let workerResult = null;
(() => {
var e = null; // remove "e"
workerResult = eval(data);
})();
self.postMessage(workerResult);
}
Then you can use the class easily:
let evaluator = new ScriptEvaluator();
evaluator.evalAsync("1+1")
.then(result => console.log(result))
.catch(reason => console.error(reason));
#Prevent usage of dangerous objects
Currently, the worker is not safe. For instance, an attacker can create a script that makes use of the XmlHttpRequest
to transfer user data to its server. The first idea is to remove the XmlHttpRequest
object in the worker. However modern browsers provide others methods to create an http request:
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.meziantou.net/');
xhr.send();
var ws = new WebSocket('https://www.meziantou.net/');
var es = new EventSource('https://www.meziantou.net/');
navigator.sendBeacon('https://www.meziantou.net/', { ... });
So instead of removing some specific objects and functions from the worker (deny list), it's safer to preserve only expected objects and functions (allow list). You must execute this code before evaluating the first script in the worker, so copy it at the beginning of the file WorkerScript.js
:
let safeObjects = [
"self", "onmessage", "postMessage",
"__proto__", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__",
"constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
"toLocaleString", "toString",
"eval", "Array", "Boolean", "Date", "Function", "Object", "String", "undefined",
"Infinity", "isFinite", "isNaN",
"Math", "NaN", "Number", "parseFloat", "parseInt",
"RegExp",
// ...
];
function secure(item: any, prop: string) {
if (safeObjects.indexOf(prop) < 0) {
const descriptor = Object.getOwnPropertyDescriptor(item, prop);
if (descriptor && descriptor.configurable) {
Object.defineProperty(item, prop, {
get: () => {
throw new Error(`Security Exception: cannot access ${prop}`);
},
configurable: false
});
} else {
if (typeof item.prop === "function") {
item.prop = () => {
throw new Error(`Security Exception: cannot access ${prop}`);
}
}
else {
delete item.prop;
}
}
}
}
[self].forEach((item: any) => {
while (item) {
Object.getOwnPropertyNames(item).forEach(prop => {
secure(item, prop);
});
item = Object.getPrototypeOf(item);
}
});
#Content Security Policy (CSP)
We currently have a great sandbox, but browsers provide another way to prevent the browser from doing unwanted actions. Content Security Policy (CSP) allows the web site owner to specify what the browser can do and can't do: expected urls, use the eval
function, allow inline scripts, etc. CSP also works with web workers, so let's use it to provide more security (Defense in depth). When the web server serves the JS file to the client, it must also send an http header Content-Security-Policy
containing a list of directive. For our worker we use the following directives:
sandbox
: create a unique origin for the worker (same origin policy), so the worker cannot access the cookies or the local storage of the page.default-src 'none'
: prevent the browser from accessing any url (including data url)script-src 'unsafe-eval'
: we need to use theeval
function in the worker
A complete list of directive is available on the w3 site: https://www.w3.org/TR/CSP2/
If the script violates the policy, the browser shows a message in the console:
Using ASP.NET core, you can use the following code to add the header for the worker script file:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
var options = new StaticFileOptions();
options.OnPrepareResponse = context =>
{
if (string.Equals(context.File.Name, "WorkerScript.js", StringComparison.OrdinalIgnoreCase))
{
context.Context.Response.Headers["Content-Security-Policy"] =
"sandbox; default-src 'none'; script-src 'unsafe-eval'";
}
};
app.UseStaticFiles(options);
}
#Conclusion
The script evaluator is now safe enough for our usage. Of course, do not trust the result of the script. I mean do not use the result directly as inner html or as an argument of the eval
function. Otherwise the sandboxing effort will be ruined 😦
Last but not least, check that your browser supports web workers and CSP:
Do you have a question or a suggestion about this post? Contact me!