Accessing secrets from forks safely with GitHub Actions

04 Sep 2023 in Tech

It feels like I see someone asking “how do I let pull requests from forks access my GitHub Actions secrets?” every other day. GitHub prevents PRs from forks from accessing secrets by default so that they can’t exfiltrate secrets and use them for malicious purposes.

There are posts out there that show you how to use pull_request_target with actions/checkout to check out the HEAD ref, but this is insecure by default as it exposes all of your secrets to anyone that can raise a pull request against a repo (which is everyone on a public repo).

So, how do you get the best of both worlds? How can you safely provide secrets to forks without malicious actors stealing them?

GitHub Actions provides a triggering actor field that lets you know who ran (or re-ran) a workflow. This allows maintainers to provide access to secrets on forks once they’ve checked the changes manually and are happy that they don’t pose a security risk by re-running a job.

If you’re working in a private repo and want to enable secrets for forks, you can make them available via repo settings. This post is only relevant for public repos.

Here’s what the workflow would look like:

  • Alice opens a pull request from her fork
  • GitHub Actions runs and fails the build as she doesn’t have permissions on the repo
  • Bob reviews the PR and decides that there’s nothing dangerous
  • Bob re-runs the failed job
  • GitHub Actions runs and the access check passes as Bob has write access to the repo
  • github.event.pull_request.head.sha is checked out and points to Alice’s changes
  • The rest of the workflow runs successfully with access to any repository secrets

So in short:

  1. Check permissions
  2. Checkout code
  3. Run tests with secrets

Check Collaborator Permissions

GitHub not trusting pull requests from forks is a proxy for “do we trust this person?”. Rather than asking the question “is the PR from a fork”, why don’t we check what permission the actor triggering the job has?

There are two ways to check permissions - author association or user permissions. Author association tells us if they have any permissions on the repository (they’ll be marked as a collaborator no matter what permissions they have). User permissions tells us exactly what permissions they have on the repository.

If you want to learn more about author associations vs user permissions see Check permissions in a GitHub Actions workflow

If you’re not sure which to choose, I recommend checking the actor’s permissions rather than relying on author association.

Author Association

The author association is available by default in the pull request event. This means that you can check permissions directly in a workflow without using the GitHub API:

yaml
- name: Check access
if: ${{ github.event.pull_request.author_association != 'COLLABORATOR' && github.event.pull_request.author_association != 'OWNER' }}
run: |
echo "Event not triggered by a collaborator."
exit 1

This step would check if the author of the PR has any access to the repo. If they do not, the workflow will exit with error code 1.

User Permission

Alternatively, you can check if the actor has specific permissions using a GitHub Action. I personally use the following action:

yaml
- name: Get User Permission
id: checkAccess
uses: actions-cool/check-user-permission@v2
with:
require: write
username: ${{ github.triggering_actor }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check User Permission
if: steps.checkAccess.outputs.require-result == 'false'
run: |
echo "${{ github.triggering_actor }} does not have permissions on this repo."
echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}"
echo "Job originally triggered by ${{ github.actor }}"
exit 1

The action returns outputs that you can then use to determine what to do next. I check the permission in the second step above and print a debugging message before exiting with an error code of 1 to fail the build.

You might notice that I use github.triggering_actor rather than github.actor. This is what allows us to run tests from forks if the job is re-run by someone with the correct permission level.

When checking for permissions, the available levels are none, read, write and admin. Although triage and maintain are permissions in GitHub itself, they are not reflected in the API.

Check out the new code

At this point it’s safe to check out the code as the job has been triggered by someone with write access.

I mentioned earlier that using actions/checkout with github.event.pull_request.head.sha is insecure. However, as the PR has been reviewed and the workflow run has been triggered by someone with write access we can be a little more trusting.

Here’s how to check out the code from the PR in your workflow by explicitly setting a ref:

yaml
- name: Checkout code
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # This is dangerous without the first access check

Run the tests

Your workflow will be different to mine at this point. If you’re testing if you’ve configured things correctly, you can set a secret named MY_SECRET to val and use the following step:

yaml
- name: Test
run: |
if [[ "x${{ secrets.MY_SECRET }}" == "xval" ]]; then
echo "Access to secrets"
else
echo "No access to secrets"
exit 1
fi

Putting it all together

Taking all of the above and combining it in to a single workflow gives us the following:

yaml
name: Run tests from fork
on:
pull_request_target:
types: [opened, synchronize]
jobs:
demo:
runs-on: ubuntu-latest
steps:
- name: Get User Permission
id: checkAccess
uses: actions-cool/check-user-permission@v2
with:
require: write
username: ${{ github.triggering_actor }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check User Permission
if: steps.checkAccess.outputs.require-result == 'false'
run: |
echo "${{ github.triggering_actor }} does not have permissions on this repo."
echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}"
echo "Job originally triggered by ${{ github.actor }}"
exit 1
- name: Checkout code
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # This is dangerous without the first access check
- name: Run tests
run: |
if [[ "x${{ secrets.MY_SECRET }}" == "xval" ]]; then
echo "Access to secrets"
else
echo "No access to secrets"
exit 1
fi

So there we have it - a mostly safe way to run workflows with access to secrets when a pull request is raised from a fork.

Why mostly safe rather than 100% safe? It relies on humans to read the pull request diff and make a good decision. We’re all human and mistakes happen. At some point a PR will get run that shouldn’t have been, and you’ll need to rotate your secrets.

I think it’s worth it. Being able to run your full suite of tests with secrets on PRs from forks is key to delivering robust software. If secrets get leaked by mistake, well, it’s always good to test your rotation procedure periodically 😁.