Dev.to Security 🔐 Cybersecurity 👁 0 📖 15 min read

pull_request_target blocked: fix workflows by Dec 2

From December 2, 2026, pull_request_target is blocked, at least in public repositories where nobody has set up a matching rule. GitHub announced the change on November 2 and pushed the date back by one month at the same

pull_request_target blocked: fix workflows by Dec 2

From December 2, 2026, pull_request_target is blocked, at least in public repositories where nobody has set up a matching rule. GitHub announced the change on November 2 and pushed the date back by one month at the same time. That sounds like plenty of time. However, two trial runs in which the block is briefly enforced already take place on November 9 and 16.

I care about this because pull_request_target is one of those triggers almost everyone has copied from some example at some point without really knowing what it does. That is exactly why I think the block is the right call. In this article I show you how to find affected workflows, how to decide for each one, and what the safe alternatives look like in practice, including a small scanner written in TypeScript.

In short

In public repositories, pull_request_target is blocked by default from December 2, 2026, because the trigger can bring fork code and secrets together. Most workflows can move to pull_request or be split with workflow_run, and the rest gets an explicit allowance through an Actions policy. The biggest pitfall: the trial blocks on November 9 and 16, when unchecked workflows already fail.

What you will learn in this article

  • What changes on December 2 and which repositories are affected
  • Why pull_request_target can lead to “pwn requests”
  • How to find affected workflows with policy insights, gh and a scanner
  • Moving workflows to pull_request or splitting them with workflow_run
  • Allowing individual workflows through an Actions policy

pull_request_target blocked from December 2: what changes

First, the term: a trigger defines which event starts a GitHub Actions workflow. Both pull_request and pull_request_target react to pull requests. However, they differ in context. With pull_request_target, the workflow runs as if it came from the target repository itself, with its secrets and a GITHUB_TOKEN that may write. For a pull request from a fork, on the other hand, pull_request only gets a read-only token and no secrets.

To get pull_request_target blocked by default, GitHub introduces a default rule. It belongs to workflow execution protections, a kind of allowlist for who may start workflows and on which events. Here are the dates:

Date What happens
until December 2 default rule runs in “evaluate” mode: nothing is blocked, but runs are recorded
November 9, 2026, 16:00–18:00 UTC first trial block: affected runs fail with an error annotation
November 16, 2026, 16:00–18:00 UTC second trial block, same behavior
December 2, 2026 permanent block for pull_request_target without a matching policy

GitHub gives the windows in UTC. In Central Europe that is 17:00 to 19:00 in November.

Who is affected

Affected are public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories are out of scope, and so is GitHub Enterprise Server. In addition, the default rule does not apply if you have already created your own event policy for the repository. It does not replace that policy.

Why GitHub has pull_request_target blocked

On its own, the trigger is not evil. As long as the workflow only runs code from your own repository, nothing bad happens. Under pull_request_target, actions/checkout without extra inputs also fetches only the state of the default branch. Things get dangerous when someone checks out the code from the pull request and then does something with it: npm install, make test, a build script. As a result, foreign code runs with your secrets.

What a pwn request is

This pattern has its own name: pwn request. Anyone who creates a fork can, for example, add a postinstall script to package.json, change a Makefile or tamper with a test file. If they then open a pull request and your workflow builds the code, deploy keys, npm tokens or cloud credentials end up with the attacker. In fact, several well-known attacks on open source supply chains worked exactly like this.

🎬 Pwn request and safe rework compared – this animated diagram only runs on my site: watch it on sascha-ms.de

What makes it tricky is that the code does not have to come through actions/checkout. A gh pr checkout in a run step, a git fetch of the PR ref or a downloaded artifact from a fork have the same effect. By the way, since version 7, actions/checkout refuses on its own to check out fork code under pull_request_target or workflow_run. If you want it anyway, you have to set allow-unsafe-pr-checkout: true. That name is deliberately uncomfortable.

Step 1: Find affected workflows

Before pull_request_target is blocked and runs start failing, you need a list. In general, there are three ways to build it, and they complement each other well. Prerequisite: admin rights in the repository or owner rights in the organization for the policy pages, otherwise just read access and the GitHub CLI for searching.

Policy insights in the settings

  1. Open Settings in the repository or organization.
  2. Open the Actions section in the sidebar and choose Policies.
  3. Right below it you will find Policy insights. It lists the runs a rule in evaluate mode would have blocked.

On the upside, you see real runs instead of guesses. However, a workflow that has not run for weeks does not show up there. That is why a look at the code is worth it, too.

Search all repositories of an organization

With the GitHub CLI you can search all repositories of an organization for the trigger. In this example the fictional organization is called velo-verleih, a bike rental with a public app:

gh search code "pull_request_target path:.github/workflows" \
  --owner velo-verleih --limit 100 \
  --json repository,path \
  --jq '.[] | "\(.repository.nameWithOwner)  \(.path)"'

As a result, you get one line per match, with repository and file path. Code search only covers the default branch and does not return everything when there are many hits. As a first overview it is good enough, though.

Check workflows locally with a small scanner

On its own, however, a match does not tell you whether a workflow is dangerous. A labeler that only reads metadata is harmless. A preview build that checks out the PR code is not. That is why I wrote a small scanner for this distinction. First, it reads all YAML files in a folder; then it looks for the trigger and checks typical pwn request patterns.

// src/scan-workflows.ts
import { parse } from "yaml";

export type Severity = "ok" | "review" | "pwn-risk";

export interface Finding {
  file: string;
  severity: Severity;
  reasons: string[];
}

type Step = { uses?: string; run?: string; with?: Record<string, unknown> };
type Job = { steps?: Step[]; permissions?: unknown };
type Workflow = { on?: unknown; permissions?: unknown; jobs?: Record<string, Job> };

// Expressions that point to code from the pull request (possibly a fork)
const PR_HEAD_REF =
  /github\.event\.pull_request\.head\.(sha|ref|repo)|github\.head_ref|refs\/pull\//;

export function usesPullRequestTarget(on: unknown): boolean {
  if (typeof on === "string") return on === "pull_request_target";
  if (Array.isArray(on)) return on.includes("pull_request_target");
  if (on && typeof on === "object") return "pull_request_target" in on;
  return false;
}

export function scanWorkflow(file: string, source: string): Finding {
  const wf = (parse(source) ?? {}) as Workflow;
  if (!usesPullRequestTarget(wf.on)) {
    return { file, severity: "ok", reasons: [] };
  }

  const reasons: string[] = ["uses pull_request_target"];
  let pwnRisk = false;

  if (wf.permissions === undefined) {
    reasons.push("no top-level permissions block (token may have broad rights)");
  }

  for (const [jobName, job] of Object.entries(wf.jobs ?? {})) {
    for (const step of job.steps ?? []) {
      const uses = step.uses ?? "";
      const withArgs = step.with ?? {};
      if (uses.startsWith("actions/checkout")) {
        const ref = String(withArgs.ref ?? "");
        const repo = String(withArgs.repository ?? "");
        if (PR_HEAD_REF.test(ref) || PR_HEAD_REF.test(repo)) {
          pwnRisk = true;
          reasons.push(`job "${jobName}": checkout of pull request code`);
        }
        if (withArgs["allow-unsafe-pr-checkout"] === true) {
          pwnRisk = true;
          reasons.push(`job "${jobName}": allow-unsafe-pr-checkout is enabled`);
        }
      }
      const run = step.run ?? "";
      if (/gh pr checkout|git fetch[^\n]*(pull\/|head)/.test(run)) {
        pwnRisk = true;
        reasons.push(`job "${jobName}": fetches pull request code in a run step`);
      }
    }
  }

  return { file, severity: pwnRisk ? "pwn-risk" : "review", reasons };
}

In addition, the command line script for it is short. It also sets an exit code, so you can use the scanner in a pipeline later:

// src/cli.ts
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { scanWorkflow } from "./scan-workflows.ts";

const dir = process.argv[2] ?? ".github/workflows";
const files = (await readdir(dir)).filter((f) => /\.ya?ml$/.test(f));

let worst = 0;
for (const name of files) {
  const finding = scanWorkflow(name, await readFile(join(dir, name), "utf8"));
  if (finding.severity === "ok") continue;
  worst = Math.max(worst, finding.severity === "pwn-risk" ? 2 : 1);
  console.log(`${finding.severity.toUpperCase()}  ${name}`);
  for (const reason of finding.reasons) console.log(`   - ${reason}`);
}

if (worst === 0) console.log(`No pull_request_target workflows found in ${dir}.`);
// Exit code 2 = pwn risk, 1 = review needed, 0 = nothing found
process.exitCode = worst;

You need Node.js 22.18 or later, because the project runs TypeScript files directly. This is how you start it:

mkdir pull-request-target-check && cd pull-request-target-check
npm init -y && npm pkg set type=module
npm install [email protected]
mkdir src   # put scan-workflows.ts and cli.ts from this article here
node src/cli.ts ~/code/fahrradverleih-app/.github/workflows

For a repository with a risky preview workflow and a harmless labeler, the output looks like this:

PWN-RISK  preview.yml
   - uses pull_request_target
   - no top-level permissions block (token may have broad rights)
   - job "preview": checkout of pull request code
REVIEW  labeler.yml
   - uses pull_request_target

To be fair, the scanner is a heuristic. It does not catch shell tricks across several lines or composite actions that check something out internally. For a quick assessment of a few dozen workflows it still does the job. If you want more, also enable CodeQL for Actions, which GitHub recommends for such checks.

Step 2: Decide for each workflow

Next, go through every workflow on the list one by one. The question is always the same: does this workflow really need secrets or write access while it reacts to a pull request? Most of the time the answer is no.

What the workflow does My recommendation
Tests, linting, build without secrets move to pull_request
Build followed by a comment, status or upload with secrets split: pull_request plus workflow_run
Set labels, post a welcome message, check metadata keep pull_request_target and allow it explicitly
Preview deployment with cloud access for fork PRs rethink it: only after manual approval, or not for forks at all

Step 3: Move to pull_request

First of all, the simplest case is also the most common one. Many workflows only use pull_request_target because at some point someone wanted them to run for forks as well. However, pull_request does that too, just without secrets. For tests and linting that is exactly right:

 name: lint
 on:
-  pull_request_target:
+  pull_request:
     branches: [main]

 permissions:
   contents: read

Afterwards, check whether the workflow reads a secret anywhere. If it does, that step will fail for fork PRs from now on. Then it belongs in the privileged part from step 4.

Step 4: Split workflows with workflow_run

It gets trickier when a workflow needs both: build foreign code and then do something with privileges, such as posting a comment with the test coverage. My solution: separate these two jobs into two workflows. First, one workflow runs on pull_request, builds and tests without secrets and only stores data as an artifact. After that, a second one starts via workflow_run once the first has finished, and it has the required permissions. It reads the data but never executes code from it.

Flow: fork PR, pr-build.yml on pull_request without secrets, artifact with data, pr-comment.yml on workflow_run

The build runs without secrets, the comment workflow only reads data.

For example, here is the unprivileged part for the bike rental:

# .github/workflows/pr-build.yml
# Runs untrusted pull request code: read-only token, no secrets
name: pr-build
on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - run: npm ci
      - run: npm test -- --coverage > coverage.txt
      - name: Save results as data
        run: |
          mkdir -p result
          echo "${{ github.event.pull_request.number }}" > result/pr-number
          tail -n 5 coverage.txt > result/summary.txt
      - uses: actions/upload-artifact@v7
        with:
          name: pr-result
          path: result/
          retention-days: 3

The privileged part

And here is the privileged part. Watch for three details: the artifact goes to runner.temp instead of the workspace, the PR number is validated as an integer, and the comment text is only read and truncated, never executed.

# .github/workflows/pr-comment.yml
# Privileged part: may write comments, but never executes PR code
name: pr-comment
on:
  workflow_run:
    workflows: [pr-build]
    types: [completed]

permissions:
  contents: read
  pull-requests: write

jobs:
  comment:
    if: >
      github.event.workflow_run.event == 'pull_request' &&
      github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: pr-result
          path: ${{ runner.temp }}/pr-result
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - name: Validate and comment
        env:
          GH_TOKEN: ${{ github.token }}
          RESULT_DIR: ${{ runner.temp }}/pr-result
        run: |
          PR=$(cat "$RESULT_DIR/pr-number")
          if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
            echo "Invalid PR number" >&2
            exit 1
          fi
          {
            echo "### Test coverage"
            echo '```
{% endraw %}
'
            head -c 2000 "$RESULT_DIR/summary.txt"
            echo '
{% raw %}
```'
          } > "$RUNNER_TEMP/comment.md"
          gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/comment.md"

One important point: everything in the artifact comes from the attacker. The PR number could be forged, and the text can contain anything. Still, for a comment that is acceptable. Running a binary from the artifact, on the other hand, would be the same mistake as before, just one detour later. If you want to be completely safe, look up the PR number through the API based on the head commit instead of reading it from the artifact.

Step 5: Allow pull_request_target explicitly

Nevertheless, some workflows really need the trigger. The classic case is a labeler that tags pull requests based on the changed paths. It has to write to fork PRs, but it only reads metadata:

# .github/workflows/labeler.yml
# Legitimate use: only reads metadata, never checks out PR code
name: labeler
on:
  pull_request_target:
    types: [opened, synchronize]

permissions:
  contents: read
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/labeler@v7

Create the event policy

For workflows like this, create your own event policy and limit it to exactly this file. In a repository it works like this:

  1. Open Settings, choose Actions and then Policies in the sidebar, and create a new policy.
  2. Give it a descriptive name, for example “labeler may use pull_request_target”.
  3. As the target, enter only the workflow path .github/workflows/labeler.yml, not all workflows.
  4. Under Restrict events, allow every event this workflow reacts to, here pull_request_target.
  5. Set enforcement to Active and save. On Enterprise Cloud you can test with Evaluate first.

In particular, step four is easy to underestimate. An event policy is an allowlist: anything not listed is blocked for the targeted workflows. If your workflow also reacts to workflow_dispatch, add it to the list. You can also see existing policies in the repository through the API:

gh api repos/velo-verleih/fahrradverleih-app/actions/policies \
  --jq '.policies[] | "\(.name)  \(.enforcement)"'

For this you need a token with administration rights for the repository. In other words, better make the policy too narrow than too broad. A rule that allows pull_request_target for all workflows makes the new block pointless.

Step 6: Use the trial block on November 9

You can put the two trial blocks to good use. On November 9 between 16:00 and 18:00 UTC, open a pull request from a fork or a test account and watch what happens. When pull_request_target is blocked, the runs show up as failed with an error annotation, and the workflow itself does not start. After the window, workflows run again, just with warnings.

It also helps to give a short heads-up to everyone reviewing pull requests during those two hours. Otherwise someone spends an hour hunting a bug that is not one. On November 16 you repeat the test with the reworked workflows.

Common mistakes during the rework

Above all, the most common mistake is a policy that allows too much. If you enable pull_request_target for all workflows under time pressure, you will have peace on December 2 and the same hole as before. Better a workflow that does not run for a week than a deploy key that ends up with strangers.

Second, people tend to assume workflow_run is safe by design. It is not, because secrets are available there too, and if you unpack and run the artifact from the first workflow, you simply rebuild the pwn request. Third, old pull requests stay open that still contain the vulnerable workflow version. Close those PRs or ask for them to be rebased onto the current state.

A final point concerns permissions. Set a permissions block in every workflow and grant only what is really needed. Together with pinned actions from my article on the GitHub Actions lockfile, that is a solid foundation. The policy page can also enforce lockfiles, so the two topics belong closely together.

My take

Having pull_request_target blocked by default is overdue in my view. pull_request_target has been a known trap for years, and documentation alone was clearly not enough. Secure by default, with exceptions only on purpose: to me that is the right direction, whether for cloud permissions or CI pipelines.

On the other hand, I see the short lead time for small open source projects critically. If you maintain a project in your spare time, you do not read every changelog entry. For teams in companies it is the opposite: there is no excuse. Take an hour, go through the list, and you have made your pipeline safer along the way. If your team is thinking about AI agents in GitHub Actions anyway, now is a good moment to clean up the permissions of all workflows once.

FAQ

Are private repositories affected by the block?

No. The default rule only applies to public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories as well as GitHub Enterprise Server are not affected.

What is the difference between pull_request and pull_request_target?

For fork PRs, pull_request runs with a read-only token and without secrets. pull_request_target executes as if it belonged to the target repository, with secrets and write access, and uses the workflow file from the default branch.

What happens during the trial blocks in November?

On November 9 and 16, each from 16:00 to 18:00 UTC, affected runs fail with an error annotation and the workflows do not start. Afterwards, evaluate mode with warnings applies again.

Is upgrading to actions/checkout v7 enough?

It helps, because v7 no longer checks out fork code under these triggers by default. It does not protect against gh pr checkout, git fetch or executed artifacts, though, and it changes nothing about the block from December 2.

Your next step

Run the search from step 1 for your organization this week and put every hit into a small table with one of the four recommendations. After that you know whether November 9 affects you at all. And I am curious: did you find a workflow that really needs pull_request_target, or was it convenience all along? Let me know in the comments.

Sources

This post was created with AI assistance.

👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.