CVE case study

CVE-2026-61706: Pull Request Title Injection in Infracost Actions

A pull request title was inserted directly into a Bash run block, allowing GitHub Actions expression expansion to turn untrusted text into runner commands.

Severity
High (7.4)
Scoring
CVSS 4.0
Weakness
CWE-94
Affected
Official range: >=4.0.0, <4.2.0; the sink is directly confirmed in the 4.1.0 tag
Remediation state
Upgrade to 4.2.0 or use the updated v4 tag
Advisory published
13 Jul 2026

Official vectorCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

Why it matters

The action inserted ${{ github.event.pull_request.title }} directly into a Bash run block. GitHub expands that expression before Bash parses the generated script, so command substitution in an untrusted title can become runner code.

The impact follows the caller's workflow context. A normal fork-based pull_request run usually has no repository secrets and a read-only token. A same-repository pull request, pull_request_target workflow, or privileged self-hosted runner can make the same injection much more serious.

How I found it

I reviewed the composite action for GitHub expressions inserted directly into shell code. The context-derivation step assigned the pull request title with PR_TITLE="${{ github.event.pull_request.title }}" inside a Bash run block.

GitHub expands expression syntax before the runner invokes Bash. That means title text is not merely data inside an existing shell process; it becomes part of the script Bash parses. Command substitution can therefore execute before the assignment completes.

I reproduced that parser boundary with a synthetic local marker, checked which workflow contexts would give the injected command meaningful permissions, and verified that the patch moves the untrusted value into env before quoting it as a shell variable.

Root cause

A GitHub expression was used as shell source text instead of being passed through an environment variable. Shell quoting written around the expression did not make the preprocessed script safe.

The advisory's package constraint is >=4.0.0, <4.2.0, while the public 4.1.0 tag directly confirms the vulnerable sink. Upgrade decisions should follow the official range.

Source-to-sink trace

  1. 01
    Attacker-controlled sourcegithub.event.pull_request.title

    A pull-request author controls the title supplied to the action.

  2. 02
    Workflow preprocessingdiff/action.yml · Derive context

    The expression is expanded into the run script before Bash starts.

  3. 03
    Shell parse boundaryPR_TITLE assignment

    Command substitution in the expanded title is parsed as shell syntax rather than retained as literal data.

  4. 04
    Runner impactCalling workflow job

    Injected commands inherit the job's token, credentials, filesystem, and runner access, subject to that workflow's actual permissions.

Safe proof of concept

Prerequisites

  • Bash in a temporary local directory.
  • No GitHub account, workflow, repository secret, or hosted runner is required.

Step-by-step reproduction

  1. Save and run the local script below. It models GitHub's expression expansion by constructing the final script text before invoking Bash.
  2. Observe that the vulnerable model evaluates the synthetic command substitution and prints its result.
  3. Run the environment-variable control. The same payload is read after Bash has parsed the script, so it remains literal text.
  4. Compare the vulnerable and fixed YAML extracts. The fixed action places the expression under env and assigns from the quoted environment variable.
  5. Inspect the 4.2.0 source and confirm that the pull request title appears under env, not as an expression inside the Bash script.

Vulnerable and fixed action pattern

# Vulnerable
run: |
  PR_TITLE="${{ github.event.pull_request.title }}"

# Fixed
env:
  EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
run: |
  PR_TITLE="$EVENT_PR_TITLE"

Offline parser-boundary proof

payload='$(printf SYNTHETIC_INJECTION)'

vulnerable_script=$(printf 'PR_TITLE="%s"; printf "%%s\n" "$PR_TITLE"' "$payload")
bash -c "$vulnerable_script"

EVENT_PR_TITLE="$payload" \
  bash -c 'PR_TITLE="$EVENT_PR_TITLE"; printf "%s\n" "$PR_TITLE"'

Expected local output

SYNTHETIC_INJECTION
$(printf SYNTHETIC_INJECTION)

Expected evidence

  • The vulnerable model prints SYNTHETIC_INJECTION, showing that command substitution was parsed and executed.
  • The environment-variable control prints $(printf SYNTHETIC_INJECTION) literally.
  • No secret or external service is used; the proof isolates only the preprocessing and Bash parsing boundary.
Negative control

Pass the title through env and quote $EVENT_PR_TITLE in the shell. Bash parses the script before reading that value, so shell metacharacters in the title remain data.

Fixed-version re-test

In 4.2.0, the pull request title appears under env and no longer appears as a GitHub expression inside the run script. Repeat the synthetic proof with the fixed pattern and require the payload to remain literal.

Impact

A pull-request author could execute commands with the permissions and exposed credentials of the job running the action. Repository modification or secret theft requires a workflow context that actually grants those capabilities.

Outside-collaborator approval and the default restrictions on fork pull requests reduce exposure, but they do not neutralize the unsafe script construction.

Fix and retest

Upgrade to 4.2.0 or use the moving @v4 tag. The patch passes the title through env and reads it as a quoted shell variable, keeping the value out of the generated script source.

If a workflow is pinned to 4.1.0 or a vulnerable commit, update the pin. Keep untrusted pull requests on pull_request, with no secrets and the smallest token permissions, and require approval for outside contributors.

Engineering lesson

In GitHub Actions, never place untrusted expression values directly inside a shell run block. Pass them through an environment variable and quote the shell expansion.

References