I am looking for a simple and secure way to access plugins from a .NET application. Although I imagine that this is a very common requirement, I am struggling to find anything that meets all my needs:
I have investigated both MEF and MAF, but I am struggling to see how either of them can be made to fit the bill.
Assuming my understanding is correct, MAF is unable to support the passing of generic types across its isolation boundary, which is essential to my application. (MAF is also very complex to implement, but I would be prepared to work with this if I could solve the generic type problem).
MEF is almost a perfect solution, but appears to fall short on the security requirement, as it loads its extension assemblies in the same AppDomain as the host, and thus apparently prevents sandboxing.
I have seen this question, which talks of running MEF in a sandboxed mode, but doesn't describe how. This post states that "when using MEF you must trust extensions not to run malicious code, or offer protection via Code Access Security" but, again, it doesn't describe how. Finally, there is this post, which describes how to prevent unknown plugins from being loaded, but this is not appropriate to my situation, as even legitimate plugins will be unknown.
I have succeeded in applying .NET 4.0 security attributes to my assemblies and they are correctly respected by MEF, but I don't see how this helps me to lock out malicous code, as many of the framework methods that might be a security threat (such as methods of System.IO.File
) are marked as SecuritySafeCritical
, which means that they are accessible from SecurityTransparent
assemblies. Am I missing something here? Is there some additonal step I can take to to tell MEF that it should provide internet privileges to plugin assemblies?
Finally, I have also looked at creating my own simple sandboxed plugin architecture, using a separate AppDomain, as described here. However, as far as I can see, this technique only allows me to use late binding to invoke static methods on classes in an untrusted assembly. When I try to extend this approach to create an instance of one of my plugin classes, the returned instance cannot be cast to the common plugin interface, which means that it is impossible for the host application to call into it. Is there some technique I can use to get strongly-typed proxy access across the AppDomain boundary?
I apologize for the length of this question; the reason was to show all the avenues that I have already investigated, in the hope that somebody can suggest something new to try.
Many thanks for your ideas, Tim
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 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(); } }
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With