Skip to content
GitHubGHSA-2wjq-689w-pprh

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

High8.5Published Aug 6, 2026

### 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](https://securitylab.github.com/resources/github-actions-untrusted-input/) 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...

GitHub advisory

Affected versions

PackageAffectedFixed in
github.com/github/gh-aw
Go
< v0.78.0v0.78.0
Details and references

### 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](https://securitylab.github.com/resources/github-actions-untrusted-input/) 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-322` — `generateSafeOutputsSetup` - **Sibling emitter, same class**: `pkg/workflow/mcp_setup_generator.go:345-358` — `GH_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: ```yaml 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_gener

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L
Severity from
GitHub (reviewed advisory)
Weakness
CWE-94, CWE-116

More GitHub advisories

All GitHub

Critical advisories by email

Wednesdays: the week’s critical and high advisories in the AI and data stack, with the fixed versions. Only in weeks that have some.

Double opt-in. Unsubscribe any time.