Skip to content

PATTERN Cited by 1 source

Environment-variable interpolation for bash

Problem: GitHub Actions script injection — attacker-controllable ${{ github.event.* }} fields (PR title, branch name, filenames, issue bodies) get string-interpolated into a shell script before the shell parses it. Shell metacharacters in the attacker's input execute as commands.

Pattern: Route attacker-controllable fields through an env: block. Reference them as "$VAR" in the bash snippet. Shell-level quoting + one layer of indirection disarms the injection primitive.

Shape

Vulnerable:

- name: Handle PR
  run: |
    echo "PR title is: ${{ github.event.pull_request.title }}"

Input PR_TITLE="; rm -rf / #" expands mid-parse into a command-chaining payload.

Safe:

- name: Handle PR
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "The PR title is: $TITLE"

Input PR_TITLE="; rm -rf / #" is set as an environment variable — when bash reads $TITLE, it receives the literal string, which it treats as data (the value of an environment variable), not as code (a shell expression). Quoting "$TITLE" further protects against word-splitting if the string contains spaces.

This is GitHub's recommended mitigation.

Why this works

The key distinction is when the string is expanded:

  • ${{ ... }} expressions are expanded by GitHub Actions before bash starts. The attacker's string becomes part of the bash script source; shell metacharacters in the string are interpreted by the parser.
  • $VAR in bash is expanded by bash after the script is parsed. The attacker's string is always a single data value; shell metacharacters inside it are not re-parsed.

Quote the variable reference ("$TITLE") and the data is also protected from word-splitting / glob expansion.

Limits

  • Doesn't defend against jq / yq / python / node-within-run misinterpretation — those tools have their own injection surfaces.
  • Doesn't help if the attacker controls a filename and your step iterates over filenames with for f in $FILES where $FILES comes from a command-substitution that embeds the filename literally. In that case, use arrays and "${FILES[@]}" properly, or process filenames one-by-one via xargs -0 / null-separated lists.
  • Datadog's 2026-02-27 datadog-iac-scanner incident exploited this — the vulnerable workflow did:
    FILES="${{ steps.changed_files.outputs.files }}"
    
    The filename was the attacker-controlled field; it contained ${IFS}-obfuscated command substitution that expanded when bash parsed the line. The fix shape is the same: route files through env: and reference as "$FILES".

Seen in

Last updated · 200 distilled / 1,178 read