Summary
gh-aw's template-injection guardrails skip heredoc bodies before scanning them,
on the incorrect premise that heredoc content is not executed. gh-aw then emits
its MCP config as an unquoted heredoc on every engine, and renders custom MCP
server fields into it verbatim. A ${{ github.event.* }} in any custom MCP
field reaches the unquoted heredoc and is command-substituted by bash at
runtime, giving code execution in the agent job. The build succeeds with no
warning.
I verified this against current main at commit 966ccb8 (2026-07-12). It was
first verified at bdfdb8e (2026-06-24, ~v0.80.9). The one intervening commit
that touched this area (PR #44454) widened the exposure rather than closing it
(see Details).
Details
removeHeredocContent (pkg/workflow/template_injection_utils.go:71-82) strips
both quoted and unquoted heredoc bodies before any guardrail scans. The premise
is wrong: GitHub 操作 substitutes ${{ ... }} into the script text before
bash parses it, a quoted delimiter does not stop the substitution, and a
multi-line attacker value can contain a line equal to the delimiter to close the
heredoc early and run the following lines. Stripping the body blinds all three
run-step defenses: the auto-fixer that hoists expressions into env vars, and
both compile validators.
gh-aw emits the MCP config as an unquoted heredoc on every engine. The gateway
JSON config is cat << GH_AW_MCP_CONFIG_<hex>_EOF | "$GH_AW_NODE" ...
(pkg/workflow/mcp_renderer.go:241, delimiter not single-quoted). The codex
TOML config is the same pattern (pkg/workflow/codex_mcp.go:103,144,156).
Custom MCP server fields (command, args, url, headers, env) are written
verbatim with raw fmt.Fprintf and no escaping
(pkg/workflow/mcp_config_custom.go; only secrets.* values are rewritten). A
${{ github.event.* }} in any of those fields lands in the unquoted heredoc and
is command-substituted at runtime.
The surrounding controls do not cover it. The env-indirection helper rewrites
only secrets.*, env.*, and github.workspace, never github.event.*
(pkg/workflow/secret_extraction.go:219-242,324-346). validateExpressionSafety
scans only the markdown body, never the frontmatter mcp-servers config
(pkg/workflow/compiler_validators.go:32).
Intervening commit: PR #44454 ("skip heredoc content in run-block expression
scan") extended the same heredoc-skipping to the one raw-text guardrail path that
previously did not understand heredocs
(pkg/workflow/template_injection_validation.go:178-246). Both scanners now
deliberately skip heredoc bodies, so the exposure is marginally wider than at
bdfdb8e, not narrower.
PoC
I compiled a workflow with
url: "https://api.example.com/${{ github.event.issue.title }}" plus a headers
entry using github.event.issue.body. The build succeeds and the raw
expressions land in the unquoted MCP heredoc (confirmed for claude/codex/copilot).
Running the compiled step through bash with an issue title of
$(echo PWNED_F11 > marker; echo INJECTED) executed the command.
Impact
Command execution in the agent job, which holds GITHUB_TOKEN and the engine API
keys. No adopter interpolates github.event.* into a custom MCP field today
(custom MCP servers are rare), so this is a framework fix in a core trust
boundary rather than an active mass-exploit. The same heredoc blind spot also
covers any author run: step that contains a heredoc with ${{ github.event.* }}:
it compiles with no warning and the raw expression survives.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H (8.3). Up to 10.0 (Critical) under
roles: all with an issue/PR trigger, where any external user reaches it
directly.
Suggested fix
Stop treating heredoc bodies as non-executable for expression scanning: either
extract ${{ }} from heredoc bodies into env vars, or make the regression
guardrail not strip heredocs so it hard-fails on any expression left inside one.
Single-quote the framework's own MCP-config heredoc delimiters and pass framework
variables through the gateway env.
Summary
gh-aw's template-injection guardrails skip heredoc bodies before scanning them,
on the incorrect premise that heredoc content is not executed. gh-aw then emits
its MCP config as an unquoted heredoc on every engine, and renders custom MCP
server fields into it verbatim. A
${{ github.event.* }}in any custom MCPfield reaches the unquoted heredoc and is command-substituted by bash at
runtime, giving code execution in the agent job. The build succeeds with no
warning.
I verified this against current
mainat commit966ccb8(2026-07-12). It wasfirst verified at
bdfdb8e(2026-06-24, ~v0.80.9). The one intervening committhat touched this area (PR #44454) widened the exposure rather than closing it
(see Details).
Details
removeHeredocContent(pkg/workflow/template_injection_utils.go:71-82) stripsboth quoted and unquoted heredoc bodies before any guardrail scans. The premise
is wrong: GitHub 操作 substitutes
${{ ... }}into the script text beforebash parses it, a quoted delimiter does not stop the substitution, and a
multi-line attacker value can contain a line equal to the delimiter to close the
heredoc early and run the following lines. Stripping the body blinds all three
run-step defenses: the auto-fixer that hoists expressions into env vars, and
both compile validators.
gh-aw emits the MCP config as an unquoted heredoc on every engine. The gateway
JSON config is
cat << GH_AW_MCP_CONFIG_<hex>_EOF | "$GH_AW_NODE" ...(
pkg/workflow/mcp_renderer.go:241, delimiter not single-quoted). The codexTOML config is the same pattern (
pkg/workflow/codex_mcp.go:103,144,156).Custom MCP server fields (
command,args,url,headers,env) are writtenverbatim with raw
fmt.Fprintfand no escaping(
pkg/workflow/mcp_config_custom.go; onlysecrets.*values are rewritten). A${{ github.event.* }}in any of those fields lands in the unquoted heredoc andis command-substituted at runtime.
The surrounding controls do not cover it. The env-indirection helper rewrites
only
secrets.*,env.*, andgithub.workspace, nevergithub.event.*(
pkg/workflow/secret_extraction.go:219-242,324-346).validateExpressionSafetyscans only the markdown body, never the frontmatter
mcp-serversconfig(
pkg/workflow/compiler_validators.go:32).Intervening commit: PR #44454 ("skip heredoc content in run-block expression
scan") extended the same heredoc-skipping to the one raw-text guardrail path that
previously did not understand heredocs
(
pkg/workflow/template_injection_validation.go:178-246). Both scanners nowdeliberately skip heredoc bodies, so the exposure is marginally wider than at
bdfdb8e, not narrower.
PoC
I compiled a workflow with
url: "https://api.example.com/${{ github.event.issue.title }}"plus aheadersentry using
github.event.issue.body. The build succeeds and the rawexpressions land in the unquoted MCP heredoc (confirmed for claude/codex/copilot).
Running the compiled step through bash with an issue title of
$(echo PWNED_F11 > marker; echo INJECTED)executed the command.Impact
Command execution in the agent job, which holds GITHUB_TOKEN and the engine API
keys. No adopter interpolates
github.event.*into a custom MCP field today(custom MCP servers are rare), so this is a framework fix in a core trust
boundary rather than an active mass-exploit. The same heredoc blind spot also
covers any author
run:step that contains a heredoc with${{ github.event.* }}:it compiles with no warning and the raw expression survives.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H (8.3). Up to 10.0 (Critical) under
roles: allwith an issue/PR trigger, where any external user reaches itdirectly.
Suggested fix
Stop treating heredoc bodies as non-executable for expression scanning: either
extract
${{ }}from heredoc bodies into env vars, or make the regressionguardrail not strip heredocs so it hard-fails on any expression left inside one.
Single-quote the framework's own MCP-config heredoc delimiters and pass framework
variables through the gateway env.