Introducing Isolator - a framework for running isolated code for .NET
Introduction
Code isolation, also known as sandboxes, are an important topic in any enterprise-level programming language: the means the ability to run code in a secure way that does not affect the main program. This used to be relatively easy to achieve with the .NET Framework, but it is no longer so, since .NET Core was introduced. I will talk a bit about it and how we can implement something similar.
Code Isolation in .NET
Custom app domains are gone from .NET since .NET Core. Officially they were removed because they were heavy and hard to maintain. There is still the AppDomain class and we always have a current app domain, which can be accessed from AppDomain.CurrentDomain, but the AppDomain.CreateDomain method now returns an exception on all platforms. App domains were great, from a programatic point of view, because they allowed us to create new ones with custom permissions, which could then be used to load and execude code into, and then unloaded when no longer needed.
So, this introduces an interesting question: what can we use instead? Well, there are at least two options:
- Processes: we can spawn a new process, maybe with generated code, and interact with it, perhaps through the command line, and standard input and output
- Assembly Load Contexts: this is the recommended way to load assemblies with some degree of isolation
Both these have some limitations, namely, one cannot fine tune the permissions our code will run with. For processes, we can, however, specify an account which may have reduced permissions on the running system. For assembly load contexts, it's just not that easy.
Introducing Isolator
So, to put all this together, I created Isolator: a framework for running isolated .NET code. It is a work in progress - and probably will always be - but, I decided to make it public so that I can get feedback and understand if there is interest in this subject. Also, of course, to test myself, but that you already know! ;-)
In a nutshell, Isolator will allow the running of plugins inside a host. It shall be possible to pass parameters, besides the actual code, to the host, and to return results from it. Both standard output and standard error streams will be isolated too.
As of now, Isolator is mostly a proof of concept, it can do some cool stuff, but there are things still missing.
Concepts
The major concepts introduced by Isolator are that of a plugin, represented by the IPlugin interface:
public interface IPlugin
{
object? Execute(IsolationContext context);
}
Then, isolation host, IIsolationHost interface:
public interface IIsolationHost : IDisposable
{
Task<PluginExecutionResult> ExecutePluginAsync<TPlugin>(TPlugin plugin, IsolationContext context, CancellationToken cancellationToken = default) where TPlugin : IPlugin, new();
}
You will notice that IIsolationHost implements IDisposable, this may or may not be relevant to concrete implementations of IIsolationHost, but we should honour it and always use a using block or using statement to wrap the creation of every instance of IIsolationHost. Also, the ExecutePluginAsync method is asynchronous (actual implementations are free to implement it) and generic, meaning, a concrete class must be supplied for its generic parameter, and it must implement IPlugin and be non-abstract, with a public parameterless constructor.
Now, the PluginExecutionResult class, which contains the result of the execution of the plugin, and also the captured standard output and standard error messages:
public record PluginExecutionResult(string StandardOutput, string StandardError, object? Result);
Finally, the execution context, IsolationContext:
public class IsolationContext
{
public Dictionary<string, object> Properties { get; set; } = []; public string[] Arguments { get; set; } = [];
}
You see that the context allows the passing of arbitrary values in a dictionary (Properties) as well as an array of string parameters (Arguments). Any changes to the Properties collection made inside the plugin's Execute method will be returned to the caller. Arguments or Properties can be used or not, and Arguments has nothing to do with process (command line) arguments.
A sample plugin could be this:
public class HelloWorldPlugin : IPlugin { public object? Execute(IsolationContext ctx) { System.Console.WriteLine(ctx.Properties["Greeting"]); System.Console.WriteLine(string.Join(", ", ctx.Arguments)); ctx.Properties["ExecutedAt"] = DateTime.UtcNow; return DateTime.UtcNow; } }
You can see a couple things here:
- We are outputting some text to the standard output (System.Console.WriteLine)
- Getting a value from the Properties dictionary ("Greeting")
- Accessing the Arguments array
- Setting a value to Properties ("ExecutedAt")
Implementations
I implemented two concrete isolation hosts:
- ProcessIsolationHost: uses a process as the host for the plugin code
- AssemblyLoadContextIsolationHost: uses an assembly load context to isolate the plugin's execution
I won't go into details about them, both rely on code generation. We can use them like this:
using var host = new ProcessIsolationHost();
var plugin = new HelloWorldPlugin();
var context = new IsolationContext { Properties = new Dictionary<string, object> { ["Greeting"] = "Hello, World!" }, Arguments = ["This", "is", "a", "test"] };
var result = await host.ExecutePluginAsync(plugin, context); //we can skip the cancellationToken parameter
For ProcessIsolationHost we can also pass security-related parameters:
using var host = new ProcessIsolationHost(
userName: "Joe",
password: "Black",
domain: "Movies",
loadUserProfile: true);
The userName, password, domain, and loadUserProfile parameters are passed to the process to create on ProcessStartInfo, please refer to it to see how they are used. They can be used to run code under some arbitrary identity on Windows only.
Of course, you can use AssemblyLoadContextIsolationHost too:
using var host = new AssemblyLoadContextIsolationHost();
For now, there are no parameters to pass.
Serialisation
All parameters on the IPlugin-concrete class, all those passed on the Properties dictionary, as well as the result returned from Execute, must be serialisable by System.Text.Json. In the future, I may make this extensible.
Note to Implementers
Remember, the choice of how we implement ExecutePluginAsync is up to us: we may want to make it asynchronous or not, depending on our code. Also, do not forget about the Dispose method, it may or may not make sense. In any case, both these facilities are here for you.
Alternatives
Some alternatives for executing .NET code in a sandbox exist. I will mention two.
DotNetIsolator
DotNetIsolator is an experimental project by Steve Sanderson of Microsoft (the creator of Knockout.js and Blazor, amongst others), that uses a WebAssembly sandbox to run code. It is available from here: https://github.com/SteveSandersonMS/DotNetIsolator.
AppDomainAlternative
AppDomainAlternative is a project by Cy Scott which uses a remoting-similar approach for executing code. Code is available here: https://github.com/CyAScott/AppDomainAlternative.
Roadmap
Some features come to my mind, and will possibly be implemented in any form someday:
- Having more security-related options, for both implementations
- Making the serialiser and deserialiser pluggable
- Having an implementation for WebAssembly, maybe using DotNetIsolator as its base
- Adding more options and checks
Conclusion
As always, I hope you find this useful, and looking forward to hearing your thoughts. This code is available on GitHub and as a Nuget package.
Comments
Post a Comment