Commands/Get-Reptile.ps1
|
function Get-Reptile { <# .SYNOPSIS Reptile .DESCRIPTION Reptile - Read Evaluate Print Terminal Input Loop Editor .NOTES ## Reptile ### Read Evaluate Print Terminal Input Loop Editor - A Scaley Simple PowerShell Data REPL Command Lines can be scary. Websites feel much safer. Reptile gives you simple, scalable and safe web terminals. ### Installing and Importing We can install Reptile from the PowerShell Gallery: ~~~PowerShell Install-Module Reptile ~~~ Once installed, we can import it with: ~~~PowerShell Import-Module Reptile -PassThru ~~~ We can also clone the repository and import it from any directory: ~~~PowerShell git clone https://github.com/PowerShellWeb/Reptile cd ./Reptile Import-Module ./ -PassThru ~~~ ### Getting Started Once installed, we just run reptile: ~~~PowerShell reptile ~~~ This will start a simple terminal with no commands enabled. You can still 'run' a few things. `2+2` will equal `4`. "a" + "b" + "c" will be `abc`. Feel free to play around. Reptile runs in Restricted Language mode, and it's pretty restrictive. ## Simple, Scalable, Safe Reptile gives you simple, scalable and safe web terminals. ### Simple Reptile run PowerShell in a [data block](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542) This only allows whatever commands you choose, and does not allow loops, strong types, or methods. All a reptile really does is take input, create a data block, and call PowerShell. ### Scalable Reptile is built with a [HttpListener](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) and [PowerShell Thread Jobs](https://learn.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?wt.mc_id=MVP_321542). This makes Reptile simple to scale: Just launch more than one job. ### Safe Data statements are a constrained form of PowerShell that primarily process data. Data statements can also run any number of -SupportedCommands. Data statements cannot access most variables, use methods, reference most types, or loop. This makes them fairly ideal for a mostly safe REPL loop. If a command is not supported, it will not be run. This means that as long as no supported command allow arbitrary code injection, you are safe. However, if you ran `reptile -supportedCommand python`, then that would be a much more dangerous reptile to deal with. Which is why there are some additional safety measures. #### Additional Safety Measures ##### Local Loopback Port By default, reptile will run on a random local loopback port. This has three security benefits: 1. It does not require elevation to administrator 2. It does not open an external port 3. It is less predictable If you are running reptile locally as intended, you control which scripts you run, and they can run as you. If you choose to allow a live reptile instance, you are as safe as the commands the reptile supports. ##### AST Inspection Scripts that are not parsable as a data block will never be run. Additionally, if someone succeeds in the miracle of escaping syntax, and the AST is not a single data statement, it will not run. ##### Background Execution All data blocks will be evaluated in a background job. This is a trade off of performance for security. Responses will take longer than they would inline, but any potential data corruption is quite literally limited in scope. The background jobs cannot access the main server thread, and so have a much more difficult time escalating any potential jailbreaks. Additionally, because the responses are run in background _thread_ jobs, it limits the overall impact of each request, and thus service is harder to deny. ### Reptile Roadmap Reptile will Evolve. Reptile is a new project, and will grow and change with time. Implementation is subject to change. The next items on the Reptile Roadmap are: * Additional Protocol Support * JsonRPC * MCP * XRPC * New Examples * Better Variable Input * More Turtles (and other useful interactive tools) .EXAMPLE reptile .LINK https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes?wt.mc_id=MVP_321542 #> [Alias('Reptile','REPL','WebRepl','🦎','🐊')] param( # The name of specific reptile. # Will check the current directory and the reptile module directory for reptiles. # If a single reptile exists with that name, it will be run. [Alias('Species')] [string] $ReptileName, # If set, will spawn a new instance of the first matching `-ReptileName/-Species` [Alias('Hatch')] [switch] $Run, # The rootUrl of the server. # By default, a random loopback address. # Randomized loopback addresses are not exposed to the network, # and do not require running as admin. [Alias('ServerURL')] [string]$RootUrl= "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", # The list of supported commands [Alias('SupportedCommands')] [string[]] $SupportedCommand = @(), # The Reptile's Shell # This object will be rendered on GET requests. # It should be HTML. [PSObject] [Alias('Repl', 'WebRepl', 'Scales','Scale', 'Skin')] $Shell = @( if (Test-Path "./repl.html") { Get-Content ./repl.html } else { "<html><head><title>Reptile</title>" "<style>" "body {" @( "max-width: 100vw" "height: 100vh" "background-color: black" "color: white;" ) -join '; ' "}" ".repl-input-area {" @( "display: grid" "place-items: center" "place-content: center" "grid-template-areas: 'input' 'go' 'output'" "grid-template-rows: auto auto auto" "grid-template-columns: 1fr" ) -join '; ' "}" ".repl-input { grid-area: input; width: 100%; }" ".repl-go { grid-area: go; width: 50%;}" ".repl-output { grid-area: output; width: 100%; }" "</style>" "</head>" "<body>" "<form action='/' method='post'>" "<input class='repl-input' id='repl' name='command'></input>" "<input type='submit' value='go'></input>" "</form>" "</body>" "</html>" } ) -join [Environment]::NewLine, # The script used to initialize the reptile. [ScriptBlock] $Initialize = {}, # The number of reptiles to run. [Alias('EggCount')] [uint32] $NodeCount = 1 ) if ($ReptileName) { if (Test-Path $ReptileName) { return Get-Item $ReptileName } $foundReptiles = @( Get-Module Reptile | Split-Path | Get-ChildItem -Recurse -File -Filter *.ps1 | Where-Object Name -match '\p{P}Reptile\p{P}' | Where-Object Name -like "$ReptileName*" Get-ChildItem -Filter *.ps1 | Where-Object Name -match '\p{P}Reptile\p{P}' | Where-Object Name -like "$ReptileName*" ) # If we found reptiles and want to run them if ($foundReptiles -and $Run) { # Launch the script & $foundReptiles[0] return } $foundReptiles return } if ($SupportedCommand -match '^(?>Invoke-Expression|iex)$') { Write-Error "No. Invoke-Expression is unsafe. We will not support this." return } # Create a listener $httpListener = [Net.HttpListener]::new() $httpListener.Prefixes.Add($RootUrl) # and write a warning so that the user knows (and can click it open) Write-Warning "Listening on $RootUrl $($httpListener.Start())" # Make our IO object by packing our job input into a dictionary. $io = [Ordered]@{ HttpListener = $httpListener SupportedCommand = $SupportedCommand Shell = $Shell Initialize = $Initialize } # Every item in this dictionary becomes a variable in our job. # We will add IO to the return objects. # If we want things to be "hot-swappable", we can reference $IO # For example, we want to reply in a background job: $ReplyDefinition = { param([ScriptBlock]$dataBlock, $reply, [Collections.IDictionary]$Option) # We want to double check the data statement is the only thing if (-not ( ($dataBlock.Ast.EndBlock.Statements.Count -eq 1) -and ($dataBlock.Ast.EndBlock.Statements[0] -is [Management.Automation.Language.DataStatementAst]) )) { $reply.Close() return } $supportedCommand = $option.SupportedCommand # Another bit of "should not be possible" paranoia: # Double-check that the list of supported commands in the data block # matches our list of supported commands. $dataStatementAllows = $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace '^["'']' -replace '["'']$' if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { $reply.close() return } $out = if ($option.Out -is [ScriptBlock]) { $option.Out } else { { param($reply) begin { if (-not $reply.OutputStream) { throw "no output stream" ; return } $reply.ProtocolVersion = '1.1' $reply.SendChunked = $true } process { $in = $_ if ($in.OuterXml) { $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } elseif ($in.html) { $buffer = $OutputEncoding.GetBytes("$($in.html)") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } else { # or the stringification of the result. $buffer = $OutputEncoding.GetBytes("$in") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } } end { if ($reply.Close) { $reply.Close() } } } } # Then we want to try running the data block try { & $dataBlock *>&1 | & $out $reply } catch { # If anything went wrong, though it feels wrong, we want to respond with 200 $reply.StatusCode = 200 # so that the error is clear to an interactive user. $reply.Close($OutputEncoding.GetBytes("$($_)"), $false) } } $IO.ReplyDefinition = $ReplyDefinition $ServerDefinition = { param([Collections.IDictionary]$io) # First, let's unpack. $psvariable = $ExecutionContext.SessionState.PSVariable foreach ($key in @($io.Keys)) { if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } else { $psvariable.set($key, $io[$key]) } } # Now we want to declare several filters for various conditions # First up, let's handle how we error out filter errorOut { $err = $_ # If this is not an error, return. if ($err -isnot [Management.Automation.ErrorRecord]) { return } # Attempt to find the best error message $bestMessage = if ($err.Exception.InnerException.Message) { $err.Exception.InnerException.Message } elseif ($err.Exception.Message) { $err.Exception.Message } else { "$err" } # write out our error $err | Out-Host $err | Write-Error # and, ironically, say things are OK $reply.StatusCode = 200 # So we can show the user the error. $reply.Close($OutputEncoding.GetBytes("$bestMessage"), $false) } # Next let's define a command to construct our data block filter getDataBlock([string]$inputString) { try { # First we construct a script block. # If this fails, the code is invalid. $inputScriptBlock = [ScriptBlock]::Create($inputString) # data blocks give us an inline restricted language mode [ScriptBlock]::Create("data $( if ($SupportedCommand) { "-supportedCommand '$( # and we can support a limited set of commands. $SupportedCommand -replace "'","''" -join "','" )'"} ) {" + [Environment]::NewLine + $inputScriptBlock + [Environment]::NewLine + "}") } catch { # If we could not make this a data block $_ | errorOut continue nextRequest } } # Next, we define how we replace variables filter replaceVariable { param([string]$variableName, [string]$replacement) # Very permissive variable pattern: # variables can begin with: $prefixes = @( ':' # colons (logo style) '-{2}' # two dashes (css style) '\$' # dollar signs (PowerShell style) '@' # sql style / splatting style () ) $variablePattern = "(?>$($prefixes -join '|'))" + ([Regex]::Escape($variableName)) $in = $_ $in -replace $variablePattern, "'$( $replacement -replace # First sanitize each value "'","''" -join # then join into a list of constants "','" # and now we have multi-value type free variable support )'" # An expanded variable that _somehow_ escapes stringification # should be be caught by the data block. # This allowing us to support parameters without sacrificing safety. } filter getCommandAndInput { # Read our body $streamReader = [IO.StreamReader]::new($request.InputStream, $request.ContentEncoding) $inputString = $streamReader.ReadToEnd() $streamReader.Close() $streamReader.Dispose() # If we cannot parse the body, we'll pass it as a command. $inputParsed = $null $jsonRpc = $null $inputCopy = [Ordered]@{} # If the content type resembled json if ($request.ContentType -match '.+?/.{0,}json') { # try to parse it $inputParsed = try { $inputString | ConvertFrom-Json -AsHashtable} catch { # and error out if that did not work. $_ | errorOut continue nextRequest } # JSON rpc sends a method and parameters if ($inputParsed.jsonrpc -and $inputParsed.method ) { $jsonRpc = $inputParsed # Per the json rpc spec, without an id it is a notification if ($null -eq $jsonRpc.method.id) { $inputString # emit the input (thus notifying the server owner) $reply.Close() # and close the request continue nextRequest } $inputCopy.input = $jsonRpc.method foreach ($key in $jsonRpc.parameters.keys) { $inputCopy[$key] = $jsonRpc.parameters[$key] } } else { foreach ($key in $inputParsed.keys) { $inputCopy[$key] = $inputParsed[$key] } } } # If the content type looks like form data if ($request.ContentType -eq 'application/x-www-form-urlencoded') { # try to parse it try { $inputParsed = [Web.HttpUtility]::ParseQueryString($inputString) } catch { # and error out if that did not work. $_ | errorOut continue nextRequest } foreach ($key in $inputParsed.Keys) { $inputCopy[$key] = $inputParsed[$key] Write-Host "$key - $($inputCopy[$key])" } $reply.ContentType = 'text/html' } # If we have parsed the input, # then it's fairly simple to support variables. # (data blocks don't have variables, # but they guard against injection enough to support open-ended text input) if ($inputCopy.Count) { if (-not $inputCopy['Command']) { $err = Write-Error "No Command" -TargetObject $request *>&1 $err | errorOut continue nextRequest } $inputString = $inputCopy['Command'] foreach ($key in $inputCopy.Keys) { $inputString = $inputString | replaceVariable $key $inputCopy[$key] } } # and then write what was attempted and when. @( "$($request.RemoteAddr) $($request.httpMethod) $($request.Url) @ $([datetime]::Now)" ) | Write-Host -ForegroundColor Cyan # Now we try to make it into a data block $dataBlock = GetDataBlock $inputString # This last bit of healthy paranoia is done twice. # It may not even be possible, but, if, somehow someone managed to inject a _second_ # command, or, magically make it not a data block, if ( ($dataBlock.Ast.EndBlock.Statements.Count -ne 1) -or ($dataBlock.Ast.EndBlock.Statements[0] -isnot [Management.Automation.Language.DataStatementAst]) ) { # we want to write an error. $err = Write-Error "Unbalanced Injection Attempted @ $([datetime]::Now)" -Category SecurityError -TargetObject $request *>&1 $err | errorOut continue nextRequest } # Another bit of "should not be possible" paranoia: # Double-check that the list of supported commands in the data block # matches our list of supported commands. $dataStatementAllows = $dataBlock.Ast.EndBlock.Statements[0].CommandsAllowed -replace '^["'']' -replace '["'']$' if (($dataStatementAllows -join ',') -ne ($SupportedCommand -join ',')) { # we want to write an error. $Message = @( "Supported Commands Change Attempt @ $([datetime]::Now)." "Expected $SupportedCommand, got $DataStatementAllows" ) -join ' ' $err = Write-Error $Message -Category SecurityError -TargetObject $request *>&1 $err | errorOut continue nextRequest } } # Now that we have prepared all of our functions, # we have the main request loop. # Then listen for the next request :nextRequest while ($httpListener.IsListening) { $getContext = $httpListener.GetContextAsync() # (wait for short prime intervals, so we can cancel if we need to). while (-not $getContext.Wait(17)) { } $request, $reply = $getContext.Result.Request, $getContext.Result.Response # We will not be able to predict head requests if ($request.httpMethod -eq 'head') { # so tell the client that the content length is zero and close out. $reply.ContentLength = 0; $reply.Close() continue nextRequest } if ($request.httpMethod -eq 'get') { # If it's get, return the REPL $reply.ContentType = 'text/html' $replBytes = $OutputEncoding.GetBytes("$($io.Shell)") $reply.Close($replBytes, $false) continue nextRequest } # Any other verb we'll try to evaluate the body. # Of course, if there is no body if (-not $request.InputStream) { Write-Host "No input" -ForegroundColor Yellow $reply.ContentLength = 0 $reply.Close() # close out continue nextRequest # and continue to the next request. } $dataBlock = $null . getCommandAndInput # Now we can launch an inner thread job to run the script and reply. $replyJobParameters = @{ ScriptBlock=$ReplyDefinition ThrottleLimit=1kb ArgumentList=@( $dataBlock, $reply, [Ordered]@{ 'supportedCommand' = $SupportedCommand 'jsonrpc' = $jsonRpcParsed } ) InitializationScript=$Initialize } # Doing this makes the server more resilient, but will be slower than directly handling each request. Start-ThreadJob @replyJobParameters # Clean up any completed requests and continue on with the loop. Get-Job | Where-Object State -eq 'Completed' | Remove-Job -Force } } $JobParameters = @{ ScriptBlock = $ServerDefinition InitializationScript = $Initialize ThrottleLimit = 256 ArgumentList = $io Name = $RootUrl } $ForcePassThru = @{Force=$true;PassThru=$true} foreach ($nodeNumber in 1..$NodeCount) { # Our server is a thread job $reptileJob = Start-ThreadJob @JobParameters| # Output our job, Add-Member -NotePropertyMembers @{ # but attach a few properties first: HttpListener=$httpListener # * The listener (so we can stop it) IO=$IO # * The IO (so we can change it) Url="$RootUrl" # The URL (so we can easily access it). } @ForcePassThru # Pass all of that thru and return it to you. $reptileJob.pstypenames.add('Reptile') $reptileJob | Add-Member ScriptProperty -Name Shell -Value { return $io.Shell } -SecondValue { $io.Shell = $args -join [Environment]::NewLine } @ForcePassThru | Add-Member AliasProperty -Name Skin -Value Shell @ForcePassThru } } |