Why is this an issue?

GitHub Actions workflows can access various sources of untrusted data through the GitHub context, such as:

When this untrusted data is directly interpolated into shell commands using GitHub’s expression syntax (${{ …​ }}), it can lead to script injection vulnerabilities. An attacker who can control the content of these data sources (for example, by creating a pull request with a malicious title) can potentially execute arbitrary commands on the runner.

Understanding expression expansion

It’s important to understand that GitHub Actions expressions (${{ …​ }}) are expanded before the shell script is executed. This expansion happens at the workflow processing stage, not within the shell itself. This means:

  1. GitHub Actions first evaluates the expression and replaces it with the actual value
  2. The resulting string (which may contain shell metacharacters) is then passed to the shell
  3. The shell receives and executes the already-expanded string as part of the script

Even seemingly safe assignments like pr_title="${{ github.event.pull_request.title }}" are vulnerable because the expansion happens first. For example, if an attacker creates a pull request with a title containing "; rm -rf / #, the workflow will expand the expression to:

pr_title=""; rm -rf / #"

The shell will then interpret this as:

  1. An assignment: pr_title="" (assigns empty string)
  2. A command separator: ;
  3. An arbitrary command: rm -rf /
  4. A comment: # (hides the remaining quote)

This demonstrates that any use of untrusted data in GitHub expressions within a run block is dangerous, regardless of whether it appears to be a simple assignment or a direct command.

What is the potential impact?

The consequences of successful script injection attacks in GitHub Actions can be severe and far-reaching:

Information disclosure

Attackers can extract sensitive information from the runner environment, including:

System compromise

Successful command injection allows attackers to:

Supply chain attacks

Compromised workflows can be used to:

Repository manipulation

Attackers may be able to:

The impact is particularly severe because GitHub Actions runners often have elevated privileges and access to sensitive resources, making them attractive targets for attackers.

How to fix it

To prevent command injection in GitHub Actions workflows, avoid directly interpolating untrusted data into shell commands. Instead, use environment variables to safely pass untrusted data without risk of code execution.

The recommended approach is to assign untrusted data to environment variables using the env key, then reference these variables using standard shell variable syntax ($VARIABLE_NAME or ${VARIABLE_NAME}) rather than GitHub’s expression syntax (${{ …​ }}).

Code examples

Noncompliant code example

The following GitHub Action is vulnerable to command injections as it uses untrusted input directly in a run command:

name: Example

on:
  pull_request:
    branches: [ main ]

jobs:
  main:
    runs-on: ubuntu-latest

    steps:
      - name: Example Step
        run: |
          pr_title="${{ github.event.pull_request.title }}" # Noncompliant: expansion happens before shell execution
          echo "PR title: $pr_title"

If an attacker creates a pull request with a title containing "; rm -rf / #, the expression expansion will transform the assignment into pr_title=""; rm -rf / #", which the shell will execute as multiple commands, not just an assignment.

Workflow inputs of type string (which is the default type) are also vulnerable when expanded directly in shell commands:

name: Example

on:
  workflow_dispatch:
    inputs:
      package_name:
        description: 'Package name to install'
        required: true
        type: string

jobs:
  main:
    runs-on: ubuntu-latest

    steps:
      - name: Install package
        run: |
          apt-get install ${{ inputs.package_name }} # Noncompliant: string input expanded directly

Compliant solution

Use environment variables to safely pass untrusted data:

name: Example

on:
  pull_request:
    branches: [ main ]

jobs:
  main:
    runs-on: ubuntu-latest

    steps:
      - name: Example Step
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "PR title: $PR_TITLE"

To securely use workflow inputs, assign them to environment variables just like any other untrusted data. Alternatively, change the input type from string to choice with predefined options. This restricts user input to safe values controlled by the workflow author:

name: Example

on:
  workflow_dispatch:
    inputs:
      package_name:
        description: 'Package name to install'
        required: true
        type: choice
        options:
          - nginx
          - apache2
          - postgresql

jobs:
  main:
    runs-on: ubuntu-latest

    steps:
      - name: Install package
        run: |
          apt-get install ${{ inputs.package_name }}

How does this work?

The key difference lies in when and how the untrusted data is processed:

When the shell evaluates $PR_TITLE, it performs variable expansion (not expression expansion), which treats the value as a string literal. Even if the value contains shell metacharacters, they remain as literal characters and are not interpreted as commands. This separation ensures that malicious shell syntax in the untrusted data cannot be interpreted as commands, effectively neutralizing injection attempts.

Pitfalls

Fixing vulnerabilities only in the main branch

It is important to note that fixing this vulnerability requires updating the workflow files in all existing branches of the repository. GitHub Actions workflows can be triggered from any branch, and an attacker could potentially target vulnerable workflows in older or feature branches. A comprehensive fix should include updating all branches that contain the vulnerable workflow patterns.

Accessing environment variables through GitHub expressions

While storing untrusted data in environment variables is the correct approach, it is important to reference them properly. Simply putting the data in an environment variable but then accessing it through GitHub’s expression syntax (${{ env.VARIABLE_NAME }}) will still result in command injection vulnerabilities.

The following example demonstrates this common mistake:

name: Example

on:
  pull_request:
    branches: [ main ]

jobs:
  main:
    runs-on: ubuntu-latest

    steps:
      - name: Example
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "PR title: ${{ env.PR_TITLE }}" # Noncompliant

Going the extra mile

Security hardening

Beyond fixing the immediate security issue, consider implementing additional security measures:

Advanced input validation

For enhanced security, implement comprehensive input validation:

Resources

Documentation

Standards

Related rules