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...
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| github.com/github/gh-aw Go | < v0.78.0 | v0.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
More GitHub advisories
All GitHub| Date | Advisory | Severity | Fixed in |
|---|---|---|---|
| Aug 8 | gh-aw: safe-output validator forwards undeclared agent fields to the appliers (scope escape / mass assignment) | Critical9.1 | v0.86.1 |
| Aug 7 | gh-aw: unauthenticated prompt-injection to code execution in the shipped ai-moderator workflow | Medium6.5 | v0.86.1 |
| Aug 7 | command injection in compiled workflow via unsanitized `sandbox.mcp.env` exports | Critical9.6 | v0.86.0 |
| Aug 7 | gh-aw: URL allowlist bypass via userinfo @ in the content sanitizer (exfiltration channel) | Medium6.8 | v0.86.0 |
| Aug 5 | GitHub Enterprise Server: denial of service | Medium6.6 | No fix yet |
| Aug 5 | GitHub Enterprise Server: path traversal | High8.8 | No fix yet |