Four tips to keep your GitHub Actions workflows secure

Researchers from Purdue and NCSU have found a large number of command injection vulnerabilities in the workflows of projects on GitHub. Follow these four tips to keep your GitHub Actions workflows secure.

Image of the GitHub logo with a blue gradient background
| 7 minutes

Continuous Integration and Continuous Deployment (CI/CD) software supply chains are a lucrative target for threat actors. GitHub Actions is one of the most widely used platforms for automation, making it an important target.

For the past few months, the GitHub Security Lab has been collaborating with a team of researchers from Purdue University (PurS3 Lab, PurSec Lab) and North Carolina State University (WSPR Lab). Their research, which they are presenting this week at the Usenix Security Conference 2023, is about how they found a number of code injection vulnerabilities in GitHub Actions workflows among open source projects hosted on GitHub. We confirmed their findings by verifying a random sample of the vulnerabilities found, and also advised them on how to report the vulnerabilities to the large number of affected projects.

The injection vulnerabilities the researchers found are all variations of the same patterns we’ve described in previous content, which we share at the end of this blog post. Please read on for the most important points from those posts and use these tips to keep your workflows secure.

Understanding command injection vulnerabilities in GitHub Actions workflows

A workflow is a program that starts automatically when a specific repository event occurs. As with any program potentially started by an external user, user-controlled inputs should be treated as untrusted. In the context of workflows, this means values such as github.event.issue.title or github.event.issue.body. In particular, GitHub Actions expression evaluation is a powerful language-independent feature which may lead to script injections when used in such blocks as run.

This is a simple example of a workflow that is vulnerable to command injection:

- name: print title
  run: echo "${{ github.event.issue.title }}"

To exploit this, an attacker would create an issue with a title like $(touch pwned.txt):

Screenshot of a new GitHub Issue named "$(touch pwned.txt)" being created.

Due to the way that the ${{}} syntax gets expanded, this causes the workflow to execute the following command:

echo "$(touch pwned.txt)"

Since expression evaluation is language independent, the injection type is not Bash shell-specific. For example, when ${{}} is used in a JS script, a syntactically valid construct can be used for injection there, too.

An attacker could use an injection vulnerability like this to achieve something far more malicious than just modifying a local file. For example, they could upload secrets to a website that they control, or commit new code to your repository to add a backdoor vulnerability or supply chain attack.

1: Don’t use ${{ }} syntax in the run section to avoid unexpected substitution behavior

Our recommendation is to use intermediate environment variables for the potentially untrusted input and then use language-specific capabilities to retrieve the value of the variable:

- name: print title
  env:
    TITLE: ${{ github.event.issue.title }}
  run: echo "$TITLE"

or

- name: print title
  env:
    TITLE: ${{ github.event.issue.title }}
  uses: actions/github-script@v6
  with:
    script: console.log(process.env.TITLE)

When used in the run or script section, the ${{ }} syntax is almost always very dangerous. It gets macro-expanded to arbitrary text in the body of the command, which means that it’s usually very easy to exploit. Using the ${{ }} syntax in the env section is safer because the user-controlled string is stored in a variable instead. The syntax "$TITLE" in the updated run section is standard Bash syntax, which is widely known and therefore less likely to behave in a way that the author of the workflow didn’t expect. Similarly, the syntax process.env.TITLE is standard JavaScript syntax, so the code will behave as expected for a JavaScript program. Please note that this solution doesn’t prevent other types of vulnerabilities: the TITLE environment still contains untrusted input and still needs to be handled with care.

2: Enable code scanning for workflows

The recommendation above is a safer way to use the${{ }} syntax, but it doesn’t prevent all command injection vulnerabilities. Therefore, we recommend carefully reviewing your workflows, focusing on the usage of untrusted input. Additionally, to prevent accidentally introducing similar vulnerabilities in new code, we recommend enabling code scanning for the repository. The Security Lab, in collaboration with the code scanning team, has written CodeQL queries that can catch unsafe interpolation usage with untrusted input.

The CodeQL workflow scanning queries are (currently) only included in the query suite for JavaScript, so they’re only enabled by default if your project is written in JavaScript.1 If the main programming language of your project is something else, such as Python or Java, then you need to manually modify the CodeQL workflow to add JavaScript as an additional language. Here’s an example of how to do this. The scanning will work even if your repository doesn’t contain any JavaScript; and if you are interested only in workflows, but not other JavaScript files, you can exclude some paths in the CodeQL configuration.

3: Use the principle of least privilege

The impact of an injection vulnerability may be greatly reduced by using the principle of least privilege. Every GitHub workflow receives a temporary repository access token (GITHUB_TOKEN). These tokens originally had a very broad set of permissions with full read and write access to the repository (except for pull requests from forks). In 2021, GitHub introduced a more fine grained permission model for workflow tokens and, today, the default permissions for new repositories and organizations are set to read-only. However, a significant number of workflows still use a write-all token due to grandfathered default workflow permission settings in older repositories. If you want to check if you are using a broad default permission for your workflow tokens, you can go to the repository (or organization) settings→actions and check the “Workflow permissions” section:

Screenshot of the "Workflow permissions" section of a repository's settings. The radio button for "Read and write permissions" is selected.

Recently, the Security Lab published a beta version of permission monitor action, which we recommend you use to make the transition smoother.

An important security guarantee that GitHub makes is that workflows triggered on pull_request from forks are run with minimal privileges: no access to secrets and the repository token is read-only. Thus, if a workflow contains potential injection vulnerabilities, but is triggered only on pull_request, then the impact is minimal, because only pull requests from users able to create branches in the same repository are able to trigger the workflow with higher privileges. However, the researchers found a separate vulnerability which enabled external users to trigger a privileged workflow run by creating a pull request between already existing branches in the same repository. The exploitability was limited to the cases when the target branch workflow contained an injection from a pull request input such as github.event.pull_request.title or github.event.pull_request.body. The researchers reported the issue (responsibly) to GitHub and it has since been fixed. Nevertheless, it is better to keep workflows injection free, even if they run with minimal privileges.

4: Enable Private Vulnerability Reporting (PVR)

Finding an appropriate contact to disclose a potential vulnerability to is often an unnecessarily complicated task for security researchers, and is why we introduced Private Vulnerability Reporting (PVR) last year, which provides a direct collaboration channel for security researchers and maintainers where they can securely discuss the issue and work on mitigations in a private pull request. We encourage all maintainers to enable PVR in their repositories.

Conclusion

We would like to thank the research team from Purdue University and North Carolina State University for doing this work and for helping to protect GitHub’s users. Open source security is a collaborative effort and this research project is a great example of how it should be done.

For more advice on how to keep your workflows secure, check out these blog posts:

Notes


  1. When you configure the default setup for code scanning, the language of your project is automatically detected. The suite of CodeQL queries is different for each language. 

Written by

Kevin Backhouse

Kevin Backhouse

@kevinbackhouse

I'm a security researcher on the GitHub Security Lab team. I try to help make open source software more secure by searching for vulnerabilities and working with maintainers to get them fixed.

Related posts