Поиск практического подхода к изолированным плагинам .NET

Я ищу простой и безопасный способ доступа к плагинам из приложения .NET. Хотя я считаю, что это очень распространенное требование, я изо всех сил стараюсь найти все, что соответствует всем моим потребностям:

  • Хост-приложение будет обнаруживать и загружать свои сборки плагинов во время выполнения
  • Плагины будут созданы неизвестными третьими сторонами, поэтому они должны быть изолированы, чтобы предотвратить их выполнение вредоносного кода.
  • Общая сборка interop будет содержать типы, на которые ссылаются как хост, так и его плагины
  • Каждая сборка плагинов будет содержать один или несколько классов, которые реализуют общий интерфейс плагина
  • При инициализации экземпляра плагина хост передаст ему ссылку на себя в виде интерфейса хоста
  • Хост будет вызывать плагин через свой общий интерфейс, и плагины могут также вызвать хост
  • Хост и плагины будут обмениваться данными в форме типов, определенных в сборке interop (включая общие типы)

Я изучил как MEF, так и MAF, но я изо всех сил пытаюсь понять, как можно сделать любой из них, чтобы соответствовать счету.

Предполагая, что мое понимание верное, MAF не может поддерживать передачу общих типов через границу изоляции, что очень важно для моего приложения. (MAF также очень сложно реализовать, но я был бы готов работать с этим, если бы мог решить проблему типового типа).

MEF - почти идеальное решение, но, похоже, не соответствует требованиям безопасности, поскольку он загружает свои сборки расширения в том же AppDomain, что и хост, и, таким образом, по-видимому, предотвращает песочницу.

Я видел этот вопрос, в котором говорится о запуске MEF в изолированном режиме, но не описывается как. В этом сообщении указано, что "при использовании MEF вы должны доверять расширениям, чтобы не запускать вредоносный код, или предлагать защиту через защиту кода доступа", он не описывает, как это сделать. Наконец, есть этот пост, в котором описывается, как предотвратить загрузку неизвестных плагинов, но это не подходит для моей ситуации, так как даже законный плагины будут неизвестны.

Мне удалось применить атрибуты безопасности .NET 4.0 к моим сборкам, и они правильно соблюдаются MEF, но я не вижу, как это помогает мне блокировать злоумышленный код, так как многие из методов структуры, которые могут быть угроза безопасности (например, методы System.IO.File) отмечены как SecuritySafeCritical, что означает, что они доступны из SecurityTransparent сборок. Я что-то упустил? Есть ли какой-нибудь дополнительный шаг, который я могу предпринять, чтобы сказать MEF, что он должен предоставлять интернет-привилегии для сборки плагинов?

Наконец, я также рассмотрел создание моей собственной простой изолированной архитектуры плагинов, используя отдельный AppDomain, как описано здесь. Однако, насколько я вижу, этот метод позволяет мне использовать последнее связывание для вызова статических методов на классах в ненадежной сборке. Когда я пытаюсь расширить этот подход, чтобы создать экземпляр одного из моих классов плагинов, возвращаемый экземпляр не может быть перенесен в общий интерфейс плагина, а это означает, что приложение-хост не может вызвать его. Есть ли какой-то метод, который я могу использовать для получения строго прокси-доступа через границу AppDomain?

Извиняюсь за длину этого вопроса; причина заключалась в том, чтобы показать все пути, которые я уже исследовал, в надежде, что кто-то может предложить что-то новое, чтобы попробовать.

Большое спасибо за ваши идеи, Тим

Ответ 1

Поскольку вы находитесь в разных AppDomains, вы не можете просто передать экземпляр через.

Вам нужно будет сделать свои плагины Remotable и создать прокси-сервер в главном приложении. Взгляните на документы для CreateInstanceAndUnWrap, в котором есть пример того, как все это может работать в нижней части.

Это еще один гораздо более общий обзор Jon Shemitz, который, я думаю, хорошо читается. Удачи.

Ответ 2

Я принял ответ Alastair Maw, так как это было его предложение и ссылки, которые привели меня к работоспособному решению, но я размещаю здесь некоторые подробности о том, что я сделал, для всех, кто может пытаться добиться чего-то подобного.

Как напоминание, в его самой простой форме мое приложение состоит из трех сборок:

  • Основная сборка приложения, которая будет потреблять плагины
  • Межсетевая сборка, которая определяет общие типы, совместно используемые приложением и его плагинами
  • Пример сборки плагинов

Код ниже - это упрощенная версия моего реального кода, показывающая только то, что требуется для обнаружения и загрузки плагинов, каждый в своем собственном AppDomain:

Начиная с основной сборки приложения, основной класс программы использует класс утилиты с именем PluginFinder для обнаружения качественных типов плагинов в любых сборках в определенной папке плагина. Для каждого из этих типов он создает экземпляр sandox AppDomain (с разрешениями интернет-зоны) и использует его для создания экземпляра обнаруженного типа плагина.

При создании AppDomain с ограниченными разрешениями можно указать одну или несколько доверенных сборок, которые не подпадают под эти разрешения. Чтобы выполнить это в представленном здесь сценарии, должна быть подписана основная сборка приложения и его зависимости (сборка взаимодействия).

Для каждого загруженного экземпляра плагина пользовательские методы в плагине могут быть вызваны через его известный интерфейс, а плагин также может перезванивать к хост-приложению через его известный интерфейс. Наконец, приложение-хозяин выгружает каждый из доменов песочницы.

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);
    }
}

В этом примере кода класс приложения-хозяина очень прост, выставляя только один метод, который может вызываться плагинами. Однако этот класс должен быть получен из MarshalByRefObject, чтобы на него можно было ссылаться между доменами приложения.

/// <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");
    }
}

Класс PluginFinder имеет только один открытый метод, который возвращает список обнаруженных типов плагинов. Этот процесс обнаружения загружает каждую собранную сборку и использует отражение для идентификации своих квалификационных типов. Поскольку этот процесс может потенциально загружать множество сборок (некоторые из которых даже не содержат типы плагинов), он также выполняется в отдельном домене приложения, который может быть впоследствии выгружен. Обратите внимание, что этот класс также наследует MarshalByRefObject по причинам, описанным выше. Поскольку экземпляры Type не могут быть переданы между доменами приложений, в этом процессе обнаружения используется настраиваемый тип, называемый TypeLocator, для хранения имени строки и имени сборки для каждого обнаруженного типа, который затем может быть безопасно передан обратно в основной домен приложения.

/// <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; }
}

В сборке interop содержится базовый класс для классов, которые будут реализовывать функциональность плагина (обратите внимание, что он также происходит от MarshalByRefObject.

Эта сборка также определяет интерфейс IHost, который позволяет плагинам переходить в основное приложение.

/// <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();
}

Наконец, каждый плагин получается из базового класса, определенного в сборке interop, и реализует его абстрактные методы. В любой плагиновой сборке может быть несколько наследующих классов, и может быть несколько сборщиков плагинов.

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();           
    }
}

Ответ 3

Если вам нужно, чтобы ваши сторонние расширения загружались с более низкими правами безопасности, чем остальные приложения, вы должны создать новый AppDomain, создать контейнер MEF для своих расширений в этом домене приложения, а затем вызывать вызовы из приложения к объектам в изолированном домене приложения. Песочница происходит в том, как вы создаете домен приложения, и не имеет ничего общего с MEF.

Ответ 4

Спасибо, что поделились с нами решением. Я хотел бы сделать важный комментарий и приговор.

Комментарий состоит в том, что вы не можете на 100% изолировать плагин, загрузив его в другой AppDomain с хоста. Чтобы узнать, обновите DoSomethingDangerous до следующего:

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

Необработанное исключение, созданное дочерним потоком, может привести к сбою всего приложения.

Прочтите this для получения информации об исключительных исключениях.

Вы также можете прочитать эти две записи в блоге из команды System.AddIn, которые объясняют, что 100% -ная изоляция может быть только тогда, когда надстройка находится в другом процессе. У них также есть пример того, что кто-то может сделать, чтобы получать уведомления от надстроек, которые не справляются с поднятыми исключениями.

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

Теперь sugestion, который я хотел сделать, связан с методом PluginFinder.FindPlugins. Вместо того, чтобы загружать каждую сборку кандидата в новом AppDomain, отражая ее типы и выгружая AppDomain, вы можете использовать Mono.Cecil. Вам тогда не придется ничего делать.

Это так же просто, как:

AssemblyDefinition ad = AssemblyDefinition.ReadAssembly(assemblyPath);

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

Возможно, есть еще лучшие способы сделать это с Cecil, но я не являюсь экспертом в этой библиотеке.

Привет,

Ответ 5

Альтернативой может быть использование этой библиотеки: https://processdomain.codeplex.com/ Он позволяет вам запускать любой код .NET в внедоменном AppDomain, который обеспечивает еще лучшую изоляцию, чем принятый ответ. Конечно, нужно выбрать правильный инструмент для своей задачи, и во многих случаях подход, данный в принятом ответе, - это все, что нужно.

Однако, если вы работаете с плагинами .net, которые вызывают в родные библиотеки, которые могут быть нестабильными (ситуация, с которой я лично сталкивался), вы хотите запускать их не только в отдельном домене приложения, но и в отдельном процессе. Хорошей особенностью этой библиотеки является то, что она автоматически перезапустит процесс, если плагин сработает.