Introduction
In fact everyone now knows what a plug-in is. A plug-in is a set of software components that extends functionality of an application. For example, plug-ins are often used in different multimedia players to extend formats that the player can play.
Background
With the release of the .NET 4.0 platform we've got the Managed Extensibility Framework (MEF) – a quite decent extension engine.
But sometimes (though seldom) a customer limits the version of the framework used. Of course you could ask Google to search for ready solutions for implementing plug-ins. For example I've seen the Plux.NET platform for building plug-in systems but I've never tried to review it in details.
So we've got a task to create the solution that would allow your application to extend its functionality without recompilation. An approximate plan of working with plug-ins is as follows:
- There is an application that supports adding plug-ins.
- You have added some plug-in files to the special application folder.
- The application must initiate loading of the plug-ins. For this purpose the application must be restarted or use an explicit method call, e.g.
LoadPlugins()
.
- Enjoy the extended function set!
There are many articles about plug-ins and I'll do my best to be original.
After reading the article you'll learn how to create extensions that support .net configuration and how to limit permissions granted to the plug-ins code.
What's new in version 2
In the first version of the demo project attached to the article loaded plug-ins were fully trusted. That meant they were able to execute potentially dangerous or undesirable code. This was because of loading plug-ins assemblies into the same application domain as the host application. It was the easiest way but not the safest.
The second version offers other approach of loading plug-ins. All plug-ins are loaded into the separate application domain called the Sandbox. The sandbox is an isolated environment with the limited access permissions granted to the executing code.
Creating base classes for our future plug-ins
First of all, let's implement the PluginBase
class – as you can understand from its name, it is the base class for our future plug-ins.
Create a new class library project in Visual Studio named PluginBase and add a file with the next code to the project:
public abstract class PluginBase : MarshalByRefObject
{
public virtual string Name { get; protected set; }
public virtual string Description { get; protected set; }
public virtual ConfigurationBase Configuration { get; set; }
public PluginBase()
{
Name = "Unnamed plugin";
Description = "No description";
}
public abstract void Run();
}
Please note the PluginBase
class is derived from the MarshalByRefObject
class. That's why instances of the PluginBase
class can be accessed across application domain boundaries.
Our simple plug-ins will contain just one clear method – Run()
. You can implement any method you want. I said "clear" method because there are three properties
in this class – Name
, Description
and Configuration
.
The Run()
method is abstract that's why you need to implement this method in your plug-in. Properties are just virtual so you can override them in your descendant plug-in.
It would be great to have configurable plug-ins. And it would be much greater if each plug-in's configuration would be stored in a separate section of the app.config file of the host application.
So let's do it. Here is the abstract class ConfigurationBase
, it's inherited from the ConfigurationSection
class (don't forget to add a reference to the System.Configuration
namespace).
public abstract class ConfigurationBase : ConfigurationSection
{
public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()
{
T instance = new T();
if (configPath.EndsWith(".config", StringComparison.InvariantCultureIgnoreCase))
configPath = configPath.Remove(configPath.Length - 7);
try
{
Configuration config = ConfigurationManager.OpenExeConfiguration(configPath);
if (config.GetSection(sectionName) == null)
{
config.Sections.Add(sectionName, instance);
foreach (ConfigurationProperty p in instance.Properties)
((T)config.GetSection(sectionName)).SetPropertyValue(p, p.DefaultValue, true);
config.Save();
}
else
instance = (T)config.Sections[sectionName];
}
catch (ConfigurationErrorsException)
{
if (instance == null)
instance = new T();
}
return instance;
}
}
The ConfigurationBase
class contains one generic method
-
public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()
This static method allows you to open the application configuration file and read the configuration section of the specified type and with the specified name. There will not be configuration section in the file when you first time load the plug-in. That's why this method will add the section to the configuration file and after that you will be able to modify the configuration properties. Anyway the method will return the instance of the configuration section's class.
You may have noticed the line if (config.GetSection(sectionName) == null)
is highlighted. Please take in mind that line. We'll come back to it later.
Creating the plug-in
Now it's time to create the plug-in. Open a new class library project (let's call it ShowConsolePlugin) in Visual Studio and add a reference
to the previously created project that contains the PluginBase
and ConfigurationBase
classes. Add two files in the project.
ShowConsolePluginConfiguration.cs:
public sealed class ShowConsolePluginConfiguration : PluginBase.ConfigurationBase
{
[ConfigurationProperty("Message", DefaultValue = "Hello from ShowConsolePlugin")]
public string Message
{
get
{
return (String)this["Message"];
}
set
{
this["Message"] = value;
}
}
}
This class is inherited from the ConfigurationBase
class of the PluginBase assembly and has the property Message
which actually is a configuration property.
ShowConsolePlugin.cs:
public sealed class ShowConsolePlugin : PluginBase.PluginBase
{
public ShowConsolePlugin()
{
Name = "ShowConsolePlugin";
Description = "ShowConsolePlugin";
}
public override void Run()
{
Console.WriteLine(String.Format("[{0}] {1}", DateTime.Now, (Configuration as ShowConsolePluginConfiguration).Message));
}
}
The Name
and Description
properties are set in the default constructor of the plug-in. Haven't you forgotten about the Configuration
property
of the PluginBase
class? We convert its type to the ShowConsolePluginConfiguration
type in the overridden method Run()
which writes the Message
value to the system console. Do not worry the value of the Configuration
property is null
at this moment. We'll take care about this later.
I've been asked how to disable plug-ins access to the Internet. Well, with the sandboxing approach you can do it along with many others security features. For demonstration purposes I've created the WebDownloadPlugin project. The process of creation of the project is the same as the previous (ShowConsolePlugin) except adding the configuration class.
WebDownloadPlugin.cs:
public sealed class WebDownloadPlugin : PluginBase.PluginBase
{
public WebDownloadPlugin()
{
Name = "WebDownloadPlugin";
Description = "WebDownloadPlugin";
}
public override void Run()
{
string html = String.Empty;
using (var wc = new WebClient())
{
html = wc.DownloadString("http://codeproject.com");
}
Console.WriteLine(Regex.Match(html, @"<title>\s*(.+?)\s*</title>").Groups[1]);
}
}
As you can see this plug-in downloads the main page of the CodeProject web-site and writes its title to the system console.
Creating the plug-in manager
The easiest part is over. Now we must create the plug-in manager class. It will provide the logic of loading plug-ins's files and the logic of interaction between plug-ins and the host application.
The plug-in manager class will be contained in the PluginBase assembly. We have to perform two steps before we start to write the code.
- First, we must give a strong name to the PluginBase assembly. It's necessary for providing full trust for this assembly in the sandboxed application domain. The PluginBase project in the attached zip-file was already signed with the key file. Detailed instructions how to sign an assembly you can read here.
-
Second, the PluginBase assembly must be marked with the
AllowPartiallyTrustedCallersAttribute
(APTCA) attribute because of untrusted plug-ins that refer to the base classes in the trusted assembly. In the attached project it is made in the file AssemblyInfo.cs from the PluginBase/Properties directory.
Unfortunately using of the APTCA attribute will also allows a plug-in to execute any method in any class from the PluginBase assembly. That's why we need to demand permissions from the callers higher in the call stack to make sure that the caller is trusted.
It can be achieved by marking such "critical" methods with the next attribute:
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
Now after fixing the security exploit we can start developing the PluginManager
class. It must be inherited from the MarshalByRefObject
class as well as the PluginBase
class.
public sealed class PluginManager : MarshalByRefObject
{
private readonly Dictionary<Assembly, PluginBase> plugins;
...
There is the plugins
field in the code above. It is an instance of Dictionary<Assembly, PluginBase>
which is used to store plug-in classes as values and plug-in assemblies as keys.
Now let's add the method for loading plug-ins. Wait, what about constructors and the sandboxing, you'll ask? Well, some architectural decisions made in the constructor are easier to explain after seeing the next method:
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginBase LoadPlugin(string fullName)
{
Assembly pluginAssembly;
try
{
new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.PathDiscovery, fullName).Assert();
pluginAssembly = AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(fullName));
}
catch (BadImageFormatException)
{
return null;
}
finally
{
CodeAccessPermission.RevertAssert();
}
var pluginType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(PluginBase));
if (pluginType == null)
throw new InvalidOperationException("Plugin's type has not been found in the specified assembly!");
var pluginInstance = Activator.CreateInstance(pluginType) as PluginBase;
plugins.Add(pluginAssembly, pluginInstance);
var pluginConfigurationType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(ConfigurationBase));
if (pluginConfigurationType != null)
{
string processPath = String.Empty;
try
{
new SecurityPermission(SecurityPermissionFlag.UnmanagedCode).Assert();
processPath = Process.GetCurrentProcess().MainModule.FileName;
}
finally
{
CodeAccessPermission.RevertAssert();
}
try
{
var pset = new PermissionSet(PermissionState.None);
pset.AddPermission(new FileIOPermission(PermissionState.Unrestricted));
pset.AddPermission(new ConfigurationPermission(PermissionState.Unrestricted));
pset.Assert();
pluginInstance.Configuration =
typeof(ConfigurationBase)
.GetMethod("Open")
.MakeGenericMethod(pluginConfigurationType)
.Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;
}
finally
{
CodeAccessPermission.RevertAssert();
}
}
return pluginInstance;
}
We use the Load
method of the current application domain here to load an assembly. After loading of the assembly we search for the first class derived from the PluginBase
class in it – it is the main plug-in class. Then we create an instance of the main plug-in class using the Activator.CreateInstance
method and add the created instance to the Plugins
dictionary with a key equal to the assembly that contains the plug-in class.
As was mentioned above we will grant the full trust to the PluginBase assembly. That's why we are free to use assertion of any needed permissions. But don't forget to remove it from the current stack frame by calling CodeAccessPermission.RevertAssert()
.
Let's try to load the configuration section of the plug-in in the same way. We can use the generic method Open
from the ConfigurationBase
class:
pluginInstance.Configuration =
typeof(ConfigurationBase)
.GetMethod("Open")
.MakeGenericMethod(pluginConfigurationType)
.Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;
If the plug-in assembly has a definition for a descendant of ConfigurationBase
then it will be loaded. And we can use its configuration properties
in the Run()
method code no more worrying about null
values.
Now let's modify a default constructor of the PluginManager
class:
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginManager()
{
plugins = new Dictionary<Assembly, PluginBase>();
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
}
Do you remember the highlighted line from the ConfigurationBase
class?
When you call the config.GetSection(sectionName)
you implicitly deserialize the configuration section of the type specified in the configuration file. The search for this type occurs in the assembly specified in the same configuration file. How the CLR locates assemblies you can read here. The PluginBase assembly resides in the custom folder Plugins and the CLR will not find it. It is the reason why the ConfigurationErrorsException
will be thrown. We can solve this problem by two ways:
Assemblies have already been loaded into the current application domain and we have already saved the assemblies references in the dictionary field plugins
of the PluginManager
class. That's why we'd better handle the AppDomain.AssemblyResolve
event:
private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
return plugins.Keys.FirstOrDefault(x => x.FullName == args.Name);
}
We've just "prepared" the PluginBase assembly to be loaded into the sandboxed application domain. To ensure maximum compatibility with older applications that had used the plug-in manager it would be better to write a factory method to create an instance of the new PluginManager
class.
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public static PluginManager GetInstance(PermissionSet grantSet)
{
if (grantSet == null)
throw new ArgumentNullException("grantSet");
grantSet.AddPermission(new ReflectionPermission(ReflectionPermissionFlag.RestrictedMemberAccess));
grantSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
var sandbox = AppDomain.CreateDomain("sandbox", null, AppDomain.CurrentDomain.SetupInformation, grantSet, getStrongName(Assembly.GetExecutingAssembly()));
return Activator.CreateInstanceFrom(sandbox, typeof(PluginManager).Assembly.ManifestModule.FullyQualifiedName, typeof(PluginManager).FullName).Unwrap() as PluginManager;
}
The GetInstance
method above takes one argument of the PermissionSet
type. It creates an application domain with that permission set and with the same setup information first. Then it creates an instance of the PluginManager
type in the sanboxed application domain, using the named assembly file and default constructor.
Also we have given full trust to the PluginBase assembly. For this purpose we needed to get its strong name. The getStrongName
method gets a strong name for an assembly passed as argument (it was taken from here).
private static StrongName getStrongName(Assembly assembly)
{
if (assembly == null)
throw new ArgumentNullException("assembly");
AssemblyName assemblyName = assembly.GetName();
byte[] publicKey = assemblyName.GetPublicKey();
if (publicKey == null || publicKey.Length == 0)
throw new InvalidOperationException("Assembly is not strongly named");
StrongNamePublicKeyBlob keyBlob = new StrongNamePublicKeyBlob(publicKey);
return new StrongName(keyBlob, assemblyName.Name, assemblyName.Version);
}
The sandbox is ready. All security attributes are set and untrusted plug-ins cannot call methods that require full trust. Everything seems fine but we missed one small detail.
Since both the host application domain and the sandbox application domain have the same setup information (instance of the AppDomainSetup
class) the Repurposing Attack is possible on the host application. That means untrusted plug-in can load the host assembly (by calling Assembly.Load
) or another assembly located in the ApplicationBase
(property of the AppDomainSetup
instance) folder. So be careful when granting file permissions to untrusted plug-ins. I've seen many advices not to use the same ApplicationBase
properties. But in this case we'll get too much other troubles:
The sandboxed application domain will not be able to access host assemblies, i.e. cannot access PluginBase assembly. (and the ReflectionTypeLoadException
exception will be thrown after getting types from plug-ins's assemblies). To fix that the PluginBase assembly should be accessible from the host application and from plug-ins simultaneously. There is an obvious workaround for this trouble - put the PluginBase assembly in the GAC. However I won't review this way in this article.
Creating the demo app
Open Visual Studio and add a new console application project named PluginDemo to the solution. Open the Program.cs file and add two constants:
internal class Program
{
private const string pluginDir = "Plugins";
private const string pluginExtMask = "*.dll";
...
First constant is the relative path to the plug-ins directory. Second constant is an extension mask for plug-ins's files.
Now let's add a method that runs plug-ins. It iterates *.dll-files in the specified directory and loads every plug-in using an instance of the PluginManager
class passed as argument.
static void RunPlugins(PluginBase.PluginManager pluginMan)
{
var path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), pluginDir);
foreach (var f in new DirectoryInfo(path).GetFiles(pluginExtMask))
{
var plugin = pluginMan.LoadPlugin(Path.Combine(path, f.Name));
try
{
plugin.Run();
Console.WriteLine("Plugin {0} has finished work\r\n", plugin.Name);
}
catch (Exception ex)
{
Console.WriteLine("An exception occurred in the {0} plugin: {1}\r\n", plugin.Name, ex.Message);
}
}
}
Now modify the Main
method:
static void Main(string[] args)
{
Console.WriteLine("1. Old way of running the plugins - full trust:\r\n");
var pluginMan = new PluginBase.PluginManager();
RunPlugins(pluginMan);
Console.WriteLine(Environment.NewLine);
Console.WriteLine("2. New way of running the plugins - limited trust:\r\n");
var pset = new PermissionSet(PermissionState.None);
pluginMan = PluginBase.PluginManager.GetInstance(pset);
RunPlugins(pluginMan);
Console.ReadKey();
}
Now we must create the folder named "Plugins" in the PluginDemo project. Please note the name of the folder must equals to the pluginDir
constant defined in a code above. Copy ShowConsolePlugin.dll and WebDownloadPlugin.dll to the folder and set the "Copy to Output Directory" property to "Copy always".
Also I recommend to add this line to all plug-ins projects post-build event:
xcopy /y $(ProjectDir)$(OutDir)$(TargetFileName) $(SolutionDir)PluginDemo\Plugins\
Make sure to set right build order of projects. All plug-ins projects must be built before the PluginDemo project.
Testing the demo app
That was all the coding fun! We can compile and run the application.
Note that I also have third plug-in here named SaveTxtPlugin. This plug-in just saves some text to the specified file. The article does not cover the process of creating SaveTxtPlugin because it is similar to that described above.
Running the application will create the file PluginDemo.exe.config. This file will contain:
="1.0" ="utf-8"
<configuration>
<configSections>
<section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</configSections>
<ShowConsolePlugin Message="Hello from ShowConsolePlugin" />
<SaveTxtPlugin FileName="textFile.txt" />
</configuration>
First let's change the Message
attribute in the ShowConsolePlugin
node:
="1.0" ="utf-8"
<configuration>
<configSections>
<section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</configSections>
<ShowConsolePlugin Message="Hello again!" />
<SaveTxtPlugin FileName="textFile.txt" />
</configuration>
Second let's uncomment the next line from the Main
method in the PluginDemo/Program.cs file and recompile the application:
pset.AddPermission(new WebPermission(PermissionState.Unrestricted));
Start PluginDemo.exe again and you will see
History
- 26.09.2012 - Version 2. Plug-ins are now loaded into the separate application domain with limited permission set.
- 16.06.2012 - First initial release.