Skip to main content
ISSUE-209HighQuickCLICI/CD Variables

Workflow writes untrusted content to $GITHUB_ENV

Control: Workflow must not write untrusted content to $GITHUB_ENV· Config key: workflowMustNotWriteUntrustedContentToGitHubEnv

📋 What is this?

A workflow routes PR-author-controlled content into $GITHUB_ENV or $GITHUB_PATH — directly (echo "KEY=${{ github.event.* }}" >> $GITHUB_ENV), through an env:-bound shell variable, or through a heredoc/redirect whose body dereferences the value.

⚠️ Impact

Writing attacker-controlled content to $GITHUB_ENV creates an environment variable for every subsequent step in the job. A title containing newlines (foo\nMALICIOUS_PATH=...) can inject *multiple* env vars at once, including overriding $PATH.

🔧 How to fix

Binding the value through env: stops command injection (ISSUE-207) but not this: a newline still opens a second $GITHUB_ENV line and any value poisons $GITHUB_PATH. Base64-encode the untrusted value itself before writing it (so no newline survives) or wrap it in toJSON. If it's only needed within one step, keep it in env: and don't write to the sticky file at all.

✗ BeforeA multi-line PR title escapes the `TITLE=` assignment into a free-form $GITHUB_ENV write.
# .github/workflows/triage.yml — ❌ Untrusted into $GITHUB_ENV
on: pull_request_target
jobs:
triage:
runs-on: ubuntu-latest
steps:
- run: echo "TITLE=${{ github.event.pull_request.title }}" >> $GITHUB_ENV
✓ AfterBase64 ensures the value is one line; consumers decode explicitly.
# .github/workflows/triage.yml — ✅ Pre-bind + base64
on: pull_request_target
jobs:
triage:
runs-on: ubuntu-latest
steps:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
encoded=$(printf '%s' "$PR_TITLE" | base64 -w0)
echo "TITLE_B64=$encoded" >> $GITHUB_ENV

💡 Tips

  • Use the same trick for $GITHUB_OUTPUT and $GITHUB_PATH.
  • If you only need the value within one step, env: is enough — don't write to $GITHUB_ENV at all.
  • A fixed heredoc delimiter (<<EOF) doesn't help — the body still lands in $GITHUB_ENV verbatim; randomise the delimiter or base64-encode the value.
  • base64 must wrap the untrusted value itself; a base64 elsewhere on the line (e.g. $(date | base64)) does not neutralise a raw $VAR.

⚙️ Configuration

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

github:
  controls:
    workflowMustNotWriteUntrustedContentToGitHubEnv:
      enabled: true

See the CLI documentation for the full configuration reference.