История одного НЕ-ОТВЕТА на stackoverflow

001b369bfea715c30b7e7ce8ab2079b2.pngПара слов о КДПВ

Сперва хотел поставить мемичный кадр с порталом из Рика и Морти, но потом решил, что это слишком избито (пока собирался писать, даже кто-то успел им воспользоваться), к тому же сам лично ни одного эпизода не видел. А вот любимая мной серия игр Blackwell, напротив, незаслуженно обделена вниманием.

Недавно наткнулся на stackoverflow на такой вопрос Need to check if code contains certain identifiers и в ходе размышлений преобразился из «маленького помощника Санты» в «адвоката дьявола». Что, конечно, гораздо веселее. Но мораль не в этом.

Смысл вопроса в том, что человек хочет проверять пользовательские скрипты на наличие запрещённого его правилами кода и в этом просит помощи у сообщества.

Вопрос (оригинал)

Need to check if code contains certain identifiers

I am going to be dynamically compiling and executing code using Roslyn like the example below. I want to make sure the code does not violate some of my rules, like:

  • Does not use Reflection

  • Does not use HttpClient or WebClient

  • Does not use File or Directory classes in System.IO namespace

  • Does not use Source Generators

  • Does not call unmanaged code

Where in the following code would I insert my rules/checks and how would I do them?

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using System.Reflection;
using System.Runtime.CompilerServices;

string code = @"using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.IO;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            //The following should not be allowed
            File.Delete(@""C:\Temp\log.txt"");

            return await Task.FromResult(data);
        }
    }
}";

var compilation = Compile(code);
var bytes = Build(compilation);

Console.WriteLine("Done");

CSharpCompilation Compile(string code)
{
    SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);

    string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
    if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
    {
        throw new ArgumentNullException("Cannot determine path to current assembly.");
    }

    string assemblyName = Path.GetRandomFileName();
    List references = new();
    references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

    CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


    SemanticModel model = compilation.GetSemanticModel(syntaxTree);
    CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

    //TODO: Check the code for use classes that are not allowed such as File in the System.IO namespace.
    //Not exactly sure how to walk through identifiers.
    IEnumerable identifiers = root.DescendantNodes()
        .Where(s => s is IdentifierNameSyntax)
        .Cast();


    return compilation;
}

[MethodImpl(MethodImplOptions.NoInlining)]
byte[] Build(CSharpCompilation compilation)
{
    using (MemoryStream ms = new())
    {
        //Emit to catch build errors
        EmitResult emitResult = compilation.Emit(ms);

        if (!emitResult.Success)
        {
            Diagnostic? firstError =
                emitResult
                    .Diagnostics
                    .FirstOrDefault
                    (
                        diagnostic => diagnostic.IsWarningAsError ||
                            diagnostic.Severity == DiagnosticSeverity.Error
                    );

            throw new Exception(firstError?.GetMessage());
        }

        return ms.ToArray();
    }
}

Вопрос (перевод)

Надо проверить, содержит ли код определённые идентификаторы

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

  • Не использует рефлексию

  • Не использует HttpClient или WebClient

  • Не использует классы File или Directory из пространства имён System.IO

  • Не использует генераторы исходного кода

  • Не вызывает неуправляемый код

Где в ниже приведённом коде мне добавить мои правила/проверки, и как их сделать?

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using System.Reflection;
using System.Runtime.CompilerServices;

string code = @"using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.IO;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            //The following should not be allowed
            File.Delete(@""C:\Temp\log.txt"");

            return await Task.FromResult(data);
        }
    }
}";

var compilation = Compile(code);
var bytes = Build(compilation);

Console.WriteLine("Done");

CSharpCompilation Compile(string code)
{
    SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);

    string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
    if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
    {
        throw new ArgumentNullException("Cannot determine path to current assembly.");
    }

    string assemblyName = Path.GetRandomFileName();
    List references = new();
    references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

    CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


    SemanticModel model = compilation.GetSemanticModel(syntaxTree);
    CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

    //TODO: Check the code for use classes that are not allowed such as File in the System.IO namespace.
    //Not exactly sure how to walk through identifiers.
    IEnumerable identifiers = root.DescendantNodes()
        .Where(s => s is IdentifierNameSyntax)
        .Cast();


    return compilation;
}

[MethodImpl(MethodImplOptions.NoInlining)]
byte[] Build(CSharpCompilation compilation)
{
    using (MemoryStream ms = new())
    {
        //Emit to catch build errors
        EmitResult emitResult = compilation.Emit(ms);

        if (!emitResult.Success)
        {
            Diagnostic? firstError =
                emitResult
                    .Diagnostics
                    .FirstOrDefault
                    (
                        diagnostic => diagnostic.IsWarningAsError ||
                            diagnostic.Severity == DiagnosticSeverity.Error
                    );

            throw new Exception(firstError?.GetMessage());
        }

        return ms.ToArray();
    }
}

Меня это заинтересовало, и, накидав в голове примерное решение, собрался я уже, было, оформлять ответ, как вспомнил поучительную историю с одного из прошлых проектов. Наша команда работала над визуальным конструктором SQL запросов в рамках одной известной ERP. Мы предусмотрели защиту от инъекций и были горды собой, но руководство решило на всякий случай отправить наше решение на стороннюю экспертизу. В итоге, эксперт показал нам, как из нашей базы данных легко и непринуждённо извлечь логины с хешами паролей и еще всякую мелочь. В общем, резонно предположив, что любой мой ответ будет дырявым и однажды может оказаться косвенной причиной чьих-то финансовых и репутационных потерь, решил действовать по принципу «не навреди» и просто стал наблюдать за активностью по этому вопросу. К счастью, довольно быстро нашелся более отважный инженер и предложил свою версию, а там и сам автор подтянулся.

Ответ (оригинал)

When checking for the use of a particular class you can look for IdentifierNameSyntax type nodes by using the OfType<>() method and filter the results by class name:

var names = root.DescendantNodes()
    .OfType()
    .Where(i => string.Equals(i.Identifier.ValueText, className, StringComparison.OrdinalIgnoreCase));

You can then use the SemanticModel to check the namespace of the class:

foreach (var name in names)
{
    var typeInfo = model.GetTypeInfo(name);
    if (string.Equals(typeInfo.Type?.ContainingNamespace?.ToString(), containingNamespace, StringComparison.OrdinalIgnoreCase))
    {
        throw new Exception($"Class {containingNamespace}.{className} is not allowed.");
    }
}

To check for the use of reflection or unmanaged code you could check for the relevant usings System.Reflection and System.Runtime.InteropServices.

if (root.Usings.Any(u => string.Equals(u.Name.ToString(), disallowedNamespace, StringComparison.OrdinalIgnoreCase)))
{
    throw new Exception($"Namespace {disallowedNamespace} is not allowed.");
}

This would catch cases where the usings were unused i.e., no actual reflection or unmanaged code, but that seems like an acceptable trade off.

I’m not sure what to do about the source generator checks as these are normally included as project references so I don’t know how they’d run against dynamically compiled code.

Keeping the checks in the same place and updating your code gives:

namespace Customization
{
    public class Script
    {
        static readonly HttpClient client = new HttpClient();

        public async Task RunAsync(object? data)
        {
            //The following should not be allowed
            File.Delete(@""C:\Temp\log.txt"");

            return await Task.FromResult(data);
        }
    }
}";

var compilation = Compile(code);

var bytes = Build(compilation);
Console.WriteLine("Done");


CSharpCompilation Compile(string code)
{
    SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);

    string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
    if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
    {
        throw new InvalidOperationException("Cannot determine path to current assembly.");
    }

    string assemblyName = Path.GetRandomFileName();
    List references = new();
    references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(HttpClient).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

    CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


    SemanticModel model = compilation.GetSemanticModel(syntaxTree);
    CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

    ThrowOnDisallowedClass("File", "System.IO", root, model);
    ThrowOnDisallowedClass("HttpClient", "System.Net.Http", root, model);
    ThrowOnDisallowedNamespace("System.Reflection", root);
    ThrowOnDisallowedNamespace("System.Runtime.InteropServices", root);

    return compilation;
}

[MethodImpl(MethodImplOptions.NoInlining)]
byte[] Build(CSharpCompilation compilation)
{
    using (MemoryStream ms = new())
    {
        //Emit to catch build errors
        EmitResult emitResult = compilation.Emit(ms);

        if (!emitResult.Success)
        {
            Diagnostic? firstError =
                emitResult
                    .Diagnostics
                    .FirstOrDefault
                    (
                        diagnostic => diagnostic.IsWarningAsError ||
                            diagnostic.Severity == DiagnosticSeverity.Error
                    );

            throw new Exception(firstError?.GetMessage());
        }

        return ms.ToArray();
    }
}

void ThrowOnDisallowedClass(string className, string containingNamespace, CompilationUnitSyntax root, SemanticModel model)
{
    var names = root.DescendantNodes()
                    .OfType()
                    .Where(i => string.Equals(i.Identifier.ValueText, className, StringComparison.OrdinalIgnoreCase));

    foreach (var name in names)
    {
        var typeInfo = model.GetTypeInfo(name);
        if (string.Equals(typeInfo.Type?.ContainingNamespace?.ToString(), containingNamespace, StringComparison.OrdinalIgnoreCase))
        {
            throw new Exception($"Class {containingNamespace}.{className} is not allowed.");
        }
    }
}

void ThrowOnDisallowedNamespace(string disallowedNamespace, CompilationUnitSyntax root)
{
    if (root.Usings.Any(u => string.Equals(u.Name.ToString(), disallowedNamespace, StringComparison.OrdinalIgnoreCase)))
    {
        throw new Exception($"Namespace {disallowedNamespace} is not allowed.");
    }
}

I’ve used throw for rule violations here which will mean that multiple violations will not be reported all at once so you may want to tweak that so it’s a bit more efficient.

Ответ (перевод)

Проверяя на использование определённого класса, можете поискать узлы типа IdentifierNameSyntax, используя метод OfType<>(), и отфильтровать результаты по имени класса:

var names = root.DescendantNodes()
    .OfType()
    .Where(i => string.Equals(i.Identifier.ValueText, className, StringComparison.OrdinalIgnoreCase));

Затем можете использовать SemanticModel для проверки пространства имён класса:

foreach (var name in names)
{
    var typeInfo = model.GetTypeInfo(name);
    if (string.Equals(typeInfo.Type?.ContainingNamespace?.ToString(), containingNamespace, StringComparison.OrdinalIgnoreCase))
    {
        throw new Exception($"Class {containingNamespace}.{className} is not allowed.");
    }
}

Для проверки на использование рефлексии или неуправляемого кода можете проверять на соответствующие using’и System.Reflection иSystem.Runtime.InteropServices.

if (root.Usings.Any(u => string.Equals(u.Name.ToString(), disallowedNamespace, StringComparison.OrdinalIgnoreCase)))
{
    throw new Exception($"Namespace {disallowedNamespace} is not allowed.");
}

Это отловит случаи, когда using’и не были использованы, т.е. без реальной рефлексии или неуправляемого кода, но это кажется приемлемым компромиссом.

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

Объединение проверок в одном месте и обновление Вашего кода даёт:

namespace Customization
{
    public class Script
    {
        static readonly HttpClient client = new HttpClient();
    public async Task<object?> RunAsync(object? data)
    {
        //The following should not be allowed
        File.Delete(@""C:\Temp\log.txt"");

        return await Task.FromResult(data);
    }
}

}";
var compilation = Compile(code);
var bytes = Build(compilation);
Console.WriteLine("Done");
CSharpCompilation Compile(string code)
{
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);
string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
{
    throw new InvalidOperationException("Cannot determine path to current assembly.");
}

string assemblyName = Path.GetRandomFileName();
List<MetadataReference> references = new();
references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(typeof(HttpClient).Assembly.Location));
references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

CSharpCompilation compilation = CSharpCompilation.Create(
    assemblyName,
    syntaxTrees: new[] { syntaxTree },
    references: references,
    options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


SemanticModel model = compilation.GetSemanticModel(syntaxTree);
CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

ThrowOnDisallowedClass("File", "System.IO", root, model);
ThrowOnDisallowedClass("HttpClient", "System.Net.Http", root, model);
ThrowOnDisallowedNamespace("System.Reflection", root);
ThrowOnDisallowedNamespace("System.Runtime.InteropServices", root);

return compilation;

}
[MethodImpl(MethodImplOptions.NoInlining)]
byte[] Build(CSharpCompilation compilation)
{
using (MemoryStream ms = new())
{
//Emit to catch build errors
EmitResult emitResult = compilation.Emit(ms);
    if (!emitResult.Success)
    {
        Diagnostic? firstError =
            emitResult
                .Diagnostics
                .FirstOrDefault
                (
                    diagnostic => diagnostic.IsWarningAsError ||
                        diagnostic.Severity == DiagnosticSeverity.Error
                );

        throw new Exception(firstError?.GetMessage());
    }

    return ms.ToArray();
}

}
void ThrowOnDisallowedClass(string className, string containingNamespace, CompilationUnitSyntax root, SemanticModel model)
{
var names = root.DescendantNodes()
.OfType()
.Where(i => string.Equals(i.Identifier.ValueText, className, StringComparison.OrdinalIgnoreCase));
foreach (var name in names)
{
    var typeInfo = model.GetTypeInfo(name);
    if (string.Equals(typeInfo.Type?.ContainingNamespace?.ToString(), containingNamespace, StringComparison.OrdinalIgnoreCase))
    {
        throw new Exception($"Class {containingNamespace}.{className} is not allowed.");
    }
}

}
void ThrowOnDisallowedNamespace(string disallowedNamespace, CompilationUnitSyntax root)
{
if (root.Usings.Any(u => string.Equals(u.Name.ToString(), disallowedNamespace, StringComparison.OrdinalIgnoreCase)))
{
throw new Exception($"Namespace {disallowedNamespace} is not allowed.");
}
}

Здесь я использовал  throw для нарушений правил, что означает, что множественные нарушения не будут зафиксированны одновременно. Поэтому Вы можете захотеть это подправить, чтобы было чуть эффективнее.

Собственный ответ автора вопроса (оригинал)

The SymbolInfo class provides some of the meatadata needed to create rules to restrict use of certain code. Here is what I came up with so far. Any suggestions on how to improve on this would be appreciated.

//Check for banned namespaces
string[] namespaceBlacklist = new string[] { "System.Net", "System.IO" };

foreach (IdentifierNameSyntax identifier in identifiers)
{
    SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(identifier);

    if (symbolInfo.Symbol is { })
    {
        if (symbolInfo.Symbol.Kind == SymbolKind.Namespace)
        {
            if (namespaceBlacklist.Any(ns => ns == symbolInfo.Symbol.ToDisplayString()))
            {
                throw new Exception($"Declaration of namespace '{symbolInfo.Symbol.ToDisplayString()}' is not allowed.");
            }
        }
        else if (symbolInfo.Symbol.Kind == SymbolKind.NamedType)
        {
            if (namespaceBlacklist.Any(ns => symbolInfo.Symbol.ToDisplayString().StartsWith(ns + ".")))
            {
                throw new Exception($"Use of namespace '{identifier.Identifier.ValueText}' is not allowed.");
            }
        }
    }
}

Собственный ответ автора вопроса (перевод)

Класс SymbolInfo предоставляет метаданные, необходимые для создания правил для ограничения использования определенного кода. Вот что у меня пока вышло. Буду благодарен за любые предложения по улучшению.

//Check for banned namespaces
string[] namespaceBlacklist = new string[] { "System.Net", "System.IO" };

foreach (IdentifierNameSyntax identifier in identifiers)
{
    SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(identifier);

    if (symbolInfo.Symbol is { })
    {
        if (symbolInfo.Symbol.Kind == SymbolKind.Namespace)
        {
            if (namespaceBlacklist.Any(ns => ns == symbolInfo.Symbol.ToDisplayString()))
            {
                throw new Exception($"Declaration of namespace '{symbolInfo.Symbol.ToDisplayString()}' is not allowed.");
            }
        }
        else if (symbolInfo.Symbol.Kind == SymbolKind.NamedType)
        {
            if (namespaceBlacklist.Any(ns => symbolInfo.Symbol.ToDisplayString().StartsWith(ns + ".")))
            {
                throw new Exception($"Use of namespace '{identifier.Identifier.ValueText}' is not allowed.");
            }
        }
    }
}

И тут роль мамкиного хакера (потому что я не грэй хэт какой-нибудь, а просто случайный мимо-крокодил, который чутка шарит за C#) показалась мне более привлекательной и, что важнее, полезной.

Итак, в путь. Для начала было бы неплохо сделать код тестопригодным. На основе кода из вопроса я создал класс Compiler, максимально сохранив авторские исходники.

Compiler

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;

namespace UserCodeValidation
{
    public class Compiler
    {
        private readonly ICodeValidator validator;

        public Compiler(ICodeValidator validator)
        {
            this.validator = validator;
        }

        public CSharpCompilation Compile(string code)
        {
            SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);

            string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
            if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
            {
                throw new ArgumentNullException("Cannot determine path to current assembly.");
            }

            string assemblyName = Path.GetRandomFileName();
            List references = new();
            references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
            references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
            references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
            references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
            references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
            references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

            CSharpCompilation compilation = CSharpCompilation.Create(
                assemblyName,
                syntaxTrees: new[] { syntaxTree },
                references: references,
                options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


            SemanticModel model = compilation.GetSemanticModel(syntaxTree);
            CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

            validator?.Validate(root, model);


            return compilation;
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public byte[] Build(CSharpCompilation compilation)
        {
            using (MemoryStream ms = new())
            {
                //Emit to catch build errors
                EmitResult emitResult = compilation.Emit(ms);

                if (!emitResult.Success)
                {
                    Diagnostic? firstError =
                        emitResult
                            .Diagnostics
                            .FirstOrDefault
                            (
                                diagnostic => diagnostic.IsWarningAsError ||
                                    diagnostic.Severity == DiagnosticSeverity.Error
                            );

                    throw new Exception(firstError?.GetMessage());
                }

                return ms.ToArray();
            }
        }
    }
}

Валидацию кода решил делать с помощью экземпляра ICodeValidator, задаваемого через конструктор.

ICodeValidator

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace UserCodeValidation
{
    public interface ICodeValidator
    {
        void Validate(CompilationUnitSyntax root, SemanticModel model);
    }
}

На основе ответа пользователя и собственного ответа автора написал реализации ICodeValidator: Answer и OwnAnswer соответственно. Код тоже максимально приближен к оригинальным исходникам. Единственно, вместо Exception выбрасываются более специализированные CodeValidationException, чтобы в тестах проверять конкретно их наличие.

CodeValidationException

using System;
using System.Runtime.Serialization;

namespace UserCodeValidation
{
    [Serializable]
    public class CodeValidationException : Exception
    {
        public CodeValidationException(string message)
            : base(message) { }

        protected CodeValidationException(SerializationInfo info, StreamingContext context)
            : base(info, context) { }
    }
}

Answer

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Linq;

namespace UserCodeValidation
{
    public class Answer : ICodeValidator
    {
        public void Validate(CompilationUnitSyntax root, SemanticModel model)
        {
            ThrowOnDisallowedClass("File", "System.IO", root, model);
            ThrowOnDisallowedClass("HttpClient", "System.Net.Http", root, model);

            ThrowOnDisallowedNamespace("System.Net", root);
            ThrowOnDisallowedNamespace("System.IO", root);
            ThrowOnDisallowedNamespace("System.Reflection", root);
            ThrowOnDisallowedNamespace("System.Runtime.InteropServices", root);
        }

        void ThrowOnDisallowedClass(string className, string containingNamespace, CompilationUnitSyntax root, SemanticModel model)
        {
            var names = root.DescendantNodes()
                            .OfType()
                            .Where(i => string.Equals(i.Identifier.ValueText, className, StringComparison.OrdinalIgnoreCase));

            foreach (var name in names)
            {
                var typeInfo = model.GetTypeInfo(name);
                if (string.Equals(typeInfo.Type?.ContainingNamespace?.ToString(), containingNamespace, StringComparison.OrdinalIgnoreCase))
                {
                    throw new CodeValidationException($"Class {containingNamespace}.{className} is not allowed.");
                }
            }
        }

        void ThrowOnDisallowedNamespace(string disallowedNamespace, CompilationUnitSyntax root)
        {
            if (root.Usings.Any(u => string.Equals(u.Name.ToString(), disallowedNamespace, StringComparison.OrdinalIgnoreCase)))
            {
                throw new CodeValidationException($"Namespace {disallowedNamespace} is not allowed.");
            }
        }
    }
}

OwnAnswer

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Generic;
using System.Linq;

namespace UserCodeValidation
{
    public class OwnAnswer : ICodeValidator
    {
        public void Validate(CompilationUnitSyntax root, SemanticModel model)
        {
            IEnumerable identifiers = root.DescendantNodes()
                .Where(s => s is IdentifierNameSyntax)
                .Cast();
            //Check for banned namespaces
            string[] namespaceBlacklist = new string[] { "System.Net", "System.IO", "System.Reflection", "System.Runtime.InteropServices" };
            foreach (IdentifierNameSyntax identifier in identifiers)
            {
                SymbolInfo symbolInfo = model.GetSymbolInfo(identifier);

                if (symbolInfo.Symbol is { })
                {
                    if (symbolInfo.Symbol.Kind == SymbolKind.Namespace)
                    {
                        if (namespaceBlacklist.Any(ns => ns == symbolInfo.Symbol.ToDisplayString()))
                        {
                            throw new CodeValidationException($"Declaration of namespace '{symbolInfo.Symbol.ToDisplayString()}' is not allowed.");
                        }
                    }
                    else if (symbolInfo.Symbol.Kind == SymbolKind.NamedType)
                    {
                        if (namespaceBlacklist.Any(ns => symbolInfo.Symbol.ToDisplayString().StartsWith(ns + ".")))
                        {
                            throw new CodeValidationException($"Use of namespace '{identifier.Identifier.ValueText}' is not allowed.");
                        }
                    }
                }
            }
        }
    }
}

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

Tests

using System;
using System.IO;
using System.Reflection;
using Xunit;

namespace UserCodeValidation.Tests
{
    public class Tests : IDisposable
    {
        #region Test data

        private const string initialContents = "initial contents";

        #endregion Test data

        private readonly string file;

        public Tests()
        {
            file = Path.GetRandomFileName();
            File.WriteAllText(file, initialContents);
        }

        public void Dispose()
        {
            File.Delete(file);
        }

        [Theory]
        public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
        {
            // Arrange
            code = code.Replace("", file);
            Compiler compiler = new Compiler((ICodeValidator)Activator.CreateInstance(validator));

            // Act
            Exception exception = Record.Exception(() =>
            {
                Type script = Assembly.Load(compiler.Build(compiler.Compile(code))).GetType("Customization.Script");
                script.GetMethod("RunAsync").Invoke(Activator.CreateInstance(script), new object[] { default });
            });

            // Assert
            Assert.Equal(isValidCode, exception is not CodeValidationException);
            Assert.Equal(finalContents ?? initialContents, File.ReadAllText(file));
        }
    }
}

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

private const string basicExample = @"
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            return await Task.FromResult(data);
        }
    }
}";

Тесты, действительно, проходят.

[Theory]
[InlineData(typeof(Answer), basicExample, true)]
[InlineData(typeof(OwnAnswer), basicExample, true)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

Для неприкрытых попыток удаления, например:

private const string fullyQualifiedNameFileExample = @"
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            System.IO.File.Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

private const string namespaceFileExample = @"
using System.IO;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            File.Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

валидатор должен выбрасывать исключение, спасая несчастный файл. Это, собственно, случаи, для которых и писался код в ответах на вопрос. И так, по факту, и происходит.

[Theory]
// ...
[InlineData(typeof(Answer), fullyQualifiedNameFileExample, false)]
[InlineData(typeof(OwnAnswer), fullyQualifiedNameFileExample, false)]
[InlineData(typeof(Answer), namespaceFileExample, false)]
[InlineData(typeof(OwnAnswer), namespaceFileExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

А теперь начнём проверять валидаторы на прочность. Удастся ли обмануть их с помощью using static?

private const string usingStaticExample = @"
using System.Threading.Tasks;
using static System.IO.File;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

Очевидно, что нет.

[Theory]
// ...
[InlineData(typeof(Answer), usingStaticExample, false)]
[InlineData(typeof(OwnAnswer), usingStaticExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

А с помощью псевдонимов пространств имен и типов?

private const string namespaceAliasExample = @"
using System.Threading.Tasks;
using Alias = System.IO;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            Alias.File.Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

private const string typeAliasExample = @"
using System.Threading.Tasks;
using Alias = System.IO.File;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            Alias.Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

Тоже не прокатило.

[Theory]
// ...
[InlineData(typeof(Answer), namespaceAliasExample, false)]
[InlineData(typeof(OwnAnswer), namespaceAliasExample, false)]
[InlineData(typeof(Answer), typeAliasExample, false)]
[InlineData(typeof(OwnAnswer), typeAliasExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

Им не нравятся псевдонимы, содержащие System.IO. А если так?

private const string trickyAliasExample = @"
using System.Threading.Tasks;
using Alias = System;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            Alias.IO.File.Delete("""");

            return await Task.FromResult(data);
        }
    }
}";

Эх… А надежды были…

[Theory]
// ...
[InlineData(typeof(Answer), trickyAliasExample, false)]
[InlineData(typeof(OwnAnswer), trickyAliasExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

На самом деле, всем предыдущим ухищрениям противостояла мощь библиотек Microsoft.CodeAnalysis.dll и Microsoft.CodeAnalysis.CSharp.dll под капотом валидаторов, и попытки были весьма наивны. Так что необходимо менять подход. Но сперва небольшое отступление.

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

private const string fullyQualifiedNameStreamWriterExample = @"
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            using (new System.IO.StreamWriter("""")) ;

            return await Task.FromResult(data);
        }
    }
}";

private const string namespaceStreamWriterExample = @"
using System.IO;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            using (new StreamWriter("""")) ;

            return await Task.FromResult(data);
        }
    }
}";

Ожидаемо, один тест для Answer падает. Немного поможем ему, обновив метод Validate. В конце концов, концептуально это ничего не меняет.

Answer.Validate

public void Validate(CompilationUnitSyntax root, SemanticModel model)
{
    ThrowOnDisallowedClass("File", "System.IO", root, model);
    ThrowOnDisallowedClass("StreamWriter", "System.IO", root, model); // update
    ThrowOnDisallowedClass("HttpClient", "System.Net.Http", root, model);

    ThrowOnDisallowedNamespace("System.Net", root);
    ThrowOnDisallowedNamespace("System.IO", root);
    ThrowOnDisallowedNamespace("System.Reflection", root);
    ThrowOnDisallowedNamespace("System.Runtime.InteropServices", root);
}

Только теперь уже этот тест падает неожиданно. Но дебаг всё ставит на свои места.

24a224e7e148243cef9bf42d3e2864f3.png

Метод расширения

TypeInfo GetTypeInfo(this SemanticModel semanticModel, SyntaxNode node, CancellationToken cancellationToken = default)

для таких случаев не определяет тип (ITypeSymbol) для идентификатора (IdentifierNameSyntax).

Итак, записываем:

[Theory]
// ...
[InlineData(typeof(Answer), fullyQualifiedNameStreamWriterExample, true, "")]
[InlineData(typeof(OwnAnswer), fullyQualifiedNameStreamWriterExample, false)]
[InlineData(typeof(Answer), namespaceStreamWriterExample, false)]
[InlineData(typeof(OwnAnswer), namespaceStreamWriterExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

Вернёмся к нашим баранам. Один из валидаторов повержен. Но, во-первых, это случайность, я себе это не так представлял. А, во-вторых, хотелось бы, чтобы оба. Так что продолжим. К чему было отступление? До него вредоносный код вызывался из статического метода. Но вредить можно и из экземплярных. В том числе конструкторов. А конструкторы можно вызывать не только через new() и рефлексию, но и через Activator, который хоть и рефлексирует под капотом, но находится в пространстве имён System. Поэтому переписываем один из предыдущих примеров.

private const string streamWriterViaActivatorExample = @"
using System;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            using ((IDisposable)Activator.CreateInstance(
                Type.GetType(""System.IO.StreamWriter""),
                new object[] { """" })) ;

            return await Task.FromResult(data);
        }
    }
}";

Оба валидатора пали.

[Theory]
// ...
[InlineData(typeof(Answer), streamWriterViaActivatorExample, true, "")]
[InlineData(typeof(OwnAnswer), streamWriterViaActivatorExample, true, "")]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

В качестве бонуса хотелось бы не просто затереть файл, а записать что-нибудь осмысленное. Но Activator возвращает object, на котором ничего особо не повызываешь. Явное приведение не проскочет мимо валидаторов.

private const string explicitCastExample = @"
using System;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            using (var sw = (System.IO.StreamWriter)Activator.CreateInstance(
                Type.GetType(""System.IO.StreamWriter""),
                new object[] { """" }))
                sw.Write(""WASTED"");

            return await Task.FromResult(data);
        }
    }
}";
[Theory]
// ...
[InlineData(typeof(Answer), explicitCastExample, false)]
[InlineData(typeof(OwnAnswer), explicitCastExample, false)]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

А dynamic не скомпилируется из-за отсутствия ссылки на Microsoft.CSharp.dll.

private const string dynamicExample = @"
using System;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            using (dynamic sw = (IDisposable)Activator.CreateInstance(
                Type.GetType(""System.IO.StreamWriter""),
                new object[] { """" }))
                sw.Write(""WASTED"");

            return await Task.FromResult(data);
        }
    }
}";

Хотя, если автор вопроса захочет добавить dynamic (например, для удобного взаимодействия с Excel) и обновит Compiler.Compile:

Compiler.Compile

public CSharpCompilation Compile(string code)
{
    SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);

    string? dotNetCoreDirectoryPath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
    if (String.IsNullOrWhiteSpace(dotNetCoreDirectoryPath))
    {
        throw new ArgumentNullException("Cannot determine path to current assembly.");
    }

    string assemblyName = Path.GetRandomFileName();
    List references = new();
    references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Console).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location));
    references.Add(MetadataReference.CreateFromFile(typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly.Location)); // enable `dynamic`
    references.Add(MetadataReference.CreateFromFile(Path.Combine(dotNetCoreDirectoryPath, "System.Runtime.dll")));

    CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));


    SemanticModel model = compilation.GetSemanticModel(syntaxTree);
    CompilationUnitSyntax root = (CompilationUnitSyntax)syntaxTree.GetRoot();

    validator?.Validate(root, model);


    return compilation;
}

Экспериментаторам откроется широкий простор для творчества.

[Theory]
// ...
[InlineData(typeof(Answer), dynamicExample, true, "WASTED")]
[InlineData(typeof(OwnAnswer), dynamicExample, true, "WASTED")]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

Но что же делать, пока этого не произошло?… Радоваться, что эти валидаторы на самом деле безполезны против рефлексии чуть менее чем полностью. Они проверяют только использование пространства имён System.Reflection, вызов статических методов на типах из него и вызов кострукторов этих типов. А если на типе из разрешённого пространства имён вызывается метод, возвращающий объект из System.Reflection, то всё ОК.

private const string reflectionExample = @"
using System;
using System.Threading.Tasks;

namespace Customization
{
    public class Script
    {
        public async Task RunAsync(object? data)
        {
            var type = Type.GetType(""System.IO.StreamWriter"");
            using (var sw = (IDisposable) Activator.CreateInstance(type, new object[] { """" }))
                type.GetMethod(""Write"", new[] { typeof(string) }).Invoke(sw, new object[] { ""WASTED"" });
            
            return await Task.FromResult(data);
        }
    }
}";

Оба валидатора позволили изменить содержимое файла.

[Theory]
// ...
[InlineData(typeof(Answer), reflectionExample, true, "WASTED")]
[InlineData(typeof(OwnAnswer), reflectionExample, true, "WASTED")]
public void Test(Type validator, string code, bool isValidCode, string finalContents = null)
// ...

Считаю такой результат неплохим и на этом, пожалуй, остановлюсь.

Ах, да. Мораль, упомянутая во вступлении. Наколенные решения по безопасности — к беде. Или нескучному вечеру. Смотря с какой Вы стороны.

© Habrahabr.ru