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:
- Demonstrate an alternative way of hosting PowerShell (namely exposing it as a JSON-emitting, REST-like API).
- 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
- Allow clients to specify PowerShell pipelines as part of the URL using the URL parts as the commands in the pipeline (e.g. Get-Command | Select-Object Name would be http://foo.com/Get-Command/Select-Object Name).
- Only allow clients to run whitelisted commands and PowerShell language features. Allow this to be specified via a PowerShell script on the server a-la PowerShell remoting’s custom session configuration initialization scripts.
- Automatically output the result in a web-friendly format, but allow clients to override the serialization output (e.g. XML, JSON, etc) by specifying a query string (e.g. http://foo.com/Get-Command/Select-Object Name?format=json)
- It has to take less time to implement than to write this blog post.
Examples of How We’d Use It
To call: Get-Command | Select-Object –First 1 Name,CommandType
- Pass this URL: “http://foo.com/Get-Command/Select-Object –First 1 Name,CommandType“
- Here’s what you get: <table><colgroup><col/><col/></colgroup><tr><th>Name</th><th>CommandType</th></tr><tr><td>ConvertTo-Html</td><td>Cmdlet</td></tr></table></body></html>
To get the output of the following as JSON: Get-Command | Select-Object –ExpandProperty Name
- Pass this URL: “http://foo.com/Get-Command/Select-Object –ExpandProperty Name?format=json”
- Here’s what you get: [ "ConvertTo-Html", "ConvertTo-JSON", "ConvertTo-Xml", "Get-Command", "Get-Help", "Get-Process", "Out-String", "Select-Object"]
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!