Looking for a practical approach to sandboxing .NET plugins

和自甴很熟 提交于 2019-11-27 05:52:41

Because you're in different AppDomains, you can't just pass the instance across.

You'll need to make your plug-ins Remotable, and create a proxy in your main app. Have a look at the docs for CreateInstanceAndUnWrap, which has an example of how all this could work towards the bottom.

This is also another much broader overview by Jon Shemitz which I think is a good read. Good luck.

Tim Coulter

I have accepted Alastair Maw's answer, as it was his suggestion and links that led me to a workable solution, but I am posting here some details of exactly what I did, for anyone else who may be trying to achieve something similar.

As a reminder, in its simplest form my application comprises three assemblies:

  • The main application assembly that will consume plugins
  • An interop assembly that defines common types shared by the application and its plugins
  • A sample plugin assembly

The code below is a simplified version of my real code, showing only what is required to discover and load plugins, each in its own AppDomain:

Starting with the main application assembly, the main program class uses a utility class named PluginFinder to discover qualifiying plugin types within any assemblies in a designated plugin folder. For each of these types, it then creates an instance of a sandox AppDomain (with internet zone permissions) and uses it to create an instance of the discovered plugin type.

When creating an AppDomain with limited permissions, it is possible to specify one or more trusted assemblies that are not subject to those permissions. To accomplish this in the scenario presented here, the main application assembly and its dependencies (the interop assembly) must be signed.

For each loaded plugin instance, the custom methods within the plugin can be called via its known interface and the plugin can also call back to the host application via its known interface. Finally, the host application unloads each of the sandbox domains.

class Program
{
    static void Main()
    {
        var domains = new List<AppDomain>();
        var plugins = new List<PluginBase>();
        var types = PluginFinder.FindPlugins();
        var host = new Host();

        foreach (var type in types)
        {
            var domain = CreateSandboxDomain("Sandbox Domain", PluginFinder.PluginPath, SecurityZone.Internet);
            plugins.Add((PluginBase)domain.CreateInstanceAndUnwrap(type.AssemblyName, type.TypeName));
            domains.Add(domain);
        }

        foreach (var plugin in plugins)
        {
            plugin.Initialize(host);
            plugin.SaySomething();
            plugin.CallBackToHost();

            // To prove that the sandbox security is working we can call a plugin method that does something
            // dangerous, which throws an exception because the plugin assembly has insufficient permissions.
            //plugin.DoSomethingDangerous();
        }

        foreach (var domain in domains)
        {
            AppDomain.Unload(domain);
        }

        Console.ReadLine();
    }

    /// <summary>
    /// Returns a new <see cref="AppDomain"/> according to the specified criteria.
    /// </summary>
    /// <param name="name">The name to be assigned to the new instance.</param>
    /// <param name="path">The root folder path in which assemblies will be resolved.</param>
    /// <param name="zone">A <see cref="SecurityZone"/> that determines the permission set to be assigned to this instance.</param>
    /// <returns></returns>
    public static AppDomain CreateSandboxDomain(
        string name,
        string path,
        SecurityZone zone)
    {
        var setup = new AppDomainSetup { ApplicationBase = Path.GetFullPath(path) };

        var evidence = new Evidence();
        evidence.AddHostEvidence(new Zone(zone));
        var permissions = SecurityManager.GetStandardSandbox(evidence);

        var strongName = typeof(Program).Assembly.Evidence.GetHostEvidence<StrongName>();

        return AppDomain.CreateDomain(name, null, setup, permissions, strongName);
    }
}

In this sample code, the host application class is very simple, exposing just one method that may be called by plugins. However, this class must derive from MarshalByRefObject so that it can be referenced between application domains.

/// <summary>
/// The host class that exposes functionality that plugins may call.
/// </summary>
public class Host : MarshalByRefObject, IHost
{
    public void SaySomething()
    {
        Console.WriteLine("This is the host executing a method invoked by a plugin");
    }
}

The PluginFinder class has only one public method that returns a list of discovered plugin types. This discovery process loads each assembly that it finds and uses reflection to identify its qualifying types. Since this process may potentially load many assemblies (some of which are do not even contain plugin types) it is also executed in a separate application domain, which may be subsequntly unloaded. Note that this class also inherits MarshalByRefObject for the reasons described above. Since instances of Type may not be passed between application domains, this discovery process uses a custom type called TypeLocator to store the string name and assembly name of each discovered type, which may then be safely passed back to the main applicatin domain.

/// <summary>
/// Safely identifies assemblies within a designated plugin directory that contain qualifying plugin types.
/// </summary>
internal class PluginFinder : MarshalByRefObject
{
    internal const string PluginPath = @"..\..\..\Plugins\Output";

    private readonly Type _pluginBaseType;

    /// <summary>
    /// Initializes a new instance of the <see cref="PluginFinder"/> class.
    /// </summary>
    public PluginFinder()
    {
        // For some reason, compile-time types are not reference equal to the corresponding types referenced
        // in each plugin assembly, so equality must be tested by loading types by name from the Interop assembly.
        var interopAssemblyFile = Path.GetFullPath(Path.Combine(PluginPath, typeof(PluginBase).Assembly.GetName().Name) + ".dll");
        var interopAssembly = Assembly.LoadFrom(interopAssemblyFile);
        _pluginBaseType = interopAssembly.GetType(typeof(PluginBase).FullName);
    }

    /// <summary>
    /// Returns the name and assembly name of qualifying plugin classes found in assemblies within the designated plugin directory.
    /// </summary>
    /// <returns>An <see cref="IEnumerable{TypeLocator}"/> that represents the qualifying plugin types.</returns>
    public static IEnumerable<TypeLocator> FindPlugins()
    {
        AppDomain domain = null;

        try
        {
            domain = AppDomain.CreateDomain("Discovery Domain");

            var finder = (PluginFinder)domain.CreateInstanceAndUnwrap(typeof(PluginFinder).Assembly.FullName, typeof(PluginFinder).FullName);
            return finder.Find();
        }
        finally
        {
            if (domain != null)
            {
                AppDomain.Unload(domain);
            }
        }
    }

    /// <summary>
    /// Surveys the configured plugin path and returns the the set of types that qualify as plugin classes.
    /// </summary>
    /// <remarks>
    /// Since this method loads assemblies, it must be called from within a dedicated application domain that is subsequently unloaded.
    /// </remarks>
    private IEnumerable<TypeLocator> Find()
    {
        var result = new List<TypeLocator>();

        foreach (var file in Directory.GetFiles(Path.GetFullPath(PluginPath), "*.dll"))
        {
            try
            {
                var assembly = Assembly.LoadFrom(file);

                foreach (var type in assembly.GetExportedTypes())
                {
                    if (!type.Equals(_pluginBaseType) &&
                        _pluginBaseType.IsAssignableFrom(type))
                    {
                        result.Add(new TypeLocator(assembly.FullName, type.FullName));
                    }
                }
            }
            catch (Exception e)
            {
                // Ignore DLLs that are not .NET assemblies.
            }
        }

        return result;
    }
}

/// <summary>
/// Encapsulates the assembly name and type name for a <see cref="Type"/> in a serializable format.
/// </summary>
[Serializable]
internal class TypeLocator
{
    /// <summary>
    /// Initializes a new instance of the <see cref="TypeLocator"/> class.
    /// </summary>
    /// <param name="assemblyName">The name of the assembly containing the target type.</param>
    /// <param name="typeName">The name of the target type.</param>
    public TypeLocator(
        string assemblyName,
        string typeName)
    {
        if (string.IsNullOrEmpty(assemblyName)) throw new ArgumentNullException("assemblyName");
        if (string.IsNullOrEmpty(typeName)) throw new ArgumentNullException("typeName");

        AssemblyName = assemblyName;
        TypeName = typeName;
    }

    /// <summary>
    /// Gets the name of the assembly containing the target type.
    /// </summary>
    public string AssemblyName { get; private set; }

    /// <summary>
    /// Gets the name of the target type.
    /// </summary>
    public string TypeName { get; private set; }
}

The interop assembly contains the base class for classes that will implement plugin functionality (note that it also derives from MarshalByRefObject.

This assembly also defines the IHost interface that enables plugins to call back into the host application.

/// <summary>
/// Defines the interface common to all untrusted plugins.
/// </summary>
public abstract class PluginBase : MarshalByRefObject
{
    public abstract void Initialize(IHost host);

    public abstract void SaySomething();

    public abstract void DoSomethingDangerous();

    public abstract void CallBackToHost();
}

/// <summary>
/// Defines the interface through which untrusted plugins automate the host.
/// </summary>
public interface IHost
{
    void SaySomething();
}

Finally, each plugin derives from the base class defined in the interop assembly and implements its abstract methods. There may be multiple inheriting classes in any plugin assembly and there may be multiple plugin assemblies.

public class Plugin : PluginBase
{
    private IHost _host;

    public override void Initialize(
        IHost host)
    {
        _host = host;
    }

    public override void SaySomething()
    {
        Console.WriteLine("This is a message issued by type: {0}", GetType().FullName);
    }

    public override void DoSomethingDangerous()
    {
        var x = File.ReadAllText(@"C:\Test.txt");
    }

    public override void CallBackToHost()
    {
        _host.SaySomething();           
    }
}

If you need your 3rd party extensions to load with a lower security privileges than the rest of your app, you should create a new AppDomain, create a MEF container for your extensions in that app domain, and then marshall calls from your application to the objects in the sandboxed app domain. The sandboxing occurs in how you create the app domain, it has nothing to to with MEF.

Thanks for sharing with us the solution. I would like to make an important comment and a sugestion.

The comment is that you cannot 100% sandbox a plugin by loading it in a different AppDomain from the host. To find out, update DoSomethingDangerous to the following:

public override void DoSomethingDangerous()                               
{                               
    new Thread(new ThreadStart(() => File.ReadAllText(@"C:\Test.txt"))).Start();
}

An unhandled exception raised by a child thread can crash the whole application.

Read this for information concerning unhandle exceptions.

You can also read these two blog entries from the System.AddIn team that explain that 100% isolation can only be when the add-in is in a different process. They also have an example of what someone can do to get notifications from add-ins that fail to handle raised exceptions.

http://blogs.msdn.com/b/clraddins/archive/2007/05/01/using-appdomain-isolation-to-detect-add-in-failures-jesse-kaplan.aspx

http://blogs.msdn.com/b/clraddins/archive/2007/05/03/more-on-logging-unhandledexeptions-from-managed-add-ins-jesse-kaplan.aspx

Now the sugestion that I wanted to make has to do with the PluginFinder.FindPlugins method. Instead of loading each candidate assembly in a new AppDomain, reflecting on it's types and the unload the AppDomain, you could use Mono.Cecil. You then will not have to do any of this.

It is as simple as:

AssemblyDefinition ad = AssemblyDefinition.ReadAssembly(assemblyPath);

foreach (TypeDefinition td in ad.MainModule.GetTypes())
{
    if (td.BaseType != null && td.BaseType.FullName == "MyNamespace.MyTypeName")
    {        
        return true;
    }
}

There are probably even better ways to do this with Cecil but I am not an expert user of this library.

Regards,

An alternative would be to use this library: https://processdomain.codeplex.com/ It allows you running any .NET code in out-of-process AppDomain, which provides even better isolation, than the accepted answer. Of course one needs to choose a right tool for their task and in many cases the approach given in the accepted answer is all that is needed.

However if your are working with .net plugins that call into native libraries that may be unstable (the situation I personally came across) you want to run them not only in a separate app domain, but also in a separate process. A nice feature of this library is that it will automatically restarts the process if a plugin crashes it.

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!