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:
^— anchor at start.(?<num>\+[1-9][0-9]{6,14})— named groupnum: an E.164 number. A literal+, a leading digit 1..9 (E.164 forbids0), then 6 to 14 more digits.\s+— one or more whitespace separators between the number and the message.(?<msg>.+)— named groupmsg: the message body, at least one character.$— anchor at end.
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-pattern | Attack 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.