-
Notifications
You must be signed in to change notification settings - Fork 123
Safely executing user provided scripts
Unless you are exposing .NET Values, Methods, Types etc. to a ScriptEngine or its objects, Jurassic runs JavaScripts in an isolated environment so untrusted scripts cannot access external data. However there is still a DoS danger where untrusted scripts can cause the process to terminate or running an endless loop as in the following cases:
Consider this JavaScript code:
(function func() {
func();
})();
If you ran this code with ScriptEngine.Execute()
, the process would terminate with a StackOverflowException
due to the infinite recursion, and StackOverflowExceptions
raised by the CLR cannot be caught as they are considered to corrupt the process state.
In order to protect against such stack overflows you can set ScriptEngine.RecursionDepthLimit
to limit the maximum number of recursions that user-defined functions are allowed to do. Once a function exceeds this limit, a StackOverflowException
is thrown by Jurassic (which, in this case, can be caught).
Note: The maximum number of recursions which will work until the stack overflows depends on the thread's maximum stack size and on the size of the stack frames created by the compiled functions. For threads where no maximum stack size is specified and for ThreadPool threads (on which callbacks of async operations are run), the default stack size of the process is used, which is 1 MB for .NET applications compiled to EXE files (except when the target platform is x64
, in which case it is 4 MB).
The default stack size (reserve size) and the stack commit size of the process can be changed using Visual Studio's editbin.exe
tool with the /STACK
option. It seems that starting with .NET 4.0 (contrary to the documentation) the CLR does not commit the full stack size when starting threads, but uses the specified commit size (which is 4 KB by default) to incrementally allocate the stack, unless <disableCommitThreadStack enabled="0" />
is specified in the application configuration file. This allows you to specify a higher default stack size without having the CLR allocating the full stack size immediately for each thread.
This section uses Thread.Abort()
in order to cancel script execution, which does not work in .NET Core, and also it does not prevent infinite loops in finally
clauses, for example:
try {}
finally {
while (true);
}
Please see #85 for more information.
Imagine executing a JavaScript like this in Jurassic:
while (true) ;
If you ran such an endless loop using ScriptEngine.Execute(String code)
, the method would never return.
Jurassic does not have an built-in way to limit the execution time of a script. Because Jurassic compiles JavaScript methods into IL code, there is no easy way to provide a timeout functionality without affecting performance.
However, it is possible to use Thread.Abort()
to raise a ThreadAbortException
in the thread that executes the script. One possibility is to run ScriptEngine.Execute()
in a new thread and call thread.Abort()
in the current thread if the new thread does not complete after a specific time. You can also use the following helper class for a simpler way to apply a timeout to script execution.
The following ScriptTimeoutHelper
class provides a simple and clean way to apply a timeout on JavaScript excecution while running the script in the same thread that calls the method, withouth having to deal with other threads and ThreadAbortExceptions
(which is done under the hood). It also provides a way to mark .NET Callbacks as critical sections which should not be aborted when the timeout applies.
This assumes you are targeting .NET 4.5 or higher and are using Visual Studio 2015 or higher.
using System;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
namespace JurassicTimeoutHelper
{
/// <summary>
/// Allows to limit execution time of a Jurassic Script by internally using the
/// technique of aborting a thread.
/// </summary>
public class ScriptTimeoutHelper
{
private HandlerState currentState;
public ScriptTimeoutHelper()
{
}
/// <summary>
/// Runs the specified <see cref="Action"/> in the current thread,
/// applying the given timeout.
/// When the handler times out, a <see cref="ThreadAbortException"/> is
/// raised in the current thread to break. However, this is managed
/// internally so it does not affect the caller of this method (i.e. it is
/// ensured that a <see cref="ThreadAbortException"/> does not flow through
/// this method or raised after this method returns).
/// </summary>
/// <exception cref="TimeoutException">Thrown when the handler times
/// out.</exception>
/// <param name="handler"></param>
/// <param name="timeout"></param>
public void RunWithTimeout(Action handler, int timeout)
{
if (currentState != null)
throw new InvalidOperationException(
$"Cannot recursively call {nameof(RunWithTimeout)}.");
if (handler == null)
throw new ArgumentException(nameof(handler));
// Throw the TimeoutException immediately when the timeout is 0.
if (timeout == 0)
throw new TimeoutException();
ExceptionDispatchInfo caughtException = null;
using (var state = currentState =
new HandlerState(Thread.CurrentThread))
{
/* Start a monitoring task that may abort the current thread after
* the specified time limit.
* Note that the task will start immediately. Therefore we need to
* ensure the task does not abort the thread until we entered the
* try clause; otherwise the ThreadAbortException might fly through
* the caller of this method. To ensure this, the monitoring task
* waits until we release the semaphore the first time before
* actually waiting for the specified time.
*/
using (var monitoringTask = Task.Run(async () =>
await RunMonitoringTask(state, timeout)))
{
try
{
bool waitForAbortException;
try
{
// Allow the monitoring task to begin by releasing the
// semaphore the first time.
// Do this in a finally block to ensure if this thread
// is aborted by other code, the semaphore is still
// released.
try { }
finally
{
state.WaitSemaphore.Release();
}
// Execute the handler.
handler();
}
catch (Exception ex) when (!(ex is ThreadAbortException))
{
/* Need to catch all exceptions (except our own
* ThreadAbortException) because we may wait for a
* ThreadAbortException to be thrown which is not
* possible in a finally handler.
*/
caughtException = ExceptionDispatchInfo.Capture(ex);
}
finally
{
/* Indicate that the handler is completed, and check
* if we need to wait for the ThreadAbortException.
* This is done in a finally handler to ensure when
* other code wants to abort this thread, the thread
* actually will abort as expected but we still can
* notify the monitoring task that we already returned.
*/
lock (state)
{
state.IsExited = true;
waitForAbortException =
state.AbortState == AbortState.IsAborting;
if (state.AbortState == AbortState.None)
{
// If the monitoring task did not do anything
// yet, allow it to complete immediately.
state.WaitSemaphore.Release();
}
}
}
if (waitForAbortException)
{
/* The monitoring task indicated that it will abort our
* thread (but the ThreadAbortException did not yet
* occur), so we need to wait for the
* ThreadAbortException.
* This wait is needed because otherwise we may return
* too early (and in the finally block we wait for the
* monitoring task, causing a deadlock).
*/
Thread.Sleep(Timeout.Infinite);
}
}
catch (ThreadAbortException ex)
when (ex.ExceptionState == state)
{
// Reset the abort.
Thread.ResetAbort();
// Indicate that the timeout has been exceeded.
throw new TimeoutException();
}
finally
{
// Wait for the monitoring task to complete.
monitoringTask.Wait();
currentState = null;
}
}
}
// Check if we need to rethrow a caught exception (preserving the
// original stacktrace).
if (caughtException != null)
caughtException.Throw();
}
private async Task RunMonitoringTask(HandlerState state, int timeout)
{
// Wait until the handler thread entered the try-block.
// Use a synchronous wait because we expect this to be a very short
// period of time.
state.WaitSemaphore.Wait();
// Now asynchronously wait until the specified time has passed or the
// semaphore has been released. In the latter case there is no need to
// call AbortExecution().
bool completed = await state.WaitSemaphore.WaitAsync(timeout);
// Abort the handler thread.
if (!completed)
AbortExecution(state);
}
private void AbortExecution(HandlerState state)
{
bool canAbort;
lock (state)
{
if (state.IsExited)
{
// The handler has already exited.
return;
}
// Check if we can call Thread.Abort() or if the handler thread is
// currently in a critical section and needs to abort himself when
// leaving the critical section.
canAbort = !state.IsCriticalSection;
state.AbortState = canAbort ? AbortState.IsAborting
: AbortState.ShouldAbort;
}
if (canAbort)
{
/* The handler thread is not in a critical section so we can
* directly abort it.
* This needs to be done outside of the lock because Abort() could
* block if the thread is currently in a finally handler (and
* trying to lock on the state object), which could lead to a
* deadlock.
*/
state.HandlerThread.Abort(state);
}
}
/// <summary>
/// Notifies this class that the handler thread is entering a critical
/// section in which aborting the thread could corrupt the system's state.
/// This means aborting the thread will be deferred until leaving the
/// critical section.
/// Note that you must call <see cref="ExitCriticalSection"/> in a
/// <c>finally</c> block once the thread left the critical section.
/// </summary>
public void EnterCriticalSection()
{
if (currentState == null)
throw new InvalidOperationException();
bool waitForAbortException;
lock (currentState)
{
if (Thread.CurrentThread != currentState.HandlerThread
|| currentState.IsCriticalSection)
throw new InvalidOperationException();
currentState.IsCriticalSection = true;
waitForAbortException =
currentState.AbortState == AbortState.IsAborting;
}
if (waitForAbortException)
{
// The monitoring task indicated that it will abort our thread, so
// we need to wait for the ThreadAbortException.
Thread.Sleep(Timeout.Infinite);
}
}
public void ExitCriticalSection()
{
if (currentState == null)
throw new InvalidOperationException();
bool shouldAbort;
lock (currentState)
{
if (Thread.CurrentThread != currentState.HandlerThread
|| !currentState.IsCriticalSection)
throw new InvalidOperationException();
currentState.IsCriticalSection = false;
shouldAbort = currentState.AbortState == AbortState.ShouldAbort;
}
if (shouldAbort)
{
// The monitoring task indicated that it wanted to abort our
// thread while we were in a critical section, so we need to abort
// ourselves.
Thread.CurrentThread.Abort(currentState);
}
}
private enum AbortState
{
/// <summary>
/// Indicates that the monitoring task has not yet done any action.
/// </summary>
None = 0,
/// <summary>
/// Indicates that the monitoring task is about to abort the handler
/// thread.
/// </summary>
IsAborting = 1,
/// <summary>
/// Indicates that the monitoring task wanted to abort the handler
/// thread but the handler thread was in a critical section, and needs
/// to abort itself when leaving the critical section.
/// </summary>
ShouldAbort = 2
}
private class HandlerState : IDisposable
{
public Thread HandlerThread { get; }
public SemaphoreSlim WaitSemaphore { get; } = new SemaphoreSlim(0);
public bool IsCriticalSection { get; set; }
/// <summary>
/// Indicates if the handler is already completed (so there's no need
/// to abort the thread).
/// This flag is set by the handler thread.
/// </summary>
public bool IsExited { get; set; }
/// <summary>
/// Indicates that the wait task wanted to abort the handler thread but
/// the handler thread was in a critical section, and needs to abort
/// itself when leaving the critical section.
/// This flag is set by the wait task.
/// </summary>
public AbortState AbortState { get; set; }
public HandlerState(Thread handlerThread)
{
this.HandlerThread = handlerThread;
}
~HandlerState()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected void Dispose(bool disposing)
{
if (disposing)
{
WaitSemaphore.Dispose();
}
}
}
}
}
This class starts an asynchronous Task
which waits for the timeout using a SemaphoreSlim
(so that there is no separate thread blocked by waiting for the timeout) and then aborts the thread if it did not complete within the timeout. RunWithTimeout()
catches the ThreadAbortException
and resets the abort to ensure the thread can continue normally after handling the abort.
Contrary to what one might think, a Thread.Abort()
in this case allows to cleanly cancel script execution without side effects (as long as critical sections in .NET Callbacks are marked correctly).
Note that the script is executed on the same thread which called the method, instead of a new thread. This ensures .NET methods handling locks which are called back from Javascript can work correctly.
Now, in your code, instead of writing
var engine = new ScriptEngine();
var compiledScript = engine.Compile(new StringScriptSource(
"while (true) ;"));
compiledScript.Execute();
you can write:
var timeoutHelper = new ScriptTimeoutHelper();
var engine = new ScriptEngine();
var compiledScript = engine.Compile(new StringScriptSource(
"while (true) ;"));
timeoutHelper.RunWithTimeout(() => compiledScript.Execute(), 1000);
You will now get a TimeoutException
if the JavaScript did not finish within 1 second. Note that we use use ScriptEngine.Compile()
to first compile the script and then applying the timeout only to the actual execution the script.
Note: After the TimeoutException
occured, you should not continue to use the ScriptEngine
and its associated objects as they may be in an inconsistent/unusable state. Instead, create a new ScriptEngine
to run further scripts.
Using the ScriptTimeoutHelper
class means that the code that is currently executing within the RunWithTimeout
can be aborted by a ThreadAbortException
. This can be a problem if your scripts are able to call back a .NET method, but the .NET method should not be aborted in the middle of its execution, e.g. because it would leave the system in an inconsistent state.
To handle this problem, call ScriptTimeoutHelper.EnterCriticalSection()
once your .NET method is called, and call ScriptTimeoutHelper.ExitCriticalSection()
(in a finally
block) when your .NET method returns. This ensures a ThreadAbortException
is delayed until the critical section is left.
Example:
Imagine you have an API class with a method that increments three variables:
class Api
{
public int a = 0;
public long b = 0;
public double c = 0;
// Increments all three counters.
public void Increment()
{
a++;
// Loop to simulate other work
for (int i = 0; i < 1000000; i++) ;
b++;
for (int i = 0; i < 1000000; i++) ;
c++;
}
}
Calling Api.Increment()
should increment all three counters, so it should not happen that a = 21 and b = 22 after the method returns - all three counters should have the same value as long as the int
variable doesn't overflow. As long as you don't abort a thread (or e.g. a OutOfMemoryException
occurs), you can expect this to work.
Now imagine that you want to allow a JavaScript to call this method, but limit the script's execution time:
class ScriptApi : ObjectInstance
{
private Api api;
public ScriptApi(ScriptEngine engine, Api api)
: base(engine)
{
this.api = api;
PopulateFunctions();
}
[JSFunction(Name = "increment")]
public void Increment() => api.Increment();
}
class Program
{
private static Api api = new Api();
static void Main()
{
var timeoutHelper = new ScriptTimeoutHelper();
var engine = new ScriptEngine();
var scriptApi = new ScriptApi(engine, api);
engine.SetGlobalValue("api", scriptApi);
try
{
var compiledScript = engine.Compile(new StringScriptSource(@"
while (true) {
// Call a .NET API method.
api.increment();
}
"));
timeoutHelper.RunWithTimeout(() => compiledScript.Execute(), 1000);
}
catch (TimeoutException)
{
Console.WriteLine("Script timed-out.");
// Note: The ScriptEngine object might now be in an
// inconsistent/unable state, so you should create a new
// ScriptEngine object to execute further scripts.
}
Console.WriteLine($"Result: a = {api.a}, b = {api.b}, c = {api.c}");
Console.ReadKey();
}
}
If you run this program a few times, you will notice that sometimes a
, b
and c
will have different values. This is because when the thread is aborted, it could either be in script code (Lightweight Function) or in the Api.Increment()
method.
To prevent the thread from being aborted while being in the Api.Increment()
method, you will need to modify the ScriptApi class in the following way:
class ScriptApi : ObjectInstance
{
private Api api;
private ScriptTimeoutHelper timeoutHelper;
public ScriptApi(ScriptEngine engine, Api api,
ScriptTimeoutHelper timeoutHelper)
: base(engine)
{
this.api = api;
this.timeoutHelper = timeoutHelper;
PopulateFunctions();
}
[JSFunction(Name = "increment")]
public void Increment()
{
timeoutHelper.EnterCriticalSection();
try
{
// Call critical code which must not be aborted here.
api.Increment();
}
finally
{
timeoutHelper.ExitCriticalSection();
}
}
}
Now, you can see that every time the script is aborted, all three counters will have the same value. The script will still be aborted after about 1 second, provided that the "critical" method (Api.Increment()
) does not take very long to execute.
If there is a chance that your .NET callbacks which you have marked as Critical Sections calls again a script function, you cannot use the same ScriptTimeoutHelper
instance to run it with a timeout because it doesn't allow recursive calls.
Instead, you can create a Stack<ScriptTimeoutHelper>
and add a new ScriptTimeoutHelper
instance (Push
) to the stack each time your .NET code calls JavaScript code, and remove the instance (Pop
) in a finally
clause from the stack once the call returns.
Then, in your callbacks, use the ScriptTimeoutHelper
that is currently on top of the stack (Peek
) to call EnterCriticalSection()
and ExitCriticalSection()
.
While limiting the max. recursion depth and the max. execution time can help to safely execute scripts from untrusted sources (thereby limiting DoS surface), a script still could allocate arbitrary amounts of memory, e.g. by infinitely allocating longer strings which will cause an OutOfMemoryException
raised soon after starting it:
let s = 'a';
while (true)
s += s;
Currently, there is no way in Jurassic to protect against such unlimited allocations.