Skip to content

Heredoc delimiter injection enables code-review bypass → RCE #23003

@lpcox

Description

@lpcox

Summary

The compiler embeds user-authored Markdown prompt content into a bash heredoc using a static, predictable delimiter (GH_AW_PROMPT_EOF). If a workflow .md file contains a line matching that delimiter, the heredoc closes early and subsequent lines execute as shell commands with workflow-token privileges.

Because .lock.yml files are marked linguist-generated=true in .gitattributes, GitHub collapses their diff by default in PR review. This converts a low-scrutiny "edit a Markdown prompt" PR into arbitrary shell execution.

Upstream tracking: github/agentic-workflows#181
Severity: High — code-review bypass → RCE in the workflow runner

Affected Code

What File Lines
Static delimiter generator pkg/workflow/strings.go GenerateHeredocDelimiter() (~L287-291)
User prompt written into heredoc pkg/workflow/unified_prompt_step.go ~L429-493
MCP config heredocs pkg/workflow/codex_engine.go, claude_engine.go Various RenderMCPConfig callers
Safe-output script heredocs pkg/workflow/safe_scripts.go buildCustomScriptFilesStep()
.lock.yml marked linguist-generated .gitattributes L2

Root Cause

// strings.go — deterministic, predictable delimiter
func GenerateHeredocDelimiter(name string) string {
    if name == "" {
        return "GH_AW_EOF"
    }
    return "GH_AW_" + strings.ToUpper(name) + "_EOF"
}

The delimiter is fully predictable from the name parameter. An attacker who knows the name (e.g., "PROMPT"GH_AW_PROMPT_EOF) can embed that string in their .md prompt to close the heredoc and inject shell commands.

The YAML run: | block-scalar strips common leading indentation before bash sees the script. The user's line and the closing delimiter have identical indentation, so after stripping, both appear at column 0 and bash treats the injected line as the heredoc terminator.

Steps to Reproduce

  1. Create .github/workflows/poc.md:
    ---
    on: workflow_dispatch
    engine: copilot
    permissions:
      contents: read
    ---
    # Routine prompt update
    Please review the repository.
    GH_AW_PROMPT_EOF
    echo "INJECTED: $(whoami)" | tee /tmp/proof.txt
    cat << 'GH_AW_PROMPT_EOF'
    Thank you.
  2. Compile: gh aw compile .github/workflows/poc.md
  3. Inspect the .lock.yml — after indent-strip, echo "INJECTED..." executes as shell.

Impact

  • Arbitrary shell execution with workflow job permissions
  • Access to GITHUB_TOKEN, secrets, runner filesystem
  • Bypasses code review (.md looks like prose, .lock.yml diff is hidden)

Recommended Solution

Replace the static delimiter with a cryptographically random one using crypto/rand. This was prototyped and validated in closed PR #22987.

1. Randomize the delimiter (pkg/workflow/strings.go)

import (
    "crypto/rand"
    "encoding/hex"
)

func GenerateHeredocDelimiter(name string) string {
    b := make([]byte, 8)
    if _, err := rand.Read(b); err != nil {
        panic("crypto/rand failed: " + err.Error())
    }
    tag := hex.EncodeToString(b) // 16 hex chars
    if name == "" {
        return "GH_AW_" + tag + "_EOF"
    }
    return "GH_AW_" + strings.ToUpper(name) + "_" + tag + "_EOF"
}

Format: GH_AW_<NAME>_<16_HEX_CHARS>_EOF (e.g., GH_AW_PROMPT_a1b2c3d4e5f6g7h8_EOF)

2. Preserve compiler skip-write optimization (pkg/workflow/compiler.go)

Because delimiters are now random, each compilation produces different output even if nothing meaningful changed. The compiler's skip-write optimization (which avoids rewriting .lock.yml when content is unchanged) must normalize delimiters before comparing:

var heredocDelimiterRE = regexp.MustCompile(`GH_AW_([A-Z0-9_]+)_[0-9a-f]{16}_EOF`)

func normalizeHeredocDelimiters(content string) string {
    return heredocDelimiterRE.ReplaceAllString(content, "GH_AW_${1}_NORM_EOF")
}

Use normalizeHeredocDelimiters() in writeWorkflowOutput() when comparing existing vs new content.

3. Update tests

All tests that check for exact heredoc delimiter strings need updating to use regex matching:

// Before (exact match — will break):
assert.Contains(t, output, "GH_AW_PROMPT_EOF")

// After (regex match — works with randomized delimiters):
delimRE := regexp.MustCompile(`GH_AW_PROMPT_[0-9a-f]{16}_EOF`)
assert.True(t, delimRE.MatchString(output))

Test files requiring updates (from PR #22987):

  • pkg/workflow/strings_test.go — test uniqueness, not exact values
  • pkg/workflow/mcp_scripts_mode_test.go — regex matching
  • pkg/workflow/unified_prompt_creation_test.go — regex counting
  • pkg/workflow/secure_markdown_rendering_test.go — regex matching
  • pkg/workflow/xml_comments_test.go — regex counting
  • pkg/workflow/codex_engine_test.go — normalize before line comparison
  • pkg/workflow/engine_helpers_shared_test.go — substring matching
  • pkg/workflow/heredoc_interpolation_test.go — regex matching
  • pkg/workflow/safe_scripts_test.go — substring matching
  • pkg/workflow/wasm_golden_test.go — normalize before golden comparison (already has normalization logic)

4. Regenerate golden files and lock files

go test -v ./pkg/workflow -run='^TestWasmGolden_CompileFixtures' -update  # Regenerate golden files
make build && make recompile                                    # Recompile all 178 lock files

Prior Work

  • PR fix: harden shell injection surfaces (heredoc, shellEscapeArg, YAML env) #22987 (closed, not merged) implemented and validated this fix across all affected files. The implementation was tested with make test-unit (all pass except 3 pre-existing failures).
  • The fix was part of a combined PR that also addressed shellEscapeArg bypass and YAML env injection. This issue covers only the heredoc delimiter injection.

Checklist

  • Update GenerateHeredocDelimiter() to use crypto/rand
  • Add normalizeHeredocDelimiters() to compiler skip-write comparison
  • Update ~10 test files to use regex-based delimiter matching
  • Regenerate wasm golden files (-update flag)
  • Recompile all workflow lock files (make recompile)
  • Run make agent-finish to validate

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions