[.net] Using CodeDom for 'Scripting' with AppDomains and Security VB Example

Started by
0 comments, last by zangetsu 17 years, 6 months ago
If anyone pays attention to my posts I've spent the last couple weeks updating some .Net 1.1 code to .Net 2.0. The old code was using the VSA (Visual Studio for Applications) to run a Scripting system. However this is obsolete in .Net 2.0 so it needs to be replaced. I set about doing this but I did run into a few problems, but with the help of the GD forums and Google I was able get things working. Now that I have it all working, I will now use my new knowledge to create a sample for the future. The sample code is VB. Sorry, it is my language of choice, and I spend enough time translating from C# so things will just have to be the other way around for a change. Before I run into the code, lets just go over a few things. I want this post to be as comprehensive as possible. Most of this information is stuff that you can find in other threads in the GD forums, but I'm a bit too lazy to do the citations. OK, First point is that this is not really scripting. The reason is that scripting is generally speaking interpreted instructions. What we will be doing is dynamically creating a .Net Assembly which will be compiled from text. However for purposes of this discussion the words script and scripting will be thrown about lightly. Now that we have that out of the way we can move on to the first bit. The System.CodeDom namespace has all sorts of neato stuff in it for representing the structure and contents of a source code document. CodeDom is short for Code Document Object Model. Fortunately, we won't be worrying about that right now, we are going to concentrate on the compiler object that are located in this namespace. In this namespace we have access to compilers for the various Microsoft supported Languages. So what we will do is use one of these compilers to create an assembly. Lets go ahead and take a look at how that is done. First we need a compiler paramters object. It contains information the compiler will need to create the assembly.
Dim CParams As New System.CodeDom.Compiler.CompilerParameters
Now the compiler will need to know what assemblies should be referenced. This is pretty simple, we just add the assemblies. At a bare minimum we will need the System.dll and the Microsoft.VisualBasic.dll. If we were using some other language for our script like C# we would need to add its assembly here too.

CParams.ReferencedAssemblies.Add("System.dll")
CParams.ReferencedAssemblies.Add("Microsoft.VisualBasic.dll")
Now chances are that the assembly we are about to create will want to interact with your application in a meaningful way. To do that it will need to know about the assembly that contains your application. So We need one more add, This line will add the assembly of the code that is running.

CParams.ReferencedAssemblies.Add(System.Reflection.[Assembly].GetAssembly(GetType(ScriptObject)).Location)
Since the assembly we are creating is not going to be an application, and just a library that will contain a class we need to tell the compiler that it is a library.

CParams.CompilerOptions = "/t:library"
We want to create the assembly in memory. There is no need to write it to disk. since it is temporary. To do this we set GenerateInMemory to True.

CParams.GenerateInMemory = True
Now this is all we really need, but I mentioned that we are going to try to secure things. I'm no security expert so I can't guarantee this configuration. The thing is that the new assembly will default to the same security level as your application. However you didn't write the code because the script will have been written by a user. So we are better off not trusting it. Normally the compiler will use the Evidence of your program. I'm not completely clear how Evidence works so I'll use a metaphor. Evidence is kind of like a key ring. It has all sorts of keys on it that open doors. So You can have full trust assemblies who have a master key to every door in the building, and you can have non-full trust assemblies, who's keys only open a few doors. So what we need is evidence for our assembly that isn't full trust. Here is how we do that. We need to create an array of objects with a Policy Zone. We will create this using the Internet Zone. There are other zones, but this zone has the balance we need between permission to execute, but no ability to claim additional permissions. Again I am glossing over this because I don't understand it fully myself.

Dim EvObj As [Object]() = { _
    New System.Security.Policy.Zone(System.Security.SecurityZone.Internet)}
CParams.Evidence = New System.Security.Policy.Evidence(EvObj, Nothing)
Thats all we need to do for the compiler parameters. Next step is to create an assembly. We will declare a results object to hold the output of the compiler. And we will also create a VBCodeProvider. This is the object that does the work of compiling the script. Then all we need to do is call one of the compile methods. I'll be using CompileAssemblyFromFile because Its a little easier to learn with a text file that contains your script. It is possible to create the code in memory and pass the string to the code provider. However you decide to do it in your program is up to you. Here we will use the string variable ScriptFile that contains the path to the file containing our script.

Dim Results As System.CodeDom.Compiler.CompilerResults
Dim Provider As New VBCodeProvider
Results = Provider.CompileAssemblyFromFile(CParams, ScriptFile)
Now at this point we would hope that the assembly was created. Since the compile methods do not throw exceptions we need to check the results by hand. To do this we need to create a CompilerErrorCollection, and set it to the Results.Errors. We can then check the count to see if everything worked. If the collection is empty the the compile was successful, if not then you can examine the contents of the collection to find out what went wrong. We won't be doing that since I want to keep things simple.

Dim CompilerErrors As New System.CodeDom.Compiler.CompilerErrorCollection       CompilerErrors = Results.Errors
If CompilerErrors.Count > 0 Then
   Throw New ApplicationException("There was an error compiling.")
End If
Ok, hopefully the script compiled, and we can move along. I suppose you would like to see at this point what the script contains? Ok This is a really simple script. We import a few namespaces. You'll want to import your host application. You'll notice that the script is really just a class. This is because later we will be creating an instance of this object and calling its methods to run the code contained here. This class has three members but it is enough to demonstrate. You will notice that there is an instance of a ScriptObject. This is a class that will be accessible to both your application and the script. More on that later. GetEngine is a convience function that helps your application find the ScriptObject. There is probably a better way using reflection. There is finally the real meat of the script the clasic Multiply code.

Imports System
Imports Microsoft.VisualBasic
Imports MyApplication

Public Class Script

Public Engine as New ScriptObject

Public Function GetEngine() as Object
	Return Engine
End Function

Public Function Multiply(ByVal X as Integer, ByVal Y as integer) as Integer
	Engine.DoSomething
	Return X * Y
End Function

End Class

If you tried building an application with what we have covered so far you might notice that we still need to define ScriptObject. So here it is.

Public Class ScriptObject
    Public Event DoSomethingCalled()

    Public Sub DoSomething()
        RaiseEvent DoSomethingCalled()
    End Sub
End Class
More simplicity. It has one method and one event. This is all you really need. The idea is that this object exposes everything a script will need to access in your application. This puts everything in one place for the script author, and helps control what they have access to. Now that we have that the next step is going to be creating an instance of the Script Class, but before we do that we need to tighten our security a little bit. The reason is that if the script class has a constructor, then code starts running as soon as we create the instance. We didn't write a constructor, but malicous code could easily have one. So we need a permission set to do that. We want to really limit what the script can do so we want a very restrictive set of permissions. We will start with a set with no permissions, and only add the Execution permission to that set. Once we have created the set we will permit only that set. Dim ScriptPermissions = New Security.PermissionSet( _ Security.Permissions.PermissionState.None) ScriptPermissions.AddPermission(New Security.Permissions.SecurityPermission( _ Security.Permissions.SecurityPermissionFlag.Execution)) ScriptPermissions.PermitOnly() At this point any code that runs will only have permission for execution. It will be prevented access to the filesystem and loads of other resources. Therefore any constructor code should be prevented from doing something harmfull. We are now ready to create an instance of the script object.

Dim ScriptInstance As Object
ScriptInstance = ScriptAssembly.CreateInstance("Script")
Now we have an instance of the script object from our compiled assembly. We are going to do some more stuff with it, but we want our code to have normal permissions again. So we create an unrestricted permission set, and demand (all though it really is a request) our full permissions back. We only want limited permission while the script code is running.

Dim AllPermissions As New Security.PermissionSet( _
    Security.Permissions.PermissionState.Unrestricted)
AllPermissions.Demand
Ok, now we have do dig a little deeper to actually use the instance for anything. We need to get the instance's Type so the Type can get a MethodInfo so we can call the MethodInfo's Invoke to make the code run. Sounds complicated right?

Dim ScriptType As Type
ScriptType = ScriptInstance.GetType
Dim MyInfo As Reflection.MethodInfo = MyType.GetMethod("Multiply")
Now we have a MethodInfo object that points the the Multiply Method inside our script. To run this method, we will want to restrict the permissions, Invoke with the needed paramters, and the restore permissions. You'll notice that we need to convert the parameters into an array of objects.

Dim MultiplyResult as Integer
Dim Parameters as [Object]() = {7,6}
ScriptPermissions.PermitOnly()
MultiplyResult = Cint(MyInfo.Invoke(ScriptInstance, Parameters))
AllPermissions.Demand()
Alright thats all well and good. you code should be running and it should be mostly secure. Now all this so far has been procedureal and not really practical. So Here is a class that embodies the code we have just covered. There are a few things I want to point out. First is the event handler. You'll notice I set a handler on the ScriptObject's Do something event. In that code there is an AllPermissions.Assert() This is needed because the call stack at this point is still running under the restricted permissions. So if we want our application to run normally we need to do this. Once we are done, we need to revert the assert so that when execution gets back to the script assembly it is restricted again. Second is the location of the add handler, the HandleDoSomething Sub, the TestMultiply members.; These are really in a bad place when it comes to OO design. So a real world implementation would put them somewhere else. Last, The Try Catch block in the Execute function is really lazy. There are probably other problems with the code, but this is meant to be a simple demonstration.

Public Class ScriptRunner
    Inherits MarshalByRefObject

    Private ScriptInstance As Object
    Private ScriptType As Type
    Private MyEngine As ScriptObject
    Private ScriptPermissions As Security.PermissionSet
    Private AllPermissions As New Security.PermissionSet( _
        Security.Permissions.PermissionState.Unrestricted)

    Public CompilerErrors As New System.CodeDom.Compiler.CompilerErrorCollection
    Public ScriptEngine As ScriptObject

    Public Sub Load(ByRef ScriptFile As String)
        Dim CParams As New System.CodeDom.Compiler.CompilerParameters
        CParams.ReferencedAssemblies.Add("System.dll")
        CParams.ReferencedAssemblies.Add("Microsoft.VisualBasic.dll")
        CParams.ReferencedAssemblies.Add(System.Reflection.[Assembly].GetAssembly(GetType(ScriptObject)).Location)
        CParams.CompilerOptions = "/t:library"
        CParams.GenerateInMemory = True
        Dim EvObj As [Object]() = { _
            New System.Security.Policy.Zone(System.Security.SecurityZone.Internet)}
        CParams.Evidence = New System.Security.Policy.Evidence(EvObj, Nothing)

        Dim Results As System.CodeDom.Compiler.CompilerResults
        Dim Provider As New VBCodeProvider
        Results = Provider.CompileAssemblyFromFile(CParams, ScriptFile)


        CompilerErrors = Results.Errors
        If CompilerErrors.Count > 0 Then
            Throw New ApplicationException("There was an error compiling the script file.")
        End If

        Dim ScriptAssembly As System.Reflection.Assembly
        ScriptAssembly = Results.CompiledAssembly

        ScriptPermissions.PermitOnly()
        ScriptInstance = ScriptAssembly.CreateInstance("Script")

        ScriptType = ScriptInstance.GetType


        ScriptEngine = Execute("GetEngine", Nothing)
        AllPermissions.Demand()

        AddHandler ScriptEngine.DoSomethingCalled, AddressOf HandleDoSomething
    End Sub

    Public Function Execute(ByVal MethodName As String, ByVal Parameters As Object()) As Object
        Dim MyInfo As System.Reflection.MethodInfo = ScriptType.GetMethod(MethodName)
        ScriptPermissions.PermitOnly()
        Try
            Return MyInfo.Invoke(ScriptInstance, Parameters)
        Catch Ex As Security.SecurityException
            'This should trap any permission exceptions caused by the script.
            Return Nothing
        End Try
        AllPermissions.Demand()
    End Function

    Public Sub HandleDoSomething()
        AllPermissions.Assert()
        MsgBox("Event Handled")
        Security.PermissionSet.RevertAssert()
        ScriptPermissions.PermitOnly()
    End Sub

    Public Sub New()
        ScriptPermissions = New Security.PermissionSet(Security.Permissions.PermissionState.None)
        ScriptPermissions.AddPermission(New Security.Permissions.SecurityPermission(Security.Permissions.SecurityPermissionFlag.Execution))
    End Sub

    Public Sub TestMultiply()
        Dim Params As Object() = {6, 7}
        Execute("Mulitply", Params)
    End Sub
End Class

All that is left is some usage. Now here is the thing. We are going to be creating assemblies dynamically in memmory. There is no easy way to unload an assembly once it has been loaded so we need to use AppDomains. because the assembly is loaded inside the AppDomain and we can unload the AppDomain. Its not hard once you have seen it. All we do is create an AppDomain. Then we use the newly created Domain to create an instance of the script runner class. We can then access that class and its members even though it is inside another AppDomain. There are probably all kinds of caveats here, but since this is still new to me I don't know what they all are.

Dim ScriptDom As AppDomain = AppDomain.CreateDomain("ScriptDom")
Dim Runner As ScriptRunner = ScriptDom.CreateInstanceFromAndUnwrap( _
    System.Reflection.Assembly.GetAssembly(GetType(ScriptRunner)).Location, _
    GetType(ScriptRunner).FullName)

Runner.Load("script.vb")
Runner.TestMultiply()

AppDomain.Unload(ScriptDom)
Thats it, Thats all the bits of information I've collected the last few weeks, fermented and distilled for your enjoyment.
Advertisement
Props to RipTorn, Washu, Rob Loach, Posters to This Thread and probably a couple of persons whom I've forgotten since they were the sources for most of this.

This topic is closed to new replies.

Advertisement