Safe PowerShell Action handlers

This page walks through a complete worked example of a PowerShell handler — the freeform SMS Action wired for a Windows host — and explains the safety patterns line by line. The script is also shipped at examples/actions/windows/sms-freeform.ps1.

The general modes-and-contract overview lives at Actions: handler safety; read it first if you haven't.

SMS as a freeform PowerShell Action

The Action sender writes:

@@482910#sms +15555551212 hello there

graywolf invokes:

powershell.exe -NoProfile -File C:\graywolf\sms-freeform.ps1 sms KE0XYZ true "+15555551212 hello there"

Here is the complete script (also at examples/actions/windows/sms-freeform.ps1):

# sms-freeform.ps1 — Send an SMS via Twilio. Freeform Action variant.

# Strict mode catches typos, uninitialized variables, and out-of-bounds
# indexing. Stop on any cmdlet error so failures don't get swallowed.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Positional args from the runner. Only $payload is used here.
$action      = $args[0]
$sender      = $args[1]
$otpVerified = $args[2]
$payload     = $args[3]

if (-not $payload) {
    [Console]::Error.WriteLine("payload missing")
    exit 64
}

# Validate format AND split into number + message in one regex match.
# Named groups: 'num' = E.164 number, 'msg' = message body.
$rx = '^(?<num>\+[1-9][0-9]{6,14})\s+(?<msg>.+)$'
$match = [regex]::Match($payload, $rx)
if (-not $match.Success) {
    [Console]::Error.WriteLine("expected '+<E164> <message>'")
    exit 64
}
$number  = $match.Groups['num'].Value
$message = $match.Groups['msg'].Value

# Defense in depth: graywolf already strips control characters, but
# re-check so this script stays safe if the operator widens the regex.
# \p{Cc} is the Unicode "Other, Control" category.
if ($message -match '\p{Cc}') {
    [Console]::Error.WriteLine("message contains control characters")
    exit 65
}
if ($message.Length -lt 1 -or $message.Length -gt 160) {
    [Console]::Error.WriteLine("message length out of range (1..160)")
    exit 65
}

# Required Twilio credentials. Fail fast with a clear message if unset.
foreach ($v in 'TWILIO_ACCOUNT_SID','TWILIO_AUTH_TOKEN','TWILIO_FROM') {
    if (-not (Get-Item "env:$v" -ErrorAction SilentlyContinue)) {
        [Console]::Error.WriteLine("$v not set")
        exit 1
    }
}

$sid  = $env:TWILIO_ACCOUNT_SID
$tok  = $env:TWILIO_AUTH_TOKEN
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${sid}:${tok}"))
$headers = @{ Authorization = "Basic $auth" }
$uri = "https://api.twilio.com/2010-04-01/Accounts/$sid/Messages.json"

# Hashtable -Body causes Invoke-RestMethod to URL-encode each value
# correctly. Never build the request body via string concatenation.
$form = @{
    From = $env:TWILIO_FROM
    To   = $number
    Body = $message
}

try {
    $resp = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers `
        -Body $form -TimeoutSec 8
    if ($resp.sid) {
        "sent $($resp.sid)"
        exit 0
    }
} catch {
    [Console]::Error.WriteLine("twilio rejected: $($_.Exception.Message)")
    exit 1
}

[Console]::Error.WriteLine("twilio rejected: no sid in response")
exit 1

Explanation

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

The PowerShell equivalent of set -euo pipefail. Set-StrictMode -Version Latest turns reading an undefined variable, calling a non-existent property, and out-of-range index access into hard errors — so a typo like $payloud fails loudly instead of being silently $null. $ErrorActionPreference = 'Stop' upgrades non-terminating cmdlet errors to terminating ones, so the first failure halts the script. Both belong at the top of every PowerShell handler.

$match = [regex]::Match($payload, '^(?<num>\+[1-9][0-9]{6,14})\s+(?<msg>.+)$')

One .NET regex match validates the format and splits the payload in a single step. The pattern reads:

If the match fails, $match.Success is $false and the script exits before any value is bound. Like the bash variant, this is pure in-process string matching: no Invoke-Expression, no subshell, no command substitution. Bytes in $payload are bytes the regex tries (and fails) to match — they cannot become PowerShell.

$number  = $match.Groups['num'].Value
$message = $match.Groups['msg'].Value

After the previous successful [regex]::Match against the ^(?<num>\+[1-9][0-9]{6,14})\s+(?<msg>.+)$ pattern, the Match object's Groups collection holds the captures. Indexing by name (['num'], ['msg']) is more durable than indexing by position because adding a non-capturing group later won't shift the indices. Reading the values is purely in-process.

if ($message -match '\p{Cc}') { ... }

\p{Cc} is the Unicode "Other, Control" category — the .NET regex equivalent of POSIX [:cntrl:]. graywolf's sanitizer already strips control characters for freeform Actions, but checking again here keeps the script self-contained.

$form = @{ From = $env:TWILIO_FROM; To = $number; Body = $message }
Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $form -TimeoutSec 8

When -Body is a hashtable on a POST/PUT, Invoke-RestMethod serializes it as application/x-www-form-urlencoded and URL-encodes each value for us. Critically, this means we never construct the request body by string-concatenation — an & in $message can't smuggle extra form fields, and a " can't break out of a quoted value. This is the PowerShell counterpart of curl's --data-urlencode.

Anti-patterns to avoid in PowerShell handlers

Anti-patternAttack it enables
Invoke-Expression $payload (or iex) Re-parses the payload as PowerShell — full code injection.
cmd.exe /c "send.exe $payload" Same as eval, but with cmd's own quoting traps.
Start-Process send.exe -ArgumentList $payload (string) String form of -ArgumentList re-splits on whitespace and re-parses quotes; pass an array instead.
Invoke-RestMethod -Body "From=$from&To=$to" String concatenation: & in the value smuggles extra form fields.
Omitting Set-StrictMode Typoed variable names silently expand to $null, bypassing validation.
Invoke-RestMethod $url with $url from the payload SSRF — the payload becomes a URL the handler fetches.

Run it through PSScriptAnalyzer

Before deploying any PowerShell Action handler, run:

Invoke-ScriptAnalyzer -Path .\your-handler.ps1

PSScriptAnalyzer flags the most common PowerShell footguns — unapproved verbs, unused variables, plain-text passwords, Invoke-Expression on user input. Fix what it flags; don't disable rules.