Monday, August 22, 2011

Exposing PowerShell as a JSON-Emitting, REST-Like Web Service

PowerShell remoting is pretty amazing. A perusal of Ravikanth Chaganti’s well-written (and free) eBook, Layman’s guide to PowerShell 2.0 remoting, will give you a feel of how powerful and well thought out it is.

But recently I was in a position at work where I needed to expose a PowerShell module I wrote to non-Windows technologies (primarily driven by coworkers that are Python/PHP fanboys that will work with Microsoft technologies only when forced to…and even then prefer to do it at arms-length in hazmat suits).

In my opinion, the architecturally sound way to do this would be to write a REST web service that completely abstracts away the fact that it’s calling into PowerShell. I didn’t have time to do this…particularly for something that was to be used internally as a test tool and to be implemented as a favor to the “hazmats.”

Another way to do this (as pointed out to me by a well-respected community member) would be to have clients call into Windows’ built-in, SOAP-based WS-Management service to invoke PowerShell as described here. Unfortunately, I think this solution would get me tarred and feathered by my prospective users due to the “overhead” of a SOAP-based protocol.

In my mind, the ideal solution given the circumstances and time constraints would be to allow these non-Windows clients to call PowerShell commands by specifying them as part of the URL and getting the results back as JSON (or whatever serialization format kids are using these days). Said solution is what I implemented, so I figured I’d share it for demonstration purposes.

The goal of this post is two-fold:

  1. Demonstrate an alternative way of hosting PowerShell (namely exposing it as a JSON-emitting, REST-like API).
  2. Demonstrate how one could use PowerShell's built-in ability to lock down a runspace to not only expose a set of white-listed commands, but even restrict its language capabilities to disallow arbitrary script execution (i.e. prevents crazy stuff from being submitted like "cmd.exe /c rmdir c:\windows /s /q" or "while($true){}")

!!!WARNING!!!

I highly discourage using this solution in a production setting.

If you're crazy enough to actually consider using it, use it at your own risk!

And for the love of Snover, throw authentication in front of it, and DOT NOT expose it on the intertubes.

You've been warned!


What We Want It To Do

Examples of How We’d Use It

To call: Get-Command | Select-Object –First 1 Name,CommandType

To get the output of the following as JSON: Get-Command | Select-Object –ExpandProperty Name

Example of How We’d Configure It and Lock It Down

Here’s an example the server-side initialization script to configure the web service to explicitly expose what we want and no more:

# Specify commands we want to allow
$allowedCommands = @(
"Get-Command"
"Get-Help"
"Get-Process"
"Select-Object"
"ConvertTo-Html"
"ConvertTo-Xml"
"Out-String"
)

# Import a JSON module and expose its commands, too
Import-Module (Join-Path (Split-Path $MyInvocation.MyCommand.Path) JSON.psm1)
$allowedCommands += @(Get-Command -Module JSON | Select-Object -ExpandProperty Name)

# Hide all commands save the ones we want to expose
$ExecutionContext.SessionState.Applications.Clear()
$ExecutionContext.SessionState.Scripts.Clear()
Get-Command | Where-Object { $allowedCommands -notcontains $_.Name } | ForEach-Object { $_.Visibility = "Private" }

# Configure the format-query-string-to-format-command variable
$ResponseFormatters = @{
default="ConvertTo-Html"
json="ConvertTo-JSON"
xml="ConvertTo-Xml -As string"
html="ConvertTo-Html"
raw=""
}

# Restrict PowerShell's language features
# See: http://msdn.microsoft.com/en-us/library/system.management.automation.pslanguagemode(v=vs.85).aspx
$ExecutionContext.SessionState.LanguageMode="RestrictedLanguage"

How We’d Implement It



  • Host an implemention of IHttpHandler in IIS that:


    • Hosts PowerShell
    • Invokes the server-side initialization script when creating a new PowerShell runspace
    • Extracts the file segment of a URL, strips off the extension, and passes the rest as a URL-decoded string to the PowerShell pipeline.
    • Reads an optional “format” query string to determine the command that will format the output. The command is discovered by looking up the value of the query string in a hashtable defined by the initialization script using a well-known PowerShell variable name.
    • If session state is available, cache the runspace as a session variable.

  • Implement an IHttpModule to peform the URL rewriting (it’s easy enough to write one specific to this scenario rather than using a general purpose rewriter in the interest of minimizing dependencies).


    • Replaces the URL separator “/” with a PowerShell pipe “|”, and rewrites the URL as a file segment so the IHttpHandler implementation can recognize it.

Here Comes the Science



IHttpModule/IHttpHandler Source Code
using System;
using System.Collections;
using System.Collections.ObjectModel;
using System.Configuration;
using System.Globalization;
using System.IO;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Text;
using System.Threading;
using System.Web;
using System.Web.SessionState;

namespace PowerShellHttpHandler
{
public sealed class PowerShellHttpHandler : IHttpHandler, IHttpModule, IRequiresSessionState
{
private const string InitializationScriptPath = "~/InitializationScript.ps1";
private const string ErrorFormat = "<error>{0}</error>";
private const string OutputFormatParameter = "format";
private const string DefaultOutputFormatValue = "default";
private const string ResponseFormattersVariableName = "ResponseFormatters";
private const string PowerShellHttpHandlerFileExtensionAppSetting = "PowerShellHttpHandlerFileExtension";
private const string DefaultPowerShellHttpHandlerFileExtension = ".command";

private string _powershellHttpHandlerFileExtension;

#region IHttpModule

public void Init(HttpApplication context)
{
_powershellHttpHandlerFileExtension =
ConfigurationManager.AppSettings[PowerShellHttpHandlerFileExtensionAppSetting] as string ??
DefaultPowerShellHttpHandlerFileExtension;

context.BeginRequest += (s, e) => RewriteUrl((HttpApplication)s);
}

private void RewriteUrl(HttpApplication httpApplication)
{
var absoluteUrlPath = httpApplication.Request.Url.AbsolutePath;

// If the URL already ends with the handler file extension, let it through as-is
if (absoluteUrlPath.EndsWith(_powershellHttpHandlerFileExtension, StringComparison.OrdinalIgnoreCase))
{
return;
}

// Treat the chunks of the URL path as commands in a PowerShell pipeline
// e.g. /Get-Command/Select-Object%20Name/ becomes Get-Command | Select-Object%20Name
var pipelineCommands = absoluteUrlPath
.Split(new char[] { Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);

// Rebuild the URL as a "file" ending with the ".command" extension
// whose root is the original virtual root
var stringBuilder = new StringBuilder("~/");
for (int i = 0; i < pipelineCommands.Length; ++i)
{
stringBuilder.Append(pipelineCommands[i]);

if (i + 1 < pipelineCommands.Length)
{
stringBuilder.Append('|');
}
}

// Append the HTTP handler file extension
stringBuilder.Append(_powershellHttpHandlerFileExtension);

// Add the query string back to the URL
stringBuilder.Append(httpApplication.Request.Url.Query);

httpApplication.Context.RewritePath(stringBuilder.ToString());
}

public void Dispose()
{
// no-op
}

#endregion

#region IHttpHandler

public bool IsReusable
{
get { return true; }
}

public void ProcessRequest(HttpContext context)
{
// Lock the runspace for the current session; PowerShell is single-threaded
using (var runspaceSessionLock = new RunspaceSessionLock(context))
{
// Extract the command from the context
var command = new StringBuilder(GetCommand(context));

// If there's a format command, add it to the end of the pipeline
var formatCommand = GetFormatCommand(context.Request, runspaceSessionLock.Runspace);
if (!string.IsNullOrEmpty(formatCommand))
{
command.Append(" | ");
command.Append(formatCommand);
}

using (var pipeline = runspaceSessionLock.Runspace.CreatePipeline(command.ToString()))
{
Collection<PSObject> results = null;
try
{
results = pipeline.Invoke();
}
catch (Exception e)
{
// If this is a critical exception, allow it to bubble up as a server error...
if (IsCriticalException(e))
{
throw;
}

// ...otherwise, let the client know an error occurred
context.Response.Write(string.Format(CultureInfo.InvariantCulture, ErrorFormat, e.Message));
}

// If we successfully ran the pipeline and have results, write them to the response
if (null != results)
{
foreach (var result in results)
{
context.Response.Write(result.ToString());
}
}
}
}
}

#endregion

private string GetCommand(HttpContext context)
{
// Get the URL path relative to the current web app, and trim off the leading ~ and /
// e.g. ~/Get-Command%20%7C%20Select-Object%20Name.command becomes Get-Command%20%7C%20Select-Object%20Name.command
var ret = context.Request.AppRelativeCurrentExecutionFilePath.TrimStart('~', '/');

// Trim off the handler extension
// e.g. Get-Command%20%7C%20Select-Object%20Name.command becomes Get-Command%20%7C%20Select-Object%20Name
var i = ret.LastIndexOf('.');
if (-1 < i)
{
ret = ret.Substring(0, i);
}

return context.Server.UrlDecode(ret);
}

private string GetFormatCommand(HttpRequest request, Runspace runspace)
{
var ret = string.Empty;

// See if the client specified the output format parameter...if it didn't, just use the default
// value
var format = request.QueryString[OutputFormatParameter] as string ?? DefaultOutputFormatValue;
format = format.ToLowerInvariant();

// See if the initialization script defined a hashtable of response formatters
// If it did, look up the command that will perform the output formatting by using
// the client-specified output format value as the key
var responseFormatters = runspace.SessionStateProxy.GetVariable(ResponseFormattersVariableName) as Hashtable;

if (null != responseFormatters)
{
if (responseFormatters.ContainsKey(format))
{
var tmp = responseFormatters[format];
if (null != tmp)
{
ret = tmp.ToString();
}
}
}

return ret;
}

private static bool IsCriticalException(Exception ex)
{
return ex is NullReferenceException ||
ex is StackOverflowException ||
ex is OutOfMemoryException ||
ex is ThreadAbortException ||
ex is ExecutionEngineException ||
ex is IndexOutOfRangeException ||
ex is AccessViolationException;
}

/// <summary>
/// Used to manage a PowerShell's runspace session's lifetime
/// </summary>
private sealed class RunspaceSessionLock : IDisposable
{
private const string SessionRunspaceKey = "SessionRunspaceKey";
private const string SessionRunspaceSyncRootKey = "SessionRunspaceSyncRootKey";

private object _syncRoot;
private HttpContext _context;

public RunspaceSessionLock(HttpContext context)
{
EnsureRunspace(context);

_context = context;

Monitor.Enter(_syncRoot);
}

public Runspace Runspace { get; private set; }

private void EnsureRunspace(HttpContext context)
{
bool createNewRunspace = true;

// See if there's a runspace stuffed in the HTTP session state
// If so, use it
if (null != context.Session)
{
if (null != context.Session[SessionRunspaceKey])
{
this.Runspace = (Runspace)context.Session[SessionRunspaceKey];
_syncRoot = context.Session[SessionRunspaceSyncRootKey];
createNewRunspace = false;
}
}

// We didn't find an existing runspace; create a new one
if (createNewRunspace)
{
this.Runspace = CreateRunspace(context.Server.MapPath(InitializationScriptPath));
_syncRoot = new object();

// If we have an HTTP session, stuff the runspace into it
if (null != context.Session)
{
context.Session[SessionRunspaceKey] = this.Runspace;
context.Session[SessionRunspaceSyncRootKey] = _syncRoot;
}
}
}

private static Runspace CreateRunspace(string initializationScript)
{
// Allow locally installed scripts to run in this runspace
Environment.SetEnvironmentVariable("PSExecutionPolicyPreference", "RemoteSigned");

// Always stop the pipeline when an error is encountered since expressing the error
// stream over HTTP will complicate the interface
var initialSessionState = InitialSessionState.CreateDefault();
initialSessionState.Variables.Add(new SessionStateVariableEntry("ErrorActionPreference", "Stop", string.Empty));

var ret = RunspaceFactory.CreateRunspace(initialSessionState);
ret.Open();

using (var powershell = PowerShell.Create())
{
// Dot-source the initialization script
powershell.Runspace = ret;
powershell.AddScript(". " + initializationScript);
powershell.Invoke();
}

return ret;
}

public void Dispose()
{
Monitor.Exit(_syncRoot);
if (null == _context.Session)
{
this.Runspace.Dispose();
}
}
}
}
}



Web config to register the IHttpModule/IHttpHandler implementations (specific to IIS 7)
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<handlers>
<add name="PowerShellHttpHandler (RDG)"
path="*.command" verb="*"
type="PowerShellHttpHandler.PowerShellHttpHandler"
resourceType="Unspecified" preCondition="integratedMode" />
</handlers>
<modules>
<add name="PowerShellHttpHandler (RDG)"
type="PowerShellHttpHandler.PowerShellHttpHandler" />
</modules>
</system.webServer>
<system.web>
<!-- Optional: turn session state on to cache runspaces as part of the session -->
<sessionState mode="Off" timeout="2" />
</system.web>
</configuration>

How To Make It Go



  • Compile the code above as a .NET 3.5 DLL.
  • Configure IIS 7 with ASP.NET.
  • Copy the web.config and desired initialization script into the root directory of a web site.
  • Copy the IHttpModule/IHttpHandler DLL into a subdirectory called “bin” off of the web site directory root.
  • Highly recommended: throw authentication in front of it!

Sunday, February 13, 2011

C# "internal" access modifier: I don’t think that means what you think that means

I’ve seen the “internal” access modifier used and abused on countless occasions, so I’d like to take the time disseminate its actual meaning along with ways it can be used.
 
Here’s the official documentation on the subject.
 
Here’s the short version :
  • The “internal” access modifier hides types and methods to anybody referencing your assembly; within the same assembly it is the same as “public”
  • Leaving off the “public” access modifier at the type level (i.e. class, struct or interface) implicitly means “internal”
Practical use-case: if you’re building a .NET assembly that is intended to be an SDK to a third party, but you need helper types and methods within your assembly that you don’t want to expose to the outside world, the use of the “internal” access modifier is justified.
 
Note that if your SDK spans multiple assemblies that need access to each others’ internal types and methods, use the InternalsVisibleTo assembly-level attribute.
 
Now that the record’s straight, here’s a couple of recipes you can accomplish with the “internal” access modifier beyond simple hiding of types/methods:
 
Problem
You want to provide a set of public classes that derive from a public base class, but you want to disallow people external to your assembly from deriving their own classes from the base class…OR, you want a class publically available but you want to disallow external components from instantiating the class directly (presumably because you want to provide a factory method that does something non-trivial).
Solution
Define an internal constructor.
Example
public class Foo
{
internal Foo()
{
}
}

// Deriving from Foo can be done within the assembly.
// Will result in a build failure outside of the assembly
public class Bar : Foo
{
}

// Can be instantiated within the assembly
// Will result in a build failure outside of the assembly
Foo foo = new Foo();

// Works both internal and external to the assembly
Bar bar = new Bar();


Problem

You want to define an interface that you want to restrict to code within your assembly, but want a public class that implements that interface while hiding its implementation.

Solution

Define an internal interface and have classes implement the interface explicitly.

Example
internal interface IFoo
{
string Message { get; }
}

public class Bar : IFoo
{
// Explicit interface implementation
string IFoo.Message
{
get
{
return "For your eyes only";
}
}
}

// Can be instantiated internal and external to the assembly
Bar bar = new Bar();

// Will result in a build failure both internal and external to the assembly
Console.WriteLine(bar.Message);

// Works only internal to the assembly
// Will result in a build failure outside of the assembly
IFoo foo = (IFoo)bar;
Console.WriteLine(foo.Message);