Skip to content

Safe-outputs config emitter: JSON injection via templated values despite env-var indirection

High
pelikhan published GHSA-2wjq-689w-pprh Aug 6, 2026

Package

gomod github.com/github/gh-aw (Go)

Affected versions

<= 0.75.0

Patched versions

v0.78.0

Description

Summary

pkg/workflow/mcp_setup_generator.go:258 (generateSafeOutputsSetup) constructs the runtime safeoutputs/config.json by:

  1. Extracting ${{ secrets.* }}, ${{ github.* }}, and (since #30878) ${{ inputs.* }} from the config string into a step env: block.
  2. String-replacing each expression in the JSON template with ${VARNAME}.
  3. Writing the template via an unquoted heredoc so bash expands those shell variables at write time.

This is the GitHub Security Lab env-var indirection pattern applied correctly for the shell threat model — the shell never parses attacker-controlled bytes as code. But the destination of the heredoc is not the shell; it is a JSON file consumed by downstream steps. Bash ${VAR} expansion is byte-substitution, not JSON encoding, so attacker-controlled " and other JSON-special characters land verbatim inside a JSON string literal. The JSON parser is the new injection target, with the same root cause as the original CWE-94 pattern but at a different boundary.

The existing regression coverage at pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76 asserts the env-var/heredoc shape but never probes with ", so this case is uncovered.

Affected source

  • Compiler emitter: pkg/workflow/mcp_setup_generator.go:258-322generateSafeOutputsSetup
  • Sibling emitter, same class: pkg/workflow/mcp_setup_generator.go:345-358GH_AW_TOOLS_META_JSON block-scalar construction
  • Run-step sanitizer (correct for its stated threat model, scope clarification recommended): pkg/workflow/run_step_sanitizer.go:38-43, 92
  • Incomplete regression coverage: pkg/workflow/safe_outputs_dynamic_allowed_repos_test.go:18-76

Threat model

The class of bug applies wherever the compiler interpolates a ${{ … }} expression into a structured-data literal:

  • ${{ inputs.* }} for workflow_dispatch / workflow_call inputs (requires repo write to exploit)
  • ${{ vars.* }} for repo/org variables (broader trust gradient — org admins set, many workflows consume)
  • ${{ github.event.* }} if surfaced into safe-outputs config

The compiler cannot determine the trust level of inputs.* or vars.* at compile time because that depends on the workflow's trigger configuration. Safe default is to treat all templated values as untrusted at the data-format boundary.

Why the existing sanitizer does not catch this

run_step_sanitizer.go correctly extracts ${{ … }} from run: bodies and explicitly skips heredoc content on the documented basis (lines 38-43) that heredoc bodies are not executed as shell code. That reasoning is sound for the shell threat model. It does not generalize to the data-format threat model. The sanitizer's docstring should also be updated to scope its claim explicitly to shell injection.

Suggested remediation

JSON-encode templated values at the data-format boundary rather than relying on bash byte-substitution. Options, in order of preference:

  1. Build the JSON via jq with --arg, which produces a properly-encoded JSON string regardless of input bytes:
    env:
      GH_AW_INPUT_TITLE_PREFIX: ${{ inputs.title_prefix }}
    run: |
      jq -n --arg title_prefix "$GH_AW_INPUT_TITLE_PREFIX" \
        '{create_issue: {labels:["triage"], max:5, title_prefix:$title_prefix}, ...}' \
        > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
  2. Construct the JSON in Go for the static portion with placeholders; merge templated values at runtime via jq rather than shell-substituting them in.
  3. Less attractive: keep current shape but pre-encode each env var as a JSON string before bash expansion (e.g. a printf '%s' "$VAR" | jq -Rs . pass). Adds a parser-aware layer but is harder to keep correct than handing off to jq upstream.

Same fix shape applies to GH_AW_TOOLS_META_JSON (mcp_setup_generator.go:346-350) — build the meta via json.Marshal in Go with templated values pulled from env at runtime, rather than emitting a templated YAML block-scalar.

Add regression coverage in safe_outputs_dynamic_allowed_repos_test.go that compiles a workflow with a "-containing input value and asserts the resulting config.json parses back to a structure where the input value occupies exactly one JSON string (i.e., no key/value injection).

References


Proof-of-concept (PoC field — separate)

Setup

Author this workflow as .github/workflows/repro.md:

---
on:
  workflow_dispatch:
    inputs:
      title_prefix:
        default: "PME "
safe-outputs:
  create-issue:
    title-prefix: ${{ inputs.title_prefix }}
    labels: [triage]
    max: 5
---
Test workflow body.

Compile: gh aw compile repro. The emitted Generate Safe Outputs Config step in repro.lock.yml:

- name: Generate Safe Outputs Config
  env:
    GH_AW_INPUT_TITLE_PREFIX: ${{ inputs.title_prefix }}
  run: |
    mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
    cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << GH_AW_SAFE_OUTPUTS_CONFIG_<hash>_EOF
    {"create_issue":{"labels":["triage"],"max":5,"title_prefix":"${GH_AW_INPUT_TITLE_PREFIX}"}, ...}
    GH_AW_SAFE_OUTPUTS_CONFIG_<hash>_EOF

Exploit

Dispatch with title_prefix = evil","max":9999,"extra":"x

Result

Actions correctly sets GH_AW_INPUT_TITLE_PREFIX=evil","max":9999,"extra":"x as an env value (no shell parsing). Bash then expands ${GH_AW_INPUT_TITLE_PREFIX} byte-for-byte into the heredoc body, writing:

{"create_issue":{"labels":["triage"],"max":5,"title_prefix":"evil","max":9999,"extra":"x"}, ...}

The downstream consumer parses the corrupted structure — max is now 9999 (vs the compile-time intent of 5), and extra is an attacker-injected key. With more careful crafting, allowed, labels, or any other safe-outputs config field can be overridden.

The same payload shape works for the target_repo / base_branch paths covered by safe_outputs_dynamic_allowed_repos_test.go. Any safe-outputs string field templated from ${{ inputs.* }}, ${{ vars.* }}, or any other ${{ … }} source whose value reaches the JSON literal via bash ${VAR} expansion is vulnerable.


Impact (Impact field — what an attacker can do)

An actor able to set the input value can corrupt the safe-outputs runtime config:

  • Raise max rate limits, bypassing the agent's compile-time safe-output quotas (could enable mass-issue creation, mass-labeling, etc.)
  • Add values to allowed lists for labels / repos / base branches
  • Inject fields the compiler intended to be statically constrained

This subverts the safety guarantees that safe-outputs is meant to enforce on the agent's behalf. Exploitation does not yield RCE on the runner — the shell is correctly defended by the existing env-var indirection — but it does break the compile-time-trusted constraints that gate what the agent's outputs can do.

Threshold today: workflow_dispatch rights (repo write) for the inputs.* path. Lower for vars.* consumers, since org/repo variables can be set by different actors than workflow dispatchers.

The structural concern is that the compiler treats env-var indirection as sufficient when the destination is structured data. That assumption will quietly extend to any future feature that templates user-controlled values into a JSON / YAML literal.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
None
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L

CVE ID

No known CVE

Weaknesses

Improper Control of Generation of Code ('Code Injection')

The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. Learn more on MITRE.

Improper Encoding or Escaping of Output

The product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. As a result, the intended structure of the message is not preserved. Learn more on MITRE.

Credits