Countermeasures against pwn requests: pull_request_target becomes permission-based starting December 2. Remaining attack paths and how to identify them

Countermeasures against pwn requests: pull_request_target becomes permission-based starting December 2. Remaining attack paths and how to identify them

To counter pwn requests in GitHub Actions, actions/checkout will refuse fork PR code by default, and pull_request_target on public repos becomes permission-based from December 2. Covers remaining paths and audit command results.
2026.10.30

This page has been translated by machine translation. View original

Hello! I'm Yuji Nishimura from the Operations Department!

In 2026, GitHub Actions has rolled out a series of countermeasures against "pwn requests," where code from an external PR runs in a workflow that holds secrets. The main target is pull_request_target. It is a trigger that reacts to PRs, and GitHub's documentation lists labeling, triage, and posting authenticated status checks for PRs from forks as its use cases. To allow such write operations, it runs with the permissions of the base repository.

actions/checkout changed in v7 and in the backports to the supported major versions other than v1 (v2 to v6). With privileged triggers such as pull_request_target, fetching code from fork PRs is now rejected by default. Furthermore, for public repositories that have not configured an event policy, pull_request_target becomes permission-based from December 2, 2026, and will stop working unless an event policy allows it.

However, Wiz's "The Pwn Request Reckoning", published on October 26, 2026, explains that these measures closed the typical route of fetching fork PR code with actions/checkout, and that there are still routes that work. Based on GitHub's changelog and documentation and Wiz's article, this article separates what the measures cover from what they don't, and summarizes how to check your own workflows.

Conclusion first: what to check and what to do

What to check What to do
Whether a workflow that uses pull_request_target really needs secrets If not, move it to pull_request
Whether an old version of actions/checkout is pinned by SHA or by a minor/patch tag, or whether v1 is used Update to v7 or later, or to a backported version or later (such as v4.4.0; see below)
Whether PR code is fetched with git fetch or gh pr checkout Do not fetch or run PR code in privileged workflows
Whether PR code is run via issue_comment or repository_dispatch Verify the permissions of the commenter and pin to the commit at the time of the comment
Whether ${{ github.event.pull_request.title }} and the like are written directly in run: Pass them via environment variables
Whether a workflow_run workflow executes artifacts from the PR side Only read artifacts as data
Whether workflows triggered by untrusted events use self-hosted runners that keep state between jobs Use ephemeral runners and separate runner groups
Whether any public repository has workflows that depend on pull_request_target (the default rule does not apply to private and internal repositories) Check the impact by December 2, and allow only the necessary ones via an event policy

pwn request: external PR code runs in a job that has secrets

"Pwn request" is the name for the problem where the content of a PR submitted by an outsider is executed inside a workflow that has secrets or write permissions.

A workflow with the pull_request trigger does not receive secrets for PRs from forks, and GITHUB_TOKEN is read-only. In contrast, pull_request_target runs in the context of the base repository's default branch and can hold secrets and write permissions. If you fetch the PR's code in this workflow and build or test it, the code of the person who submitted the PR runs in an environment with secrets.

GitHub's documentation also advises using pull_request if no additional secrets are needed, and not explicitly fetching untrusted code in pull_request_target.

Measures GitHub introduced in 2026

The measures that can be confirmed in GitHub's changelog are as follows. As of October 30, 2026, when I checked, no changelog has been published that changes the dates or scope after these.

Date Description Source
2026-06-18 actions/checkout v7 rejects fetching fork PR code by default in pull_request_target and PR-triggered workflow_run GitHub Changelog
2026-07-20 The same rejection is backported to the supported versions other than v1 Same as above (changed from the original July 16 by an editor's note dated July 15, 2026)
2026-09-17 Workflow Execution Protections becomes generally available (GA). For public repositories, a default rule that blocks pull_request_target is first introduced in evaluate mode (a mode that records runs that would be blocked without blocking them) GitHub Changelog
2026-12-02 Enforcement of the default rule begins for public repositories that had not configured a pull_request_target event policy before GA (date updated by an editor's note dated October 21, 2026) Same as above

What the actions/checkout rejection covers

According to the changelog, v7 rejects the checkout when, for a PR from a fork, the target is any of the following:

  • The fork's repository
  • refs/pull/<number>/head or refs/pull/<number>/merge
  • A commit SHA that resolves to the fork's head / merge

workflow_run is covered only when the original event is pull_request*.

As for the backports, workflows that reference a major tag such as @v4 are covered automatically. Workflows pinned by SHA, or by a minor tag (@v4.3) or patch tag (@v4.3.1), need to be updated. If you want to lift the rejection, specify allow-unsafe-pr-checkout: true in the with: of actions/checkout.

When I checked the actions/checkout release list on October 30, 2026, v2.8.0 / v3.7.0 / v4.4.0 / v5.1.0 / v6.1.0 had been published on July 20, 2026. The release notes of all five contain a line such as "[BREAKING] backport allow-unsafe-pr-checkout to v4" (in the case of v4). Looking with git ls-remote --tags https://github.com/actions/checkout, the major tags v2 to v6 each pointed to the same commit as these versions, and the v1 tag still pointed to the same commit as v1.2.0. In the v7 line, v7.0.1 was released on the same day. Wiz's article also gives v2.8.0 / v3.7.0 / v4.4.0 / v5.1.0 / v6.1.0 as the minimum versions that include the backport.

What the default rule covers

The default rule of Workflow Execution Protections blocks pull_request_target in public repositories that have no event policy configured. It does not apply to private and internal repositories, and it does not replace event policies that are already configured. Enforcement beginning on December 2 applies to repositories that had not configured a pull_request_target event policy before GA. If you need pull_request_target, you can explicitly allow it with an event policy. In that case, you can narrow the scope down to individual workflow files.

Routes that still remain

Wiz lays out the routes that still work even after the measures above. The following four are ones for which I could confirm the same explanation in GitHub's documentation.

The following diagram is a conceptual diagram I made myself based on Wiz's article and GitHub's documentation; it is not an official architecture diagram. For each privileged trigger, it separates the routes stopped by the actions/checkout fix from those that are not. The leftovers on self-hosted runners, described later, are omitted from the diagram.

1. Fetching with something other than actions/checkout

The rejection in v7 is a check performed inside actions/checkout. The June 18 checkout changelog explicitly states that fetching PR code with the git or gh CLI inside run:, and fetching unrelated third-party repositories, are not subject to the rejection. In addition, the documentation says that downloaded artifacts are also out of scope. Fetches using an old version of actions/checkout pinned by SHA, and fetches with allow-unsafe-pr-checkout: true, are not rejected either.

2. Running PR code from a trigger that is out of scope

What checkout rejects is only pull_request_target and PR-triggered workflow_run, and the default rule covers only pull_request_target. The checkout changelog states that pwn requests via other events such as issue_comment are not stopped. The documentation also says that issue_comment workflows that fetch and run fork PR code are equally vulnerable. Wiz gives bot commands such as /deploy and repository_dispatch as examples.

3. PR strings become shell syntax (script injection)

This is a route that works even without fetching any code. Expressions such as ${{ github.event.pull_request.title }} are embedded as plain strings before the shell script is executed. Since PR titles and branch names can be freely chosen by the person who submitted the PR, writing them directly in run: causes the string to be interpreted as part of the command. This route has nothing to do with the checkout rejection or the default rule, so it remains after the 2026 measures.

4. What remains in artifacts and self-hosted runners

Suppose a pull_request workflow that has no secrets builds the PR's code. If a privileged workflow_run workflow then downloads and executes that output, the PR's code runs in an environment with secrets and write permissions. GitHub's documentation advises treating artifacts uploaded by other workflows as untrusted data in workflow_run workflows.

Wiz also points out the problem of self-hosted runners that are not recreated for each job. According to Wiz, because ~/.npm and ~/.cache/pip persist between jobs, an untrusted job can poison the dependencies of jobs that perform releases or publishing. GitHub's documentation also advises, as a rule, not to use self-hosted runners with public repositories.

A common misconception: the approval setting does not stop pull_request_target

There is a setting that requires approval for workflow runs from external contributors (Require approval for all external contributors). Wiz lists the belief that this setting also holds pull_request_target workflows for approval as a common misconception. GitHub's repository settings documentation also states that pull_request_target workflows run in the context of the base branch and therefore always run regardless of the approval setting.

Checking whether your workflows are affected

The commands introduced here are the ones in Wiz's article. I placed six dummy workflow files in a local repository on which I had only run git init, and ran them on October 30, 2026. The environment was macOS 26.6.1 (BSD grep 2.6.0, bash 5.3.20, git 2.56.0). The screenshots were taken by running the same commands in a Linux container (GNU grep 3.11), and the grep results were the same as on macOS. The dummy files were not pushed to GitHub and were not run as workflows.

Enumerating privileged triggers and dangerous patterns

Wiz's article includes the following grep targeting .github/workflows/. It enumerates in one go the dangerous triggers, fetches other than actions/checkout, old actions/checkout tag pins, artifact downloads, self-hosted runners, and expansions of ${{ github.event. and github.head_ref.

grep -rnE 'pull_request_target|workflow_run|issue_comment|repository_dispatch|allow-unsafe-pr-checkout|self-hosted|download-artifact|gh (pr checkout|repo clone)|git (clone|fetch)|refs/pull|actions/checkout@v(1|2\.[0-7]|3\.[0-6]|4\.[0-3]|5\.0|6\.0)([^0-9]|$)|\$\{\{[[:space:]]*github\.(event\.|head_ref)' .github/workflows/

Run against the six dummy workflows, it hit 19 lines. In addition to lines containing triggers, gh pr checkout, git fetch, self-hosted, download-artifact, and allow-unsafe-pr-checkout, it picked up the tag pins @v1, @v2.7.0, @v3.6.0, @v4.3.1, @v5.0.1, and @v6.0.3. It did not pick up @v4, @v4.4.0, @v7, @v2.8.0, or @v6.1.0. The following image shows the result narrowed down to the two files that handle issue_comment and the PR title.

The result of running grep on two dummy workflows. The lines issue_comment, runs-on: self-hosted, gh pr checkout, and git fetch origin refs/pull/1/head, the TITLE and HEAD lines written in env:, and the echo lines written directly in run: are matched

If something matches, check visually whether that workflow executes the PR's code or strings. As the image shows, the line TITLE: ${{ github.event.pull_request.title }} written as an env: value is matched just like the lines written directly in run:. Wiz's article also treats writing it as an env: value as the safe way, so focus mainly on the other lines (the reason is explained below in "Pass untrusted strings via environment variables"). A pull_request_target written in a comment line was also matched. As a static analysis tool, Wiz also mentions the open-source zizmor (I did not run zizmor this time).

Note that this grep does not pick up actions/checkout pinned by SHA. Check SHA pins with the next script.

Checking the version of actions/checkout pinned by SHA

If you pin by SHA, you need to check which release that SHA corresponds to. The following script from Wiz's article maps actions/checkout@<40-digit SHA> in the workflows to a release tag of actions/checkout. Check whether the resulting tag is v7.0.0 or later, or a backported version or later. This script fetches the tag list from GitHub with git ls-remote, so it requires a network connection.

tags=$(git ls-remote --tags https://github.com/actions/checkout)
grep -rhoE "actions/checkout@[0-9a-f]{40}" .github/workflows/ | cut -d@ -f2 | sort -u |
while read -r sha; do
  tag=$(echo "$tags" | grep "^$sha" | sed 's#.*refs/tags/##; s#\^{}$##' | sort -V | tail -1)
  echo "$sha ${tag:-UNKNOWN - not a release tag, check manually}"
done

This is the result of running it after writing into the dummy workflows the commit SHAs of v4.3.0, v4.4.0, v7.0.1, v1.2.0, and v6.0.3, plus one nonexistent SHA.

The result of running the SHA resolution script. The nonexistent SHA is UNKNOWN, and the other five are mapped to v4.3.0, v4.4.0, v7.0.1, v1.2.0, and v6.0.3 respectively

For a SHA with multiple tags pointing to the same commit, like v4 and v4.4.0, v4.4.0, which comes last in sort -V, was displayed. With this result, the three entries v4.3.0, v1.2.0, and v6.0.3 need to be updated. A SHA that comes out as UNKNOWN may be a commit other than a release tag, or a SHA that does not belong to actions/checkout, so check it individually.

Checking which workflows will stop on December 2

The September 17 changelog advises checking the results of the default rule's evaluate mode in Insights, to understand in advance which runs will fail once enforcement begins. According to the workflow execution control documentation, when you open the repository's Settings, there is a Policy insights page directly under Actions > Policies in the left sidebar. Also, the same documentation says that evaluate mode for policies you create yourself is a GitHub Enterprise Cloud-only feature, but this refers to policies you create yourself, and it does not describe how the evaluation results of the default rule appear.

As of October 30, 2026, I have not opened the Policy insights screen, so I have not confirmed whether the evaluation results of the default rule can be viewed in a public repository on the GitHub Free plan. For reference, when I fetched the policy list via the REST API (GET /repos/{owner}/{repo}/actions/policies) for a public repository for testing under the same conditions, the result was empty, and the default rule did not appear in the list. I have not isolated whether the default rule is simply not exposed through this API or whether this repository is out of scope, and the API's output may change with future updates. Since the API list alone cannot tell you whether you are affected, checking on the Policy insights screen is the surest way. If you cannot view the evaluation results on the screen, enumerate the workflows that use pull_request_target with the grep above.

How to protect yourself: review triggers, checkout, inputs, and runners

Choose triggers again

The basic approach is to move workflows that don't need secrets to pull_request. If you want to perform privileged processing using the PR's results (such as labeling or posting comments), process the PR with pull_request and split the follow-up processing out to workflow_run. In this case, do not execute the PR-side artifacts; only read them as data.

Update actions/checkout and audit the exceptions

Use actions/checkout v7 or later, or a backported version or later. For places where allow-unsafe-pr-checkout is used, verify that the fetched code is not executed, and review it every time there is a change. GitHub's documentation also advises using it only when you have confirmed that the fetched code is never executed.

Pass untrusted strings via environment variables

GitHub's security hardening documentation recommends putting the value of an expression into an intermediate environment variable and then using it in the script. The following is the step portion extracted from the documentation's example and re-indented for readability.

.github/workflows/check-title.yml
      - name: Check PR title
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: |
          if [[ "$TITLE" =~ ^octocat ]]; then
            echo "PR title starts with 'octocat'"
            exit 0
          else
            echo "PR title did not start with 'octocat'"
            exit 1
          fi

The value is passed as a shell variable, so the content of the title is not interpreted as a command. On the shell side, enclose it in double quotes like "$TITLE".

Add conditions to comment-triggered workflows

Wiz recommends the following measures for issue_comment and repository_dispatch.

  • Verify the commenter's actual permission (write or higher). Do not rely on author_association, as it does not reflect current permissions
  • Pin to the head commit at the time of the comment, and abort if the PR has been updated since
  • Validate comment arguments and repository_dispatch payloads with an anchored allowlist regular expression, and pass them via environment variables rather than embedding them with ${{ }}

Separate privileged jobs and runners

Wiz recommends restricting privileged jobs with environments that require review, or with Workflow Execution Protections actor rules (rules that block based on who triggered the run). It also mentions running workflows triggered by untrusted events on ephemeral runners (runners recreated for each job) and separating runner groups from those used by trusted workflows.

Decide on an event policy by December 2

If you keep using pull_request_target in a public repository, check the impact and then allow only the necessary workflows via an event policy. If you don't use it, the default rule is fine as it is. Private and internal repositories do not become permission-based, so updating actions/checkout and reviewing triggers are even more important there.

Clues for detection

  • In the execution logs of pull_request_target, workflow_run, and issue_comment runs triggered by fork PRs, check which commit was fetched and what was executed

Summary

  • Thanks to GitHub's 2026 measures, the route of fetching fork PR code with actions/checkout inside pull_request_target is now closed, as long as you reference actions/checkout by a major tag or by a fixed version or later. For public repositories that have not configured an event policy, pull_request_target becomes permission-based from December 2, 2026.
  • Even so, fetching with git or gh, old SHA pins and allow-unsafe-pr-checkout, issue_comment and repository_dispatch, script injection, workflow_run artifacts, and leftovers on self-hosted runners remain outside the measures. Also, the default rule does not apply to private and internal repositories.
  • First, enumerate the privileged triggers and actions/checkout versions in .github/workflows/, and move those that don't need secrets to pull_request.
  • For public repositories, by December 2, check the workflows that depend on pull_request_target using Policy insights (or the grep results if you cannot view it), and allow only the necessary ones via an event policy.

I hope this is helpful to someone.


References:

Share this article